feat: Initial Hub Dashboard with email, calendar, and tasks

AI-powered unified business workspace for LetsBe Cloud tenants.
- Next.js 15 (App Router) with Keycloak SSO via NextAuth.js v5
- Email client: IMAP/SMTP via ImapFlow + nodemailer (inbox, compose, search, folders)
- Calendar: CalDAV via tsdav + Schedule-X (month/week/day views, event CRUD)
- Task management: Vikunja API integration (list view, kanban, filters)
- Responsive shell with sidebar navigation and activity feed
- Docker deployment ready

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-07 08:03:25 +01:00
commit 615a36eb20
97 changed files with 8897 additions and 0 deletions

29
.env.example Normal file
View File

@ -0,0 +1,29 @@
# Authentication
NEXTAUTH_URL=https://hub.example.com
NEXTAUTH_SECRET=generate-a-secret-here
# Keycloak SSO
KEYCLOAK_CLIENT_ID=hub-dashboard
KEYCLOAK_CLIENT_SECRET=your-client-secret
KEYCLOAK_ISSUER=https://auth.example.com/realms/letsbe
# Orchestrator API (same server)
ORCHESTRATOR_URL=http://orchestrator:8100
# Tenant domain (set during provisioning)
TENANT_DOMAIN=example.com
# Server IP
SERVER_IP=0.0.0.0
# IMAP/SMTP (Poste.io on same server)
IMAP_HOST=mail.example.com
IMAP_PORT=993
SMTP_HOST=mail.example.com
SMTP_PORT=587
# CalDAV (Nextcloud on same server)
CALDAV_URL=https://cloud.example.com/remote.php/dav
# Vikunja Task Manager
VIKUNJA_URL=https://tasks.example.com/api/v1

34
.gitignore vendored Normal file
View File

@ -0,0 +1,34 @@
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

44
Dockerfile Normal file
View File

@ -0,0 +1,44 @@
# Stage 1: Install dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --only=production
# Stage 2: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# Stage 3: Production
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permissions for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3002
ENV PORT=3002
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

30
docker-compose.yml Normal file
View File

@ -0,0 +1,30 @@
services:
hub-dashboard:
build: .
container_name: letsbe-hub-dashboard
restart: unless-stopped
ports:
- "3002:3002"
environment:
- NEXTAUTH_URL=${NEXTAUTH_URL}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-hub-dashboard}
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET}
- KEYCLOAK_ISSUER=${KEYCLOAK_ISSUER}
- ORCHESTRATOR_URL=${ORCHESTRATOR_URL:-http://orchestrator:8100}
- TENANT_DOMAIN=${TENANT_DOMAIN}
- SERVER_IP=${SERVER_IP}
- IMAP_HOST=${IMAP_HOST:-mail.${TENANT_DOMAIN}}
- IMAP_PORT=${IMAP_PORT:-993}
- SMTP_HOST=${SMTP_HOST:-mail.${TENANT_DOMAIN}}
- SMTP_PORT=${SMTP_PORT:-587}
- MAIL_USER=${MAIL_USER}
- MAIL_PASSWORD=${MAIL_PASSWORD}
- CALDAV_URL=${CALDAV_URL:-https://cloud.${TENANT_DOMAIN}/remote.php/dav}
- VIKUNJA_URL=${VIKUNJA_URL:-https://tasks.${TENANT_DOMAIN}/api/v1}
networks:
- letsbe
networks:
letsbe:
external: true

46
next.config.ts Normal file
View File

@ -0,0 +1,46 @@
import type { NextConfig } from 'next'
const securityHeaders = [
{
key: 'X-DNS-Prefetch-Control',
value: 'on',
},
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'X-Frame-Options',
value: 'SAMEORIGIN',
},
{
key: 'X-XSS-Protection',
value: '1; mode=block',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=()',
},
]
const nextConfig: NextConfig = {
output: 'standalone',
async headers() {
return [
{
source: '/(.*)',
headers: securityHeaders,
},
]
},
}
export default nextConfig

59
package.json Normal file
View File

@ -0,0 +1,59 @@
{
"name": "letsbe-hub-dashboard",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev --port 3002",
"build": "next build",
"start": "next start --port 3002",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-progress": "^1.1.4",
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"next": "15.1.8",
"next-auth": "5.0.0-beta.30",
"react": "19.0.0",
"react-dom": "19.0.0",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.1",
"imapflow": "^1.0.0",
"nodemailer": "^7.0.0",
"mailparser": "^3.7.0",
"tsdav": "^2.0.0",
"@schedule-x/react": "^2.0.0",
"@schedule-x/calendar": "^2.0.0",
"@schedule-x/events-service": "^2.0.0",
"@schedule-x/drag-and-drop": "^2.0.0",
"@schedule-x/theme-default": "^2.0.0",
"@tiptap/react": "^2.0.0",
"@tiptap/starter-kit": "^2.0.0",
"dompurify": "^3.2.0"
},
"devDependencies": {
"@types/dompurify": "^3.2.0",
"@types/node": "^22.10.5",
"@types/nodemailer": "^6.4.17",
"@types/react": "^19.0.4",
"@types/react-dom": "^19.0.2",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint-config-next": "15.1.8",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3"
}
}

9
postcss.config.js Normal file
View File

@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
module.exports = config

View File

@ -0,0 +1,2 @@
import { handlers } from '@/auth'
export const { GET, POST } = handlers

View File

@ -0,0 +1,21 @@
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getCalendars } from '@/lib/caldav-client'
export async function GET() {
const session = await auth()
if (!session?.user || !session.accessToken) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const calendars = await getCalendars(session.accessToken)
return NextResponse.json(calendars)
} catch (error) {
console.error('Failed to fetch calendars:', error)
return NextResponse.json(
{ error: 'Failed to fetch calendars' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { updateEvent, deleteEvent } from '@/lib/caldav-client'
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session?.user || !session.accessToken) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { id } = await params
const body = await request.json()
const { title, start, end, allDay, location, description, recurrence, eventUrl } = body
if (!eventUrl) {
return NextResponse.json(
{ error: 'eventUrl is required' },
{ status: 400 }
)
}
await updateEvent(session.accessToken, eventUrl, {
title,
start,
end,
allDay,
location,
description,
recurrence,
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Failed to update event:', error)
return NextResponse.json(
{ error: 'Failed to update event' },
{ status: 500 }
)
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session?.user || !session.accessToken) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { searchParams } = request.nextUrl
const eventUrl = searchParams.get('eventUrl')
if (!eventUrl) {
return NextResponse.json(
{ error: 'eventUrl query parameter is required' },
{ status: 400 }
)
}
await deleteEvent(session.accessToken, eventUrl)
return NextResponse.json({ success: true })
} catch (error) {
console.error('Failed to delete event:', error)
return NextResponse.json(
{ error: 'Failed to delete event' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getEvents, createEvent } from '@/lib/caldav-client'
export async function GET(request: NextRequest) {
const session = await auth()
if (!session?.user || !session.accessToken) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = request.nextUrl
const start = searchParams.get('start')
const end = searchParams.get('end')
const calendarId = searchParams.get('calendarId') || undefined
if (!start || !end) {
return NextResponse.json(
{ error: 'start and end query parameters are required' },
{ status: 400 }
)
}
try {
const events = await getEvents(session.accessToken, start, end, calendarId)
return NextResponse.json(events)
} catch (error) {
console.error('Failed to fetch events:', error)
return NextResponse.json(
{ error: 'Failed to fetch events' },
{ status: 500 }
)
}
}
export async function POST(request: NextRequest) {
const session = await auth()
if (!session?.user || !session.accessToken) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const { title, start, end, allDay, location, description, calendarUrl, recurrence } = body
if (!title || !start || !end || !calendarUrl) {
return NextResponse.json(
{ error: 'title, start, end, and calendarUrl are required' },
{ status: 400 }
)
}
const event = await createEvent(session.accessToken, {
title,
start,
end,
allDay,
location,
description,
calendarUrl,
recurrence,
})
return NextResponse.json(event, { status: 201 })
} catch (error) {
console.error('Failed to create event:', error)
return NextResponse.json(
{ error: 'Failed to create event' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,18 @@
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import { listFolders } from '@/lib/imap-client'
export async function GET() {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const folders = await listFolders()
return NextResponse.json(folders)
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to list folders'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@ -0,0 +1,116 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getMessage, updateFlags, moveMessage, deleteMessage } from '@/lib/imap-client'
import { z } from 'zod'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ uid: string }> }
) {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { uid } = await params
const folder = request.nextUrl.searchParams.get('folder') || 'INBOX'
const uidNum = Number(uid)
if (!uidNum || uidNum < 1) {
return NextResponse.json({ error: 'Invalid UID' }, { status: 400 })
}
try {
const message = await getMessage(folder, uidNum)
if (!message) {
return NextResponse.json({ error: 'Message not found' }, { status: 404 })
}
return NextResponse.json(message)
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to get message'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
const patchSchema = z.object({
action: z.enum(['markRead', 'markUnread', 'star', 'unstar', 'move']),
folder: z.string().optional(),
destination: z.string().optional(),
})
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ uid: string }> }
) {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { uid } = await params
const uidNum = Number(uid)
if (!uidNum || uidNum < 1) {
return NextResponse.json({ error: 'Invalid UID' }, { status: 400 })
}
try {
const body = await request.json()
const parsed = patchSchema.parse(body)
const folder = parsed.folder || 'INBOX'
switch (parsed.action) {
case 'markRead':
await updateFlags(folder, uidNum, 'add', ['\\Seen'])
break
case 'markUnread':
await updateFlags(folder, uidNum, 'remove', ['\\Seen'])
break
case 'star':
await updateFlags(folder, uidNum, 'add', ['\\Flagged'])
break
case 'unstar':
await updateFlags(folder, uidNum, 'remove', ['\\Flagged'])
break
case 'move':
if (!parsed.destination) {
return NextResponse.json({ error: 'Destination required for move' }, { status: 400 })
}
await moveMessage(folder, uidNum, parsed.destination)
break
}
return NextResponse.json({ success: true })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid request', details: error.errors }, { status: 400 })
}
const msg = error instanceof Error ? error.message : 'Failed to update message'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ uid: string }> }
) {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { uid } = await params
const folder = request.nextUrl.searchParams.get('folder') || 'INBOX'
const uidNum = Number(uid)
if (!uidNum || uidNum < 1) {
return NextResponse.json({ error: 'Invalid UID' }, { status: 400 })
}
try {
await deleteMessage(folder, uidNum)
return NextResponse.json({ success: true })
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to delete message'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { listMessages } from '@/lib/imap-client'
import { sendEmail, SendEmailOptions } from '@/lib/smtp-client'
import { z } from 'zod'
export async function GET(request: NextRequest) {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const folder = request.nextUrl.searchParams.get('folder') || 'INBOX'
const page = Math.max(1, Number(request.nextUrl.searchParams.get('page')) || 1)
const limit = Math.min(100, Math.max(1, Number(request.nextUrl.searchParams.get('limit')) || 50))
try {
const result = await listMessages(folder, page, limit)
return NextResponse.json(result)
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to list messages'
return NextResponse.json({ error: message }, { status: 500 })
}
}
const sendSchema = z.object({
to: z.string().email(),
cc: z.string().optional(),
bcc: z.string().optional(),
subject: z.string().min(1),
html: z.string(),
text: z.string().optional(),
inReplyTo: z.string().optional(),
references: z.array(z.string()).optional(),
attachments: z.array(z.object({
filename: z.string(),
content: z.string(),
contentType: z.string(),
})).optional(),
})
export async function POST(request: NextRequest) {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const parsed = sendSchema.parse(body)
const result = await sendEmail(parsed as SendEmailOptions)
return NextResponse.json(result)
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid request', details: error.errors }, { status: 400 })
}
const message = error instanceof Error ? error.message : 'Failed to send email'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { searchMessages } from '@/lib/imap-client'
export async function GET(request: NextRequest) {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const q = request.nextUrl.searchParams.get('q')
const folder = request.nextUrl.searchParams.get('folder') || 'INBOX'
if (!q || q.trim().length === 0) {
return NextResponse.json({ error: 'Search query required' }, { status: 400 })
}
try {
const messages = await searchMessages(q.trim(), folder)
return NextResponse.json({ messages })
} catch (error) {
const message = error instanceof Error ? error.message : 'Search failed'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
const ORCHESTRATOR_URL = process.env.ORCHESTRATOR_URL || 'http://orchestrator:8100'
async function proxyRequest(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { path } = await params
const targetPath = path.join('/')
const url = new URL(`/api/v1/${targetPath}`, ORCHESTRATOR_URL)
// Forward query parameters
request.nextUrl.searchParams.forEach((value, key) => {
url.searchParams.set(key, value)
})
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
// Forward the access token if available
if (session.accessToken) {
headers['Authorization'] = `Bearer ${session.accessToken}`
}
const fetchOptions: RequestInit = {
method: request.method,
headers,
}
// Forward body for non-GET requests
if (request.method !== 'GET' && request.method !== 'HEAD') {
try {
const body = await request.text()
if (body) {
fetchOptions.body = body
}
} catch {
// No body to forward
}
}
try {
const response = await fetch(url.toString(), fetchOptions)
const data = await response.text()
return new NextResponse(data, {
status: response.status,
headers: {
'Content-Type': response.headers.get('Content-Type') || 'application/json',
},
})
} catch (error) {
return NextResponse.json(
{ error: 'Failed to connect to orchestrator' },
{ status: 502 }
)
}
}
export const GET = proxyRequest
export const POST = proxyRequest
export const PUT = proxyRequest
export const PATCH = proxyRequest
export const DELETE = proxyRequest

View File

@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { moveTaskToBucket } from '@/lib/vikunja-client'
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { id } = await params
const taskId = parseInt(id, 10)
const body = await request.json()
const { projectId, viewId, bucketId, position } = body
if (!projectId || !viewId || !bucketId) {
return NextResponse.json(
{ error: 'projectId, viewId, and bucketId are required' },
{ status: 400 }
)
}
const task = await moveTaskToBucket(projectId, viewId, taskId, bucketId, position ?? 0)
return NextResponse.json(task)
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to move task'
return NextResponse.json({ error: message }, { status: 502 })
}
}

View File

@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { updateTask, deleteTask } from '@/lib/vikunja-client'
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { id } = await params
const taskId = parseInt(id, 10)
const body = await request.json()
const task = await updateTask(taskId, body)
return NextResponse.json(task)
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to update task'
return NextResponse.json({ error: message }, { status: 502 })
}
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { id } = await params
const taskId = parseInt(id, 10)
await deleteTask(taskId)
return NextResponse.json({ success: true })
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to delete task'
return NextResponse.json({ error: message }, { status: 502 })
}
}

View File

@ -0,0 +1,18 @@
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getLabels } from '@/lib/vikunja-client'
export async function GET() {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const labels = await getLabels()
return NextResponse.json(labels)
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch labels'
return NextResponse.json({ error: message }, { status: 502 })
}
}

View File

@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getProject, getProjectBuckets } from '@/lib/vikunja-client'
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { id } = await params
const projectId = parseInt(id, 10)
const project = await getProject(projectId)
// Find the kanban view (view_kind=3) or first view
const kanbanView = project.views?.find(v => v.view_kind === 3)
const listView = project.views?.find(v => v.view_kind === 0)
const defaultView = kanbanView || listView || project.views?.[0]
let buckets: unknown[] = []
if (defaultView) {
try {
buckets = await getProjectBuckets(projectId, defaultView.id)
} catch {
// Buckets may not exist for all view types
}
}
return NextResponse.json({
...project,
buckets,
kanbanViewId: kanbanView?.id || null,
listViewId: listView?.id || null,
defaultViewId: defaultView?.id || null,
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch project'
return NextResponse.json({ error: message }, { status: 502 })
}
}

View File

@ -0,0 +1,34 @@
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getProjects, createProject } from '@/lib/vikunja-client'
export async function GET() {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const projects = await getProjects()
return NextResponse.json(projects)
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch projects'
return NextResponse.json({ error: message }, { status: 502 })
}
}
export async function POST(request: Request) {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const project = await createProject(body)
return NextResponse.json(project, { status: 201 })
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create project'
return NextResponse.json({ error: message }, { status: 502 })
}
}

View File

@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getAllTasks, createTask } from '@/lib/vikunja-client'
export async function GET(request: NextRequest) {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { searchParams } = request.nextUrl
const tasks = await getAllTasks({
page: searchParams.get('page') ? parseInt(searchParams.get('page')!, 10) : undefined,
per_page: searchParams.get('per_page') ? parseInt(searchParams.get('per_page')!, 10) : 50,
sort_by: searchParams.get('sort_by') || undefined,
order_by: searchParams.get('order_by') || undefined,
filter: searchParams.get('filter') || undefined,
})
return NextResponse.json(tasks)
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch tasks'
return NextResponse.json({ error: message }, { status: 502 })
}
}
export async function POST(request: Request) {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const { projectId, ...taskData } = body
if (!projectId) {
return NextResponse.json({ error: 'projectId is required' }, { status: 400 })
}
const task = await createTask(projectId, taskData)
return NextResponse.json(task, { status: 201 })
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create task'
return NextResponse.json({ error: message }, { status: 502 })
}
}

View File

@ -0,0 +1,238 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import { ArrowLeft, Clock, MapPin, Calendar, Repeat, Loader2, Trash2, Pencil } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { EventDialog, EventFormData } from '@/components/calendar/event-dialog'
import type { CalendarInfo, CalendarEvent } from '@/lib/caldav-client'
export default function EventDetailPage() {
const params = useParams()
const router = useRouter()
const searchParams = useSearchParams()
const eventUrl = searchParams.get('url') || ''
const [event, setEvent] = useState<CalendarEvent | null>(null)
const [calendars, setCalendars] = useState<CalendarInfo[]>([])
const [loading, setLoading] = useState(true)
const [editOpen, setEditOpen] = useState(false)
useEffect(() => {
loadData()
}, [])
async function loadData() {
setLoading(true)
try {
// Fetch calendars
const calRes = await fetch('/api/calendar/calendars')
if (calRes.ok) {
const calData: CalendarInfo[] = await calRes.json()
setCalendars(calData)
}
// Fetch events in a wide range to find the target event
const now = new Date()
const start = new Date(now.getFullYear() - 1, 0, 1).toISOString()
const end = new Date(now.getFullYear() + 1, 11, 31).toISOString()
const evtRes = await fetch(
`/api/calendar/events?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`
)
if (evtRes.ok) {
const evtData: CalendarEvent[] = await evtRes.json()
const found = evtData.find(
(e) => e.id === params.id || e.url === eventUrl
)
setEvent(found || null)
}
} catch (err) {
console.error('Failed to load event:', err)
} finally {
setLoading(false)
}
}
async function handleSave(formData: EventFormData) {
if (!event) return
try {
await fetch(`/api/calendar/events/${encodeURIComponent(event.id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...formData,
eventUrl: event.url,
}),
})
setEditOpen(false)
await loadData()
} catch (err) {
console.error('Failed to update event:', err)
}
}
async function handleDelete() {
if (!event) return
try {
await fetch(
`/api/calendar/events/${encodeURIComponent(event.id)}?eventUrl=${encodeURIComponent(event.url)}`,
{ method: 'DELETE' }
)
router.push('/calendar')
} catch (err) {
console.error('Failed to delete event:', err)
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
if (!event) {
return (
<div className="space-y-4">
<Button variant="ghost" onClick={() => router.push('/calendar')}>
<ArrowLeft className="mr-1 h-4 w-4" />
Back to Calendar
</Button>
<div className="rounded-lg border p-6 text-center text-muted-foreground">
Event not found
</div>
</div>
)
}
const calendarName =
calendars.find((c) => c.url === event.calendarUrl)?.displayName ||
'Calendar'
function formatEventDate(dateStr: string, allDay: boolean): string {
if (allDay) {
return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
const d = new Date(dateStr.replace(' ', 'T'))
return d.toLocaleString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
}
const recurrenceLabel: Record<string, string> = {
'FREQ=DAILY': 'Repeats daily',
'FREQ=WEEKLY': 'Repeats weekly',
'FREQ=MONTHLY': 'Repeats monthly',
'FREQ=YEARLY': 'Repeats yearly',
}
return (
<div className="space-y-4">
<Button variant="ghost" onClick={() => router.push('/calendar')}>
<ArrowLeft className="mr-1 h-4 w-4" />
Back to Calendar
</Button>
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-2xl">{event.title}</CardTitle>
<div className="mt-2 flex items-center gap-2">
<Badge variant="secondary">
<Calendar className="mr-1 h-3 w-3" />
{calendarName}
</Badge>
{event.allDay && (
<Badge variant="outline">All day</Badge>
)}
{event.status && (
<Badge variant="outline">{event.status}</Badge>
)}
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setEditOpen(true)}
>
<Pencil className="mr-1 h-3 w-3" />
Edit
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleDelete}
>
<Trash2 className="mr-1 h-3 w-3" />
Delete
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-3">
<Clock className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div>
<p className="text-sm font-medium">
{formatEventDate(event.start, event.allDay)}
</p>
{event.start !== event.end && (
<p className="text-sm text-muted-foreground">
to {formatEventDate(event.end, event.allDay)}
</p>
)}
</div>
</div>
{event.location && (
<div className="flex items-start gap-3">
<MapPin className="mt-0.5 h-4 w-4 text-muted-foreground" />
<p className="text-sm">{event.location}</p>
</div>
)}
{event.recurrence && (
<div className="flex items-start gap-3">
<Repeat className="mt-0.5 h-4 w-4 text-muted-foreground" />
<p className="text-sm">
{recurrenceLabel[event.recurrence] || event.recurrence}
</p>
</div>
)}
{event.description && (
<div className="border-t pt-4">
<p className="text-sm whitespace-pre-wrap">{event.description}</p>
</div>
)}
</CardContent>
</Card>
<EventDialog
open={editOpen}
onClose={() => setEditOpen(false)}
onSave={handleSave}
onDelete={handleDelete}
event={event}
calendars={calendars}
/>
</div>
)
}

View File

@ -0,0 +1,7 @@
export default function CalendarLayout({
children,
}: {
children: React.ReactNode
}) {
return <>{children}</>
}

358
src/app/calendar/page.tsx Normal file
View File

@ -0,0 +1,358 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Calendar, Plus, RefreshCw, Loader2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { CalendarView } from '@/components/calendar/calendar-view'
import { EventDialog, EventFormData } from '@/components/calendar/event-dialog'
import { CalendarSidebar } from '@/components/calendar/calendar-sidebar'
import { MiniCalendar } from '@/components/calendar/mini-calendar'
import { EventCard } from '@/components/calendar/event-card'
import type { CalendarInfo, CalendarEvent } from '@/lib/caldav-client'
const DEFAULT_COLORS = [
'#3b82f6', '#ef4444', '#22c55e', '#f59e0b', '#8b5cf6',
'#ec4899', '#06b6d4', '#f97316',
]
export default function CalendarPage() {
const [calendars, setCalendars] = useState<CalendarInfo[]>([])
const [events, setEvents] = useState<CalendarEvent[]>([])
const [loading, setLoading] = useState(true)
const [eventsLoading, setEventsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [selectedDate, setSelectedDate] = useState(
new Date().toISOString().split('T')[0]
)
const [dateRange, setDateRange] = useState<{ start: string; end: string }>({
start: getMonthStart(new Date()),
end: getMonthEnd(new Date()),
})
const [visibleCalendars, setVisibleCalendars] = useState<Set<string>>(
new Set()
)
const [calendarColors, setCalendarColors] = useState<Record<string, string>>(
{}
)
// Dialog state
const [dialogOpen, setDialogOpen] = useState(false)
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null)
const [defaultDialogDate, setDefaultDialogDate] = useState<string>('')
// Build color map
useEffect(() => {
const colors: Record<string, string> = {}
calendars.forEach((cal, idx) => {
colors[cal.url] = cal.color || DEFAULT_COLORS[idx % DEFAULT_COLORS.length]
})
setCalendarColors(colors)
}, [calendars])
// Fetch calendars on mount
useEffect(() => {
fetchCalendars()
}, [])
// Fetch events when date range or visible calendars change
useEffect(() => {
if (calendars.length > 0 && dateRange.start && dateRange.end) {
fetchEvents()
}
}, [dateRange, calendars])
async function fetchCalendars() {
setLoading(true)
setError(null)
try {
const res = await fetch('/api/calendar/calendars')
if (!res.ok) throw new Error('Failed to fetch calendars')
const data: CalendarInfo[] = await res.json()
setCalendars(data)
setVisibleCalendars(new Set(data.map((c) => c.url)))
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load calendars')
} finally {
setLoading(false)
}
}
async function fetchEvents() {
setEventsLoading(true)
try {
const params = new URLSearchParams({
start: dateRange.start,
end: dateRange.end,
})
const res = await fetch(`/api/calendar/events?${params}`)
if (!res.ok) throw new Error('Failed to fetch events')
const data: CalendarEvent[] = await res.json()
setEvents(data)
} catch (err) {
console.error('Failed to fetch events:', err)
} finally {
setEventsLoading(false)
}
}
function handleToggleCalendar(url: string) {
setVisibleCalendars((prev) => {
const next = new Set(prev)
if (next.has(url)) {
next.delete(url)
} else {
next.add(url)
}
return next
})
}
function handleEventClick(event: CalendarEvent) {
setEditingEvent(event)
setDialogOpen(true)
}
function handleDateClick(date: string) {
setDefaultDialogDate(date)
setEditingEvent(null)
setDialogOpen(true)
}
async function handleEventUpdate(
event: CalendarEvent,
newStart: string,
newEnd: string
) {
try {
await fetch(`/api/calendar/events/${encodeURIComponent(event.id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: event.title,
start: newStart,
end: newEnd,
allDay: event.allDay,
location: event.location,
description: event.description,
eventUrl: event.url,
}),
})
await fetchEvents()
} catch (err) {
console.error('Failed to update event:', err)
}
}
function handleRangeChange(start: string, end: string) {
setDateRange({ start, end })
}
async function handleSaveEvent(formData: EventFormData) {
try {
if (editingEvent) {
await fetch(
`/api/calendar/events/${encodeURIComponent(editingEvent.id)}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...formData,
eventUrl: editingEvent.url,
}),
}
)
} else {
await fetch('/api/calendar/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
})
}
setDialogOpen(false)
setEditingEvent(null)
await fetchEvents()
} catch (err) {
console.error('Failed to save event:', err)
}
}
async function handleDeleteEvent() {
if (!editingEvent) return
try {
await fetch(
`/api/calendar/events/${encodeURIComponent(editingEvent.id)}?eventUrl=${encodeURIComponent(editingEvent.url)}`,
{ method: 'DELETE' }
)
setDialogOpen(false)
setEditingEvent(null)
await fetchEvents()
} catch (err) {
console.error('Failed to delete event:', err)
}
}
// Get today's events for the sidebar
const todayEvents = events
.filter((e) => {
const eventDate = e.start.split('T')[0].split(' ')[0]
return eventDate === selectedDate && visibleCalendars.has(e.calendarUrl)
})
.sort((a, b) => a.start.localeCompare(b.start))
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
if (error) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Calendar</h1>
<p className="text-muted-foreground">
Manage your schedule and events
</p>
</div>
</div>
<div className="rounded-lg border border-destructive/50 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">{error}</p>
<Button
variant="outline"
size="sm"
className="mt-3"
onClick={fetchCalendars}
>
<RefreshCw className="mr-1 h-3 w-3" />
Retry
</Button>
</div>
</div>
)
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Calendar</h1>
<p className="text-muted-foreground">
Manage your schedule and events
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary">
<Calendar className="mr-1 h-3 w-3" />
CalDAV
</Badge>
<Button
variant="outline"
size="icon"
onClick={fetchEvents}
disabled={eventsLoading}
title="Refresh events"
>
<RefreshCw
className={`h-4 w-4 ${eventsLoading ? 'animate-spin' : ''}`}
/>
</Button>
<Button
onClick={() => {
setEditingEvent(null)
setDefaultDialogDate('')
setDialogOpen(true)
}}
>
<Plus className="mr-1 h-4 w-4" />
New Event
</Button>
</div>
</div>
{/* Main layout */}
<div className="flex gap-4">
{/* Left sidebar */}
<div className="hidden w-56 shrink-0 space-y-6 lg:block">
<MiniCalendar
selectedDate={selectedDate}
onSelectDate={setSelectedDate}
/>
<CalendarSidebar
calendars={calendars}
visibleCalendars={visibleCalendars}
onToggleCalendar={handleToggleCalendar}
calendarColors={calendarColors}
/>
{todayEvents.length > 0 && (
<div className="space-y-2">
<h3 className="px-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Today&apos;s Events
</h3>
<div className="space-y-1">
{todayEvents.map((evt) => (
<EventCard
key={evt.id}
event={evt}
color={calendarColors[evt.calendarUrl]}
onClick={handleEventClick}
compact
/>
))}
</div>
</div>
)}
</div>
{/* Calendar view */}
<div className="min-w-0 flex-1">
<CalendarView
events={events}
onEventClick={handleEventClick}
onDateClick={handleDateClick}
onEventUpdate={handleEventUpdate}
onRangeChange={handleRangeChange}
selectedDate={selectedDate}
visibleCalendars={visibleCalendars}
calendarColors={calendarColors}
/>
</div>
</div>
{/* Event dialog */}
<EventDialog
open={dialogOpen}
onClose={() => {
setDialogOpen(false)
setEditingEvent(null)
}}
onSave={handleSaveEvent}
onDelete={editingEvent ? handleDeleteEvent : undefined}
event={editingEvent}
calendars={calendars}
defaultDate={defaultDialogDate}
defaultCalendarUrl={calendars[0]?.url}
/>
</div>
)
}
function getMonthStart(date: Date): string {
const d = new Date(date.getFullYear(), date.getMonth(), 1)
// Include last week of previous month for calendar grid
d.setDate(d.getDate() - 7)
return d.toISOString()
}
function getMonthEnd(date: Date): string {
const d = new Date(date.getFullYear(), date.getMonth() + 1, 0)
// Include first week of next month for calendar grid
d.setDate(d.getDate() + 7)
return d.toISOString()
}

View File

@ -0,0 +1,85 @@
'use client'
import { useState, useEffect, use } from 'react'
import { useRouter } from 'next/navigation'
import { Separator } from '@/components/ui/separator'
import { MessageView, FullMessageData } from '@/components/email/message-view'
import { EmailToolbar } from '@/components/email/email-toolbar'
export default function MessagePage({
params,
}: {
params: Promise<{ folder: string; uid: string }>
}) {
const { folder: encodedFolder, uid } = use(params)
const folder = decodeURIComponent(encodedFolder)
const router = useRouter()
const [message, setMessage] = useState<FullMessageData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
async function load() {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams({ folder })
const res = await fetch(`/api/email/messages/${uid}?${params}`)
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Failed to load message')
}
const data = await res.json()
setMessage(data)
// Mark as read automatically
if (data && !data.flags?.includes('\\Seen')) {
fetch(`/api/email/messages/${uid}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'markRead', folder }),
}).catch(() => {})
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load message')
} finally {
setLoading(false)
}
}
load()
}, [uid, folder])
if (error) {
return (
<div className="p-6 text-sm text-destructive">{error}</div>
)
}
return (
<div className="flex flex-col h-full">
{message && (
<div className="px-4 py-2 border-b">
<EmailToolbar
uid={message.uid}
folder={folder}
flags={message.flags}
from={message.from}
subject={message.subject}
messageId={message.messageId}
references={message.references}
onFlagsChange={(flags) =>
setMessage((prev) => (prev ? { ...prev, flags } : prev))
}
/>
</div>
)}
<div className="flex-1 overflow-auto">
<MessageView
message={message}
loading={loading}
onBack={() => router.push(`/email/${encodeURIComponent(folder)}`)}
/>
</div>
</div>
)
}

View File

@ -0,0 +1,138 @@
'use client'
import { useState, useEffect, useCallback, use } from 'react'
import { useSearchParams } from 'next/navigation'
import { RefreshCw, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { MessageList, MessageListItem } from '@/components/email/message-list'
import { SearchBar } from '@/components/email/search-bar'
export default function FolderPage({
params,
}: {
params: Promise<{ folder: string }>
}) {
const { folder: encodedFolder } = use(params)
const folder = decodeURIComponent(encodedFolder)
const searchParams = useSearchParams()
const searchQuery = searchParams.get('q') || ''
const [messages, setMessages] = useState<MessageListItem[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState<string | null>(null)
const loadMessages = useCallback(
async (pageNum: number, append = false) => {
if (append) {
setLoadingMore(true)
} else {
setLoading(true)
}
setError(null)
try {
let res: Response
if (searchQuery) {
const params = new URLSearchParams({ q: searchQuery, folder })
res = await fetch(`/api/email/search?${params}`)
} else {
const params = new URLSearchParams({
folder,
page: String(pageNum),
limit: '50',
})
res = await fetch(`/api/email/messages?${params}`)
}
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Failed to load messages')
}
const data = await res.json()
const msgs = data.messages || []
const tot = data.total ?? msgs.length
if (append) {
setMessages((prev) => [...prev, ...msgs])
} else {
setMessages(msgs)
}
setTotal(tot)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load messages')
} finally {
setLoading(false)
setLoadingMore(false)
}
},
[folder, searchQuery]
)
useEffect(() => {
setPage(1)
loadMessages(1)
}, [loadMessages])
const handleLoadMore = () => {
const nextPage = page + 1
setPage(nextPage)
loadMessages(nextPage, true)
}
const folderName = folder === 'INBOX' ? 'Inbox' : decodeURIComponent(folder).split('/').pop() || folder
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-3 p-4 border-b">
<h2 className="text-lg font-semibold shrink-0">{folderName}</h2>
<div className="flex-1">
<SearchBar folder={folder} defaultQuery={searchQuery} />
</div>
<Button
variant="ghost"
size="icon"
onClick={() => {
setPage(1)
loadMessages(1)
}}
title="Refresh"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
{searchQuery && (
<div className="px-4 py-2 bg-accent/50 text-sm">
Search results for &quot;{searchQuery}&quot;
{!loading && ` - ${messages.length} found`}
</div>
)}
{error && <div className="p-4 text-sm text-destructive">{error}</div>}
<div className="flex-1 overflow-auto">
<MessageList messages={messages} folder={folder} loading={loading} />
</div>
{!searchQuery && messages.length < total && !loading && (
<div className="p-4 border-t text-center">
<Button
variant="outline"
size="sm"
onClick={handleLoadMore}
disabled={loadingMore}
>
{loadingMore ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Load more ({messages.length} of {total})
</Button>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,44 @@
'use client'
import { useSearchParams, useRouter } from 'next/navigation'
import { Suspense } from 'react'
import { ComposeForm } from '@/components/email/compose-form'
function ComposeContent() {
const searchParams = useSearchParams()
const router = useRouter()
const to = searchParams.get('to') || ''
const subject = searchParams.get('subject') || ''
const inReplyTo = searchParams.get('inReplyTo') || undefined
let references: string[] | undefined
try {
const refsParam = searchParams.get('references')
if (refsParam) references = JSON.parse(refsParam)
} catch {
// Ignore invalid JSON
}
return (
<div className="p-6">
<h2 className="text-lg font-semibold mb-4">
{inReplyTo ? 'Reply' : subject?.startsWith('Fwd:') ? 'Forward' : 'New Message'}
</h2>
<ComposeForm
defaultTo={to}
defaultSubject={subject}
inReplyTo={inReplyTo}
references={references}
onCancel={() => router.back()}
/>
</div>
)
}
export default function ComposePage() {
return (
<Suspense fallback={<div className="p-6">Loading...</div>}>
<ComposeContent />
</Suspense>
)
}

70
src/app/email/layout.tsx Normal file
View File

@ -0,0 +1,70 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { PenSquare, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { FolderList, FolderInfo } from '@/components/email/folder-list'
export default function EmailLayout({
children,
}: {
children: React.ReactNode
}) {
const [folders, setFolders] = useState<FolderInfo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
async function loadFolders() {
setLoading(true)
setError(null)
try {
const res = await fetch('/api/email/folders')
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Failed to load folders')
}
const data = await res.json()
setFolders(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load folders')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadFolders()
}, [])
return (
<div className="flex h-[calc(100vh-7rem)] lg:h-[calc(100vh-2rem)] -m-6 lg:-m-8">
{/* Folder sidebar */}
<div className="hidden md:flex w-56 flex-col border-r bg-background shrink-0">
<div className="flex items-center justify-between p-3 border-b">
<Link href="/email/compose">
<Button size="sm" className="w-full">
<PenSquare className="mr-2 h-4 w-4" />
Compose
</Button>
</Link>
<Button
variant="ghost"
size="icon"
className="ml-2 shrink-0"
onClick={loadFolders}
title="Refresh folders"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
<FolderList folders={folders} loading={loading} error={error} />
</div>
{/* Main content */}
<div className="flex-1 min-w-0 overflow-auto">
{children}
</div>
</div>
)
}

108
src/app/email/page.tsx Normal file
View File

@ -0,0 +1,108 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { RefreshCw, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { MessageList, MessageListItem } from '@/components/email/message-list'
import { SearchBar } from '@/components/email/search-bar'
export default function InboxPage() {
const folder = 'INBOX'
const [messages, setMessages] = useState<MessageListItem[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState<string | null>(null)
const loadMessages = useCallback(async (pageNum: number, append = false) => {
if (append) {
setLoadingMore(true)
} else {
setLoading(true)
}
setError(null)
try {
const params = new URLSearchParams({
folder,
page: String(pageNum),
limit: '50',
})
const res = await fetch(`/api/email/messages?${params}`)
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Failed to load messages')
}
const data = await res.json()
if (append) {
setMessages((prev) => [...prev, ...data.messages])
} else {
setMessages(data.messages)
}
setTotal(data.total)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load messages')
} finally {
setLoading(false)
setLoadingMore(false)
}
}, [])
useEffect(() => {
loadMessages(1)
}, [loadMessages])
const handleLoadMore = () => {
const nextPage = page + 1
setPage(nextPage)
loadMessages(nextPage, true)
}
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-3 p-4 border-b">
<h2 className="text-lg font-semibold shrink-0">Inbox</h2>
<div className="flex-1">
<SearchBar folder={folder} />
</div>
<Button
variant="ghost"
size="icon"
onClick={() => { setPage(1); loadMessages(1) }}
title="Refresh"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
{error && (
<div className="p-4 text-sm text-destructive">{error}</div>
)}
<div className="flex-1 overflow-auto">
<MessageList
messages={messages}
folder={folder}
loading={loading}
/>
</div>
{messages.length < total && !loading && (
<div className="p-4 border-t text-center">
<Button
variant="outline"
size="sm"
onClick={handleLoadMore}
disabled={loadingMore}
>
{loadingMore ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Load more ({messages.length} of {total})
</Button>
</div>
)}
</div>
)
}

26
src/app/error.tsx Normal file
View File

@ -0,0 +1,26 @@
'use client'
import { Button } from '@/components/ui/button'
import { AlertCircle, RotateCcw } from 'lucide-react'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center text-center">
<AlertCircle className="h-12 w-12 text-destructive mb-4" />
<h2 className="text-2xl font-bold">Something went wrong</h2>
<p className="mt-2 text-muted-foreground max-w-md">
{error.message || 'An unexpected error occurred. Please try again.'}
</p>
<Button onClick={reset} className="mt-6">
<RotateCcw className="mr-2 h-4 w-4" />
Try again
</Button>
</div>
)
}

59
src/app/globals.css Normal file
View File

@ -0,0 +1,59 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

45
src/app/layout.tsx Normal file
View File

@ -0,0 +1,45 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import { auth } from '@/auth'
import { Sidebar } from '@/components/dashboard/sidebar'
import { TooltipProvider } from '@/components/ui/tooltip'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'LetsBe Hub Dashboard',
description: 'AI-powered business dashboard with email, calendar, and task management',
}
export default async function RootLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await auth()
return (
<html lang="en">
<body className={inter.className}>
<TooltipProvider>
{session?.user ? (
<div className="min-h-screen">
<Sidebar
userName={session.user.name}
userEmail={session.user.email}
/>
<main className="lg:pl-64">
<div className="pt-14 lg:pt-0">
<div className="p-6 lg:p-8">{children}</div>
</div>
</main>
</div>
) : (
children
)}
</TooltipProvider>
</body>
</html>
)
}

31
src/app/loading.tsx Normal file
View File

@ -0,0 +1,31 @@
import { Skeleton } from '@/components/ui/skeleton'
export default function Loading() {
return (
<div className="space-y-8">
<div className="space-y-2">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-5 w-80" />
</div>
<div className="grid gap-4 sm:grid-cols-3">
<Skeleton className="h-24" />
<Skeleton className="h-24" />
<Skeleton className="h-24" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Skeleton className="h-32" />
<Skeleton className="h-32" />
<Skeleton className="h-32" />
<Skeleton className="h-32" />
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<Skeleton className="h-40" />
<Skeleton className="h-40" />
<Skeleton className="h-40" />
<Skeleton className="h-40" />
<Skeleton className="h-40" />
<Skeleton className="h-40" />
</div>
</div>
)
}

39
src/app/login/page.tsx Normal file
View File

@ -0,0 +1,39 @@
import { signIn } from '@/auth'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Shield } from 'lucide-react'
export default function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-muted/50 p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-primary text-primary-foreground">
<span className="text-xl font-bold">LB</span>
</div>
<CardTitle className="text-2xl">LetsBe Hub Dashboard</CardTitle>
<CardDescription>
Sign in to access your email, calendar, tasks, and tools
</CardDescription>
</CardHeader>
<CardContent>
<form
action={async () => {
'use server'
await signIn('keycloak', { redirectTo: '/' })
}}
>
<Button type="submit" className="w-full" size="lg">
<Shield className="mr-2 h-4 w-4" />
Sign in with Keycloak SSO
</Button>
</form>
<p className="mt-4 text-center text-xs text-muted-foreground">
Your identity is managed through Keycloak on this server.
Contact your administrator if you need access.
</p>
</CardContent>
</Card>
</div>
)
}

18
src/app/not-found.tsx Normal file
View File

@ -0,0 +1,18 @@
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { ArrowLeft } from 'lucide-react'
export default function NotFound() {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center text-center">
<h1 className="text-6xl font-bold text-muted-foreground">404</h1>
<p className="mt-4 text-lg text-muted-foreground">Page not found</p>
<Button asChild className="mt-6">
<Link href="/">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Dashboard
</Link>
</Button>
</div>
)
}

49
src/app/page.tsx Normal file
View File

@ -0,0 +1,49 @@
import { Button } from '@/components/ui/button'
import { ArrowRight } from 'lucide-react'
import Link from 'next/link'
import { ServerHealth } from '@/components/dashboard/server-health'
import { ActivityFeed } from '@/components/dashboard/activity-feed'
import { QuickActions } from '@/components/dashboard/quick-actions'
import { ToolLauncher } from '@/components/dashboard/tool-launcher'
import { AVAILABLE_TOOLS } from '@/lib/tools'
export default function DashboardPage() {
return (
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">
Your business command center -- email, calendar, tasks, and server management
</p>
</div>
{/* Quick Actions */}
<QuickActions />
{/* Activity Feed + Server Health side by side on large screens */}
<div className="grid gap-6 lg:grid-cols-5">
<div className="lg:col-span-3">
<ActivityFeed />
</div>
<div className="lg:col-span-2 space-y-4">
<h2 className="text-lg font-semibold">Server Health</h2>
<ServerHealth />
</div>
</div>
{/* Tool Launcher */}
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Deployed Tools</h2>
<Button variant="outline" size="sm" asChild>
<Link href="/tools">
View all
<ArrowRight className="ml-1 h-3.5 w-3.5" />
</Link>
</Button>
</div>
<ToolLauncher tools={AVAILABLE_TOOLS} />
</div>
</div>
)
}

View File

@ -0,0 +1,127 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Globe, CheckCircle2, XCircle, AlertCircle } from 'lucide-react'
import { AVAILABLE_TOOLS } from '@/lib/tools'
const domain = process.env.TENANT_DOMAIN || 'example.com'
function DnsStatusIcon({ status }: { status: string }) {
switch (status) {
case 'active':
return <CheckCircle2 className="h-4 w-4 text-green-600" />
case 'pending':
return <AlertCircle className="h-4 w-4 text-yellow-500" />
default:
return <XCircle className="h-4 w-4 text-red-500" />
}
}
function DnsStatusBadge({ status }: { status: string }) {
switch (status) {
case 'active':
return <Badge variant="success">Active</Badge>
case 'pending':
return <Badge variant="warning">Pending</Badge>
default:
return <Badge variant="destructive">Error</Badge>
}
}
export default function DomainSettingsPage() {
const subdomains = [
{ name: 'hub', fullDomain: `hub.${domain}`, purpose: 'Hub Dashboard', status: 'active' as const },
{ name: 'panel', fullDomain: `panel.${domain}`, purpose: 'Control Panel', status: 'active' as const },
...AVAILABLE_TOOLS.map((tool) => ({
name: tool.subdomain,
fullDomain: `${tool.subdomain}.${domain}`,
purpose: tool.name,
status: 'active' as const,
})),
]
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Domain & DNS</h1>
<p className="text-muted-foreground">
View your domain configuration and DNS record status
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5" />
Domain Information
</CardTitle>
<CardDescription>Your primary domain and configuration</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<p className="text-sm text-muted-foreground">Primary Domain</p>
<p className="text-lg font-semibold">{domain}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Wildcard DNS</p>
<div className="flex items-center gap-2 mt-0.5">
<Badge variant="success">Configured</Badge>
<span className="text-sm text-muted-foreground">*.{domain}</span>
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Subdomain Records</CardTitle>
<CardDescription>
All subdomains pointing to your server ({subdomains.length} total)
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{subdomains.map((sub) => (
<div
key={sub.name}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-3">
<DnsStatusIcon status={sub.status} />
<div>
<p className="text-sm font-medium">{sub.fullDomain}</p>
<p className="text-xs text-muted-foreground">{sub.purpose}</p>
</div>
</div>
<DnsStatusBadge status={sub.status} />
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>DNS Configuration</CardTitle>
<CardDescription>Required DNS records for your domain</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-lg bg-muted p-4 font-mono text-sm space-y-1">
<p>; A record for root domain</p>
<p>{domain}. IN A YOUR_SERVER_IP</p>
<p></p>
<p>; Wildcard A record for all subdomains</p>
<p>*.{domain}. IN A YOUR_SERVER_IP</p>
<p></p>
<p>; MX record for email (if using Poste.io)</p>
<p>{domain}. IN MX 10 mail.{domain}.</p>
<p></p>
<p>; SPF record</p>
<p>{domain}. IN TXT &quot;v=spf1 a mx ip4:YOUR_SERVER_IP ~all&quot;</p>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,168 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Save, Mail, AlertCircle, CheckCircle2 } from 'lucide-react'
export default function EmailSettingsPage() {
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | null>(null)
const [config, setConfig] = useState({
host: '',
port: '587',
username: '',
password: '',
fromEmail: '',
fromName: '',
})
const handleSave = async () => {
setSaving(true)
setError(null)
setSaved(false)
try {
const res = await fetch('/api/proxy/playbooks/poste-smtp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
smtp_host: config.host,
smtp_port: parseInt(config.port),
smtp_user: config.username,
smtp_pass: config.password,
from_email: config.fromEmail,
from_name: config.fromName,
}),
})
if (!res.ok) {
throw new Error('Failed to save SMTP configuration')
}
setSaved(true)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to save')
} finally {
setSaving(false)
}
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Email Configuration</h1>
<p className="text-muted-foreground">
Configure SMTP settings for outbound email from your tools
</p>
</div>
{saved && (
<Alert>
<CheckCircle2 className="h-4 w-4" />
<AlertTitle>Saved</AlertTitle>
<AlertDescription>
SMTP configuration has been updated. Email will be reconfigured shortly.
</AlertDescription>
</Alert>
)}
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
SMTP Settings
</CardTitle>
<CardDescription>
These settings are used by Poste.io and other tools to send outbound email.
Configure your noreply address and relay settings.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="host">SMTP Host</Label>
<Input
id="host"
placeholder="smtp.gmail.com"
value={config.host}
onChange={(e) => setConfig({ ...config, host: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="port">SMTP Port</Label>
<Input
id="port"
type="number"
placeholder="587"
value={config.port}
onChange={(e) => setConfig({ ...config, port: e.target.value })}
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
placeholder="noreply@yourdomain.com"
value={config.username}
onChange={(e) => setConfig({ ...config, username: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="App password or SMTP password"
value={config.password}
onChange={(e) => setConfig({ ...config, password: e.target.value })}
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="fromEmail">From Email</Label>
<Input
id="fromEmail"
type="email"
placeholder="noreply@yourdomain.com"
value={config.fromEmail}
onChange={(e) => setConfig({ ...config, fromEmail: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="fromName">From Name</Label>
<Input
id="fromName"
placeholder="Your Company"
value={config.fromName}
onChange={(e) => setConfig({ ...config, fromName: e.target.value })}
/>
</div>
</div>
<Button onClick={handleSave} disabled={saving} className="mt-2">
<Save className="mr-2 h-4 w-4" />
{saving ? 'Saving...' : 'Save Configuration'}
</Button>
</CardContent>
</Card>
</div>
)
}

66
src/app/settings/page.tsx Normal file
View File

@ -0,0 +1,66 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Mail, Globe, Server, ArrowRight } from 'lucide-react'
import Link from 'next/link'
const settingsSections = [
{
title: 'Email Configuration',
description: 'Configure SMTP settings for outbound email notifications from your tools',
icon: Mail,
href: '/settings/email',
},
{
title: 'Domain & DNS',
description: 'View your domain configuration and DNS record status for all subdomains',
icon: Globe,
href: '/settings/domain',
},
{
title: 'Server Information',
description: 'View SSH credentials, server IP, and system information',
icon: Server,
href: '/settings/server',
},
]
export default function SettingsPage() {
return (
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold tracking-tight">Settings</h1>
<p className="text-muted-foreground">
Manage your server configuration and preferences
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{settingsSections.map((section) => (
<Card key={section.href} className="hover:shadow-md transition-shadow">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<section.icon className="h-5 w-5" />
</div>
<div>
<CardTitle className="text-base">{section.title}</CardTitle>
</div>
</div>
<CardDescription className="mt-2">
{section.description}
</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" size="sm" asChild className="w-full">
<Link href={section.href}>
Configure
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,192 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
Server,
Cpu,
HardDrive,
MemoryStick,
Clock,
Network,
Terminal,
AlertCircle,
CheckCircle2,
} from 'lucide-react'
import { getHealth } from '@/lib/orchestrator'
function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (days > 0) return `${days} days, ${hours} hours, ${minutes} minutes`
if (hours > 0) return `${hours} hours, ${minutes} minutes`
return `${minutes} minutes`
}
export default async function ServerSettingsPage() {
let health = null
let healthError = null
try {
health = await getHealth()
} catch (e) {
healthError = e instanceof Error ? e.message : 'Failed to connect'
}
const serverIp = process.env.SERVER_IP || 'Not configured'
const domain = process.env.TENANT_DOMAIN || 'example.com'
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Server Information</h1>
<p className="text-muted-foreground">
View server details, resource usage, and connection information
</p>
</div>
{healthError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Connection Error</AlertTitle>
<AlertDescription>{healthError}</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="h-5 w-5" />
Server Details
</CardTitle>
<CardDescription>Connection and identification information</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<p className="text-sm text-muted-foreground">Server IP</p>
<p className="text-sm font-mono font-medium">{serverIp}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Domain</p>
<p className="text-sm font-mono font-medium">{domain}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Status</p>
<Badge variant={health ? 'success' : 'destructive'}>
{health ? 'Online' : 'Unreachable'}
</Badge>
</div>
<div>
<p className="text-sm text-muted-foreground">Uptime</p>
<p className="text-sm font-medium">
{health ? formatUptime(health.uptime_seconds) : 'N/A'}
</p>
</div>
</div>
</CardContent>
</Card>
{health && (
<Card>
<CardHeader>
<CardTitle>Resource Usage</CardTitle>
<CardDescription>Current server resource utilization</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Cpu className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">CPU</span>
</div>
<span className="text-sm font-medium">{health.cpu_percent.toFixed(1)}%</span>
</div>
<Progress value={health.cpu_percent} className="h-2" />
</div>
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<MemoryStick className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Memory</span>
</div>
<span className="text-sm font-medium">{health.memory_percent.toFixed(1)}%</span>
</div>
<Progress value={health.memory_percent} className="h-2" />
</div>
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Disk</span>
</div>
<span className="text-sm font-medium">
{health.disk_used_gb.toFixed(1)} / {health.disk_total_gb.toFixed(1)} GB ({health.disk_percent.toFixed(1)}%)
</span>
</div>
<Progress value={health.disk_percent} className="h-2" />
</div>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Terminal className="h-5 w-5" />
SSH Access
</CardTitle>
<CardDescription>
Connect to your server via SSH for advanced administration
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-lg bg-muted p-4 font-mono text-sm">
<p className="text-muted-foreground"># Connect via SSH</p>
<p>ssh root@{serverIp}</p>
<p className="mt-2 text-muted-foreground"># Application directory</p>
<p>cd /opt/letsbe/</p>
</div>
<p className="mt-3 text-xs text-muted-foreground">
SSH credentials were provided during server provisioning.
Contact your administrator if you need access.
</p>
</CardContent>
</Card>
{health && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Network className="h-5 w-5" />
Orchestrator
</CardTitle>
<CardDescription>Automation agent status</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-3">
<div>
<p className="text-sm text-muted-foreground">Status</p>
<div className="flex items-center gap-1.5 mt-0.5">
<CheckCircle2 className="h-4 w-4 text-green-600" />
<span className="text-sm font-medium">Connected</span>
</div>
</div>
<div>
<p className="text-sm text-muted-foreground">Active Agents</p>
<p className="text-sm font-medium">{health.agent_count}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Total Tasks</p>
<p className="text-sm font-medium">{health.task_count}</p>
</div>
</div>
</CardContent>
</Card>
)}
</div>
)
}

49
src/app/tasks/layout.tsx Normal file
View File

@ -0,0 +1,49 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { ProjectSidebar } from '@/components/tasks/project-sidebar'
import type { VikunjaProject } from '@/lib/vikunja-client'
export default function TasksLayout({ children }: { children: React.ReactNode }) {
const [projects, setProjects] = useState<VikunjaProject[]>([])
const fetchProjects = useCallback(async () => {
try {
const res = await fetch('/api/tasks/projects')
if (res.ok) {
const data = await res.json()
setProjects(data)
}
} catch {
// Silently fail - sidebar will be empty
}
}, [])
useEffect(() => {
fetchProjects()
}, [fetchProjects])
async function handleCreateProject(title: string) {
try {
const res = await fetch('/api/tasks/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
})
if (res.ok) {
fetchProjects()
}
} catch {
// Silently fail
}
}
return (
<div className="-m-6 lg:-m-8 flex h-[calc(100vh-3.5rem)] lg:h-screen">
<ProjectSidebar projects={projects} onCreateProject={handleCreateProject} />
<div className="flex-1 overflow-auto p-6 lg:p-8">
{children}
</div>
</div>
)
}

176
src/app/tasks/page.tsx Normal file
View File

@ -0,0 +1,176 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { TaskListView } from '@/components/tasks/task-list-view'
import { TaskDialog, type TaskFormData } from '@/components/tasks/task-dialog'
import { TaskFiltersBar, applyFilters, type TaskFilters } from '@/components/tasks/task-filters'
import { Skeleton } from '@/components/ui/skeleton'
import { CheckSquare, Plus, RefreshCw } from 'lucide-react'
import type { VikunjaTask, VikunjaLabel, VikunjaProject } from '@/lib/vikunja-client'
export default function TasksPage() {
const [tasks, setTasks] = useState<VikunjaTask[]>([])
const [labels, setLabels] = useState<VikunjaLabel[]>([])
const [projects, setProjects] = useState<VikunjaProject[]>([])
const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false)
const [editingTask, setEditingTask] = useState<VikunjaTask | null>(null)
const [filters, setFilters] = useState<TaskFilters>({
priority: null,
labelId: null,
dueDate: null,
status: 'open',
})
const fetchData = useCallback(async () => {
try {
const [tasksRes, labelsRes, projectsRes] = await Promise.all([
fetch('/api/tasks?per_page=200'),
fetch('/api/tasks/labels'),
fetch('/api/tasks/projects'),
])
if (tasksRes.ok) setTasks(await tasksRes.json())
if (labelsRes.ok) setLabels(await labelsRes.json())
if (projectsRes.ok) setProjects(await projectsRes.json())
} catch {
// Fail silently
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
async function handleToggleDone(task: VikunjaTask) {
try {
const res = await fetch(`/api/tasks/${task.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ done: !task.done }),
})
if (res.ok) {
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, done: !t.done } : t)))
}
} catch {
// Fail silently
}
}
async function handleSave(data: TaskFormData) {
try {
if (editingTask) {
const res = await fetch(`/api/tasks/${editingTask.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: data.title,
description: data.description,
due_date: data.due_date,
priority: data.priority,
labels: data.labels,
}),
})
if (res.ok) {
const updated = await res.json()
setTasks((prev) => prev.map((t) => (t.id === editingTask.id ? updated : t)))
}
} else {
const res = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectId: data.projectId,
title: data.title,
description: data.description,
due_date: data.due_date,
priority: data.priority,
labels: data.labels,
}),
})
if (res.ok) {
fetchData()
}
}
} catch {
// Fail silently
}
}
async function handleDelete(taskId: number) {
try {
const res = await fetch(`/api/tasks/${taskId}`, { method: 'DELETE' })
if (res.ok) {
setTasks((prev) => prev.filter((t) => t.id !== taskId))
}
} catch {
// Fail silently
}
}
const defaultProjectId = projects[0]?.id || null
const filteredTasks = applyFilters(tasks, filters) as VikunjaTask[]
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">All Tasks</h1>
<p className="text-sm text-muted-foreground">
Tasks across all projects
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="hidden sm:flex">
<CheckSquare className="mr-1 h-3 w-3" />
Vikunja
</Badge>
<Button
variant="outline"
size="sm"
onClick={() => { setLoading(true); fetchData() }}
>
<RefreshCw className="h-4 w-4" />
</Button>
<Button
size="sm"
onClick={() => { setEditingTask(null); setDialogOpen(true) }}
disabled={!defaultProjectId}
>
<Plus className="mr-1 h-4 w-4" />
New Task
</Button>
</div>
</div>
<TaskFiltersBar filters={filters} onChange={setFilters} labels={labels} />
{loading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-lg" />
))}
</div>
) : (
<TaskListView
tasks={filteredTasks}
onEditTask={(task) => { setEditingTask(task); setDialogOpen(true) }}
onToggleDone={handleToggleDone}
/>
)}
<TaskDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
task={editingTask}
labels={labels}
projectId={editingTask?.project_id || defaultProjectId}
onSave={handleSave}
onDelete={handleDelete}
/>
</div>
)
}

View File

@ -0,0 +1,293 @@
'use client'
import { useState, useEffect, useCallback, use } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { TaskListView } from '@/components/tasks/task-list-view'
import { KanbanBoard } from '@/components/tasks/kanban-board'
import { TaskDialog, type TaskFormData } from '@/components/tasks/task-dialog'
import { TaskFiltersBar, applyFilters, type TaskFilters } from '@/components/tasks/task-filters'
import { Skeleton } from '@/components/ui/skeleton'
import { FolderOpen, Plus, RefreshCw, LayoutList, Columns3 } from 'lucide-react'
import type { VikunjaTask, VikunjaLabel, VikunjaBucket, VikunjaProject } from '@/lib/vikunja-client'
interface ProjectData extends VikunjaProject {
buckets: VikunjaBucket[]
kanbanViewId: number | null
listViewId: number | null
defaultViewId: number | null
}
export default function ProjectPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const projectId = parseInt(id, 10)
const [project, setProject] = useState<ProjectData | null>(null)
const [tasks, setTasks] = useState<VikunjaTask[]>([])
const [labels, setLabels] = useState<VikunjaLabel[]>([])
const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false)
const [editingTask, setEditingTask] = useState<VikunjaTask | null>(null)
const [filters, setFilters] = useState<TaskFilters>({
priority: null,
labelId: null,
dueDate: null,
status: 'open',
})
const fetchData = useCallback(async () => {
try {
const [projectRes, labelsRes] = await Promise.all([
fetch(`/api/tasks/projects/${projectId}`),
fetch('/api/tasks/labels'),
])
if (projectRes.ok) {
const projectData = await projectRes.json()
setProject(projectData)
// Fetch tasks using the default view
const viewId = projectData.kanbanViewId || projectData.listViewId || projectData.defaultViewId
if (viewId) {
// Use the all-tasks endpoint filtered by project
const tasksRes = await fetch(`/api/tasks?per_page=200&filter=project_id%3D${projectId}`)
if (tasksRes.ok) {
const allTasks = await tasksRes.json()
// Filter to just this project's tasks client-side as fallback
const projectTasks = allTasks.filter ? allTasks.filter((t: VikunjaTask) => t.project_id === projectId) : allTasks
setTasks(projectTasks)
}
}
}
if (labelsRes.ok) setLabels(await labelsRes.json())
} catch {
// Fail silently
} finally {
setLoading(false)
}
}, [projectId])
useEffect(() => {
setLoading(true)
fetchData()
}, [fetchData])
async function handleToggleDone(task: VikunjaTask) {
try {
const res = await fetch(`/api/tasks/${task.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ done: !task.done }),
})
if (res.ok) {
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, done: !t.done } : t)))
}
} catch {
// Fail silently
}
}
async function handleSave(data: TaskFormData) {
try {
if (editingTask) {
const res = await fetch(`/api/tasks/${editingTask.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: data.title,
description: data.description,
due_date: data.due_date,
priority: data.priority,
labels: data.labels,
}),
})
if (res.ok) {
const updated = await res.json()
setTasks((prev) => prev.map((t) => (t.id === editingTask.id ? updated : t)))
}
} else {
const res = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectId,
title: data.title,
description: data.description,
due_date: data.due_date,
priority: data.priority,
labels: data.labels,
}),
})
if (res.ok) {
fetchData()
}
}
} catch {
// Fail silently
}
}
async function handleDelete(taskId: number) {
try {
const res = await fetch(`/api/tasks/${taskId}`, { method: 'DELETE' })
if (res.ok) {
setTasks((prev) => prev.filter((t) => t.id !== taskId))
}
} catch {
// Fail silently
}
}
async function handleMoveTask(taskId: number, bucketId: number) {
if (!project) return
const viewId = project.kanbanViewId || project.defaultViewId
if (!viewId) return
try {
const res = await fetch(`/api/tasks/${taskId}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId, viewId, bucketId, position: 0 }),
})
if (res.ok) {
setTasks((prev) =>
prev.map((t) => (t.id === taskId ? { ...t, bucket_id: bucketId } : t))
)
}
} catch {
// Fail silently
}
}
async function handleQuickAdd(title: string, bucketId: number) {
try {
const res = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId, title, bucket_id: bucketId }),
})
if (res.ok) {
fetchData()
}
} catch {
// Fail silently
}
}
const filteredTasks = applyFilters(tasks, filters) as VikunjaTask[]
const hasBuckets = (project?.buckets?.length || 0) > 0
if (loading) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-6 w-72" />
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-lg" />
))}
</div>
</div>
)
}
if (!project) {
return (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<p>Project not found.</p>
</div>
)
}
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<FolderOpen
className="h-6 w-6"
style={project.hex_color ? { color: project.hex_color } : undefined}
/>
<div>
<h1 className="text-2xl font-bold tracking-tight">{project.title}</h1>
{project.description && (
<p className="text-sm text-muted-foreground">{project.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="hidden sm:flex">
{filteredTasks.length} tasks
</Badge>
<Button
variant="outline"
size="sm"
onClick={() => { setLoading(true); fetchData() }}
>
<RefreshCw className="h-4 w-4" />
</Button>
<Button
size="sm"
onClick={() => { setEditingTask(null); setDialogOpen(true) }}
>
<Plus className="mr-1 h-4 w-4" />
New Task
</Button>
</div>
</div>
<TaskFiltersBar filters={filters} onChange={setFilters} labels={labels} />
{hasBuckets ? (
<Tabs defaultValue="kanban">
<TabsList>
<TabsTrigger value="kanban" className="gap-1.5">
<Columns3 className="h-4 w-4" />
Board
</TabsTrigger>
<TabsTrigger value="list" className="gap-1.5">
<LayoutList className="h-4 w-4" />
List
</TabsTrigger>
</TabsList>
<TabsContent value="kanban">
<KanbanBoard
buckets={project.buckets}
tasks={filteredTasks}
onEditTask={(task) => { setEditingTask(task); setDialogOpen(true) }}
onToggleDone={handleToggleDone}
onMoveTask={handleMoveTask}
onQuickAdd={handleQuickAdd}
/>
</TabsContent>
<TabsContent value="list">
<TaskListView
tasks={filteredTasks}
onEditTask={(task) => { setEditingTask(task); setDialogOpen(true) }}
onToggleDone={handleToggleDone}
/>
</TabsContent>
</Tabs>
) : (
<TaskListView
tasks={filteredTasks}
onEditTask={(task) => { setEditingTask(task); setDialogOpen(true) }}
onToggleDone={handleToggleDone}
/>
)}
<TaskDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
task={editingTask}
labels={labels}
projectId={editingTask?.project_id || projectId}
onSave={handleSave}
onDelete={handleDelete}
/>
</div>
)
}

70
src/app/tools/page.tsx Normal file
View File

@ -0,0 +1,70 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { ExternalLink } from 'lucide-react'
import { AVAILABLE_TOOLS } from '@/lib/tools'
const categoryLabels: Record<string, string> = {
communication: 'Communication',
productivity: 'Productivity',
development: 'Development',
monitoring: 'Monitoring',
storage: 'Storage',
marketing: 'Marketing',
security: 'Security',
other: 'Other',
}
export default function ToolsPage() {
const grouped = AVAILABLE_TOOLS.reduce(
(acc, tool) => {
const cat = tool.category
if (!acc[cat]) acc[cat] = []
acc[cat].push(tool)
return acc
},
{} as Record<string, typeof AVAILABLE_TOOLS>
)
return (
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold tracking-tight">Tools</h1>
<p className="text-muted-foreground">
Access all your deployed tools. Click &quot;Open&quot; to go to each tool&apos;s web interface.
</p>
</div>
{Object.entries(grouped).map(([category, tools]) => (
<div key={category}>
<h2 className="text-lg font-semibold mb-3">
{categoryLabels[category] || category}
</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{tools.map((tool) => (
<Card key={tool.id} className="hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">{tool.name}</CardTitle>
<Badge variant="secondary" className="text-xs">
{tool.subdomain}
</Badge>
</div>
<CardDescription>{tool.description}</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" size="sm" asChild className="w-full">
<a href={tool.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
Open {tool.name}
</a>
</Button>
</CardContent>
</Card>
))}
</div>
</div>
))}
</div>
)
}

45
src/auth.ts Normal file
View File

@ -0,0 +1,45 @@
import NextAuth from 'next-auth'
import Keycloak from 'next-auth/providers/keycloak'
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Keycloak({
clientId: process.env.KEYCLOAK_CLIENT_ID!,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
issuer: process.env.KEYCLOAK_ISSUER!,
}),
],
pages: {
signIn: '/login',
},
callbacks: {
authorized({ auth }) {
return !!auth?.user
},
async jwt({ token, account, profile }) {
if (account) {
token.accessToken = account.access_token
token.idToken = account.id_token
token.expiresAt = account.expires_at
token.refreshToken = account.refresh_token
}
if (profile) {
token.name = profile.name
token.email = profile.email
}
return token
},
async session({ session, token }) {
if (token.accessToken) {
session.accessToken = token.accessToken as string
}
return session
},
},
})
declare module 'next-auth' {
interface Session {
accessToken?: string
}
}

View File

@ -0,0 +1,77 @@
'use client'
import { cn } from '@/lib/utils'
import type { CalendarInfo } from '@/lib/caldav-client'
interface CalendarSidebarProps {
calendars: CalendarInfo[]
visibleCalendars: Set<string>
onToggleCalendar: (url: string) => void
calendarColors: Record<string, string>
}
const DEFAULT_COLORS = [
'#3b82f6', '#ef4444', '#22c55e', '#f59e0b', '#8b5cf6',
'#ec4899', '#06b6d4', '#f97316',
]
export function CalendarSidebar({
calendars,
visibleCalendars,
onToggleCalendar,
calendarColors,
}: CalendarSidebarProps) {
if (calendars.length === 0) return null
return (
<div className="space-y-1">
<h3 className="px-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Calendars
</h3>
<div className="space-y-0.5">
{calendars.map((cal, idx) => {
const color = calendarColors[cal.url] || cal.color || DEFAULT_COLORS[idx % DEFAULT_COLORS.length]
const isVisible = visibleCalendars.has(cal.url)
return (
<button
key={cal.url}
onClick={() => onToggleCalendar(cal.url)}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent',
!isVisible && 'opacity-50'
)}
>
<span
className={cn(
'flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-sm border',
isVisible ? 'border-transparent' : 'border-muted-foreground/30'
)}
style={{
backgroundColor: isVisible ? color : 'transparent',
}}
>
{isVisible && (
<svg
className="h-2.5 w-2.5 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
)}
</span>
<span className="truncate">{cal.displayName}</span>
</button>
)
})}
</div>
</div>
)
}

View File

@ -0,0 +1,165 @@
'use client'
import { useEffect, useState, useMemo, useRef } from 'react'
import { useNextCalendarApp, ScheduleXCalendar } from '@schedule-x/react'
import {
createViewDay,
createViewWeek,
createViewMonthGrid,
} from '@schedule-x/calendar'
import { createEventsServicePlugin } from '@schedule-x/events-service'
import { createDragAndDropPlugin } from '@schedule-x/drag-and-drop'
import '@schedule-x/theme-default/dist/index.css'
import type { CalendarEvent } from '@/lib/caldav-client'
interface CalendarViewProps {
events: CalendarEvent[]
onEventClick: (event: CalendarEvent) => void
onDateClick: (date: string) => void
onEventUpdate: (event: CalendarEvent, newStart: string, newEnd: string) => void
onRangeChange: (start: string, end: string) => void
selectedDate?: string
visibleCalendars: Set<string>
calendarColors: Record<string, string>
}
function getColorConfig(hex: string) {
return {
colorName: hex,
lightColors: {
main: hex,
container: hex + '20',
onContainer: '#1a1a1a',
},
darkColors: {
main: hex,
container: hex + '30',
onContainer: '#f0f0f0',
},
}
}
export function CalendarView({
events,
onEventClick,
onDateClick,
onEventUpdate,
onRangeChange,
selectedDate,
visibleCalendars,
calendarColors,
}: CalendarViewProps) {
const [eventsPlugin] = useState(() => createEventsServicePlugin())
// Store callback refs to avoid recreating the calendar on every render
const onEventClickRef = useRef(onEventClick)
const onDateClickRef = useRef(onDateClick)
const onEventUpdateRef = useRef(onEventUpdate)
const onRangeChangeRef = useRef(onRangeChange)
const eventsRef = useRef(events)
onEventClickRef.current = onEventClick
onDateClickRef.current = onDateClick
onEventUpdateRef.current = onEventUpdate
onRangeChangeRef.current = onRangeChange
eventsRef.current = events
// Build calendar color config - memoized
const calendarsConfig = useMemo(() => {
const config: Record<string, ReturnType<typeof getColorConfig>> = {}
Object.entries(calendarColors).forEach(([url, color]) => {
config[url] = getColorConfig(color)
})
return config
}, [calendarColors])
const calendar = useNextCalendarApp({
views: [createViewMonthGrid(), createViewWeek(), createViewDay()],
defaultView: 'week',
selectedDate: selectedDate || new Date().toISOString().split('T')[0],
events: [],
calendars: calendarsConfig,
callbacks: {
onEventClick(calendarEvent: Record<string, unknown>) {
const original = eventsRef.current.find((e) => e.id === calendarEvent.id)
if (original) {
onEventClickRef.current(original)
}
},
onClickDateTime(dateTime: string) {
onDateClickRef.current(dateTime)
},
onClickDate(date: string) {
onDateClickRef.current(date)
},
onEventUpdate(updatedEvent: Record<string, unknown>) {
const original = eventsRef.current.find((e) => e.id === updatedEvent.id)
if (original) {
onEventUpdateRef.current(
original,
updatedEvent.start as string,
updatedEvent.end as string
)
}
},
onRangeUpdate(range: { start: string; end: string }) {
onRangeChangeRef.current(range.start, range.end)
},
},
plugins: [eventsPlugin, createDragAndDropPlugin()],
})
// Compute visible Schedule-X events
const visibleEventIds = useMemo(() => {
return events
.filter((e) => visibleCalendars.has(e.calendarUrl))
.map((e) => e.id)
}, [events, visibleCalendars])
// Sync events with the plugin when data changes
useEffect(() => {
if (!eventsPlugin) return
const scheduleEvents = events
.filter((e) => visibleCalendars.has(e.calendarUrl))
.map((e) => ({
id: e.id,
title: e.title,
start: e.start,
end: e.end,
calendarId: e.calendarUrl,
location: e.location || undefined,
description: e.description || undefined,
}))
try {
const currentEvents = eventsPlugin.getAll()
const currentIds = new Set(currentEvents.map((e: { id: string }) => e.id))
const newIds = new Set(scheduleEvents.map((e) => e.id))
// Remove events no longer present
currentEvents.forEach((e: { id: string }) => {
if (!newIds.has(e.id)) {
eventsPlugin.remove(e.id)
}
})
// Add or update events
scheduleEvents.forEach((e) => {
if (currentIds.has(e.id)) {
eventsPlugin.update(e)
} else {
eventsPlugin.add(e)
}
})
} catch {
// Plugin may not be ready during initial render
}
}, [visibleEventIds, eventsPlugin])
return (
<div className="sx-calendar-wrapper rounded-lg border bg-background">
<ScheduleXCalendar calendarApp={calendar} />
</div>
)
}

View File

@ -0,0 +1,77 @@
'use client'
import { Clock, MapPin } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { CalendarEvent } from '@/lib/caldav-client'
interface EventCardProps {
event: CalendarEvent
color?: string
onClick: (event: CalendarEvent) => void
compact?: boolean
}
function formatTime(dateStr: string): string {
if (!dateStr || !dateStr.includes(' ') && !dateStr.includes('T')) {
return 'All day'
}
const timePart = dateStr.includes('T')
? dateStr.split('T')[1]
: dateStr.split(' ')[1]
if (!timePart) return 'All day'
return timePart.slice(0, 5)
}
export function EventCard({ event, color, onClick, compact }: EventCardProps) {
const startTime = formatTime(event.start)
const endTime = formatTime(event.end)
const timeStr = event.allDay ? 'All day' : `${startTime} - ${endTime}`
if (compact) {
return (
<button
onClick={() => onClick(event)}
className="flex w-full items-center gap-2 rounded px-2 py-1 text-left text-xs hover:bg-accent transition-colors"
>
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: color || '#3b82f6' }}
/>
<span className="truncate">{event.title}</span>
<span className="ml-auto shrink-0 text-muted-foreground">
{startTime}
</span>
</button>
)
}
return (
<button
onClick={() => onClick(event)}
className={cn(
'w-full rounded-lg border p-3 text-left transition-colors hover:bg-accent',
'group relative'
)}
>
<div
className="absolute left-0 top-0 bottom-0 w-1 rounded-l-lg"
style={{ backgroundColor: color || '#3b82f6' }}
/>
<div className="pl-2">
<p className="font-medium text-sm">{event.title}</p>
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{timeStr}
</span>
{event.location && (
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
<span className="truncate max-w-[150px]">{event.location}</span>
</span>
)}
</div>
</div>
</button>
)
}

View File

@ -0,0 +1,287 @@
'use client'
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Trash2 } from 'lucide-react'
import type { CalendarInfo, CalendarEvent } from '@/lib/caldav-client'
interface EventDialogProps {
open: boolean
onClose: () => void
onSave: (event: EventFormData) => void
onDelete?: () => void
event?: CalendarEvent | null
calendars: CalendarInfo[]
defaultDate?: string
defaultCalendarUrl?: string
}
export interface EventFormData {
title: string
start: string
end: string
allDay: boolean
location: string
description: string
calendarUrl: string
recurrence: string
}
const RECURRENCE_OPTIONS = [
{ label: 'None', value: '' },
{ label: 'Daily', value: 'FREQ=DAILY' },
{ label: 'Weekly', value: 'FREQ=WEEKLY' },
{ label: 'Monthly', value: 'FREQ=MONTHLY' },
{ label: 'Yearly', value: 'FREQ=YEARLY' },
]
function toDateTimeLocal(dateStr: string): string {
if (!dateStr) return ''
// Handle "YYYY-MM-DD HH:mm" format
if (dateStr.includes(' ') && !dateStr.includes('T')) {
return dateStr.replace(' ', 'T')
}
// Handle ISO format
if (dateStr.includes('T')) {
return dateStr.slice(0, 16)
}
// Date only - add default time
return `${dateStr}T09:00`
}
function toDateOnly(dateStr: string): string {
if (!dateStr) return ''
return dateStr.split('T')[0].split(' ')[0]
}
function fromDateTimeLocal(value: string, allDay: boolean): string {
if (allDay) {
return value.split('T')[0]
}
// Return as "YYYY-MM-DD HH:mm"
return value.replace('T', ' ')
}
export function EventDialog({
open,
onClose,
onSave,
onDelete,
event,
calendars,
defaultDate,
defaultCalendarUrl,
}: EventDialogProps) {
const isEditing = !!event
const [title, setTitle] = useState('')
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [allDay, setAllDay] = useState(false)
const [location, setLocation] = useState('')
const [description, setDescription] = useState('')
const [calendarUrl, setCalendarUrl] = useState('')
const [recurrence, setRecurrence] = useState('')
useEffect(() => {
if (event) {
setTitle(event.title)
setStartDate(event.allDay ? toDateOnly(event.start) : toDateTimeLocal(event.start))
setEndDate(event.allDay ? toDateOnly(event.end) : toDateTimeLocal(event.end))
setAllDay(event.allDay)
setLocation(event.location || '')
setDescription(event.description || '')
setCalendarUrl(event.calendarUrl)
setRecurrence(event.recurrence || '')
} else {
const now = defaultDate || new Date().toISOString()
const defaultStart = toDateTimeLocal(now)
const endTime = new Date(now)
endTime.setHours(endTime.getHours() + 1)
const defaultEnd = toDateTimeLocal(endTime.toISOString())
setTitle('')
setStartDate(defaultStart)
setEndDate(defaultEnd)
setAllDay(false)
setLocation('')
setDescription('')
setCalendarUrl(defaultCalendarUrl || calendars[0]?.url || '')
setRecurrence('')
}
}, [event, defaultDate, defaultCalendarUrl, calendars, open])
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
onSave({
title,
start: fromDateTimeLocal(startDate, allDay),
end: fromDateTimeLocal(endDate, allDay),
allDay,
location,
description,
calendarUrl,
recurrence,
})
}
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{isEditing ? 'Edit Event' : 'New Event'}
</DialogTitle>
<DialogDescription>
{isEditing
? 'Update the event details below.'
: 'Fill in the details to create a new event.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Event title"
required
autoFocus
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="allDay"
checked={allDay}
onChange={(e) => {
setAllDay(e.target.checked)
if (e.target.checked) {
setStartDate(toDateOnly(startDate))
setEndDate(toDateOnly(endDate))
} else {
setStartDate(toDateTimeLocal(startDate))
setEndDate(toDateTimeLocal(endDate))
}
}}
className="rounded border-gray-300"
/>
<Label htmlFor="allDay" className="text-sm font-normal">
All day
</Label>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="start">Start</Label>
<Input
id="start"
type={allDay ? 'date' : 'datetime-local'}
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="end">End</Label>
<Input
id="end"
type={allDay ? 'date' : 'datetime-local'}
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="location">Location</Label>
<Input
id="location"
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="Event location"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Event description"
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
rows={3}
/>
</div>
{calendars.length > 1 && (
<div className="space-y-2">
<Label htmlFor="calendar">Calendar</Label>
<select
id="calendar"
value={calendarUrl}
onChange={(e) => setCalendarUrl(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{calendars.map((cal) => (
<option key={cal.url} value={cal.url}>
{cal.displayName}
</option>
))}
</select>
</div>
)}
<div className="space-y-2">
<Label htmlFor="recurrence">Repeat</Label>
<select
id="recurrence"
value={recurrence}
onChange={(e) => setRecurrence(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{RECURRENCE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<DialogFooter className="gap-2">
{isEditing && onDelete && (
<Button
type="button"
variant="destructive"
onClick={onDelete}
className="mr-auto"
>
<Trash2 className="mr-1 h-4 w-4" />
Delete
</Button>
)}
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit">{isEditing ? 'Update' : 'Create'}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,146 @@
'use client'
import { useState, useMemo } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
interface MiniCalendarProps {
selectedDate: string
onSelectDate: (date: string) => void
}
const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
function getDaysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate()
}
function getFirstDayOfMonth(year: number, month: number): number {
return new Date(year, month, 1).getDay()
}
function formatDate(year: number, month: number, day: number): string {
return `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
}
export function MiniCalendar({ selectedDate, onSelectDate }: MiniCalendarProps) {
const selected = selectedDate ? new Date(selectedDate) : new Date()
const [viewYear, setViewYear] = useState(selected.getFullYear())
const [viewMonth, setViewMonth] = useState(selected.getMonth())
const today = new Date()
const todayStr = formatDate(
today.getFullYear(),
today.getMonth(),
today.getDate()
)
const selectedStr = selectedDate?.split('T')[0]?.split(' ')[0] || todayStr
const days = useMemo(() => {
const daysInMonth = getDaysInMonth(viewYear, viewMonth)
const firstDay = getFirstDayOfMonth(viewYear, viewMonth)
const result: (number | null)[] = []
// Leading empty cells
for (let i = 0; i < firstDay; i++) {
result.push(null)
}
// Days of month
for (let d = 1; d <= daysInMonth; d++) {
result.push(d)
}
return result
}, [viewYear, viewMonth])
function prevMonth() {
if (viewMonth === 0) {
setViewYear(viewYear - 1)
setViewMonth(11)
} else {
setViewMonth(viewMonth - 1)
}
}
function nextMonth() {
if (viewMonth === 11) {
setViewYear(viewYear + 1)
setViewMonth(0)
} else {
setViewMonth(viewMonth + 1)
}
}
function goToToday() {
setViewYear(today.getFullYear())
setViewMonth(today.getMonth())
onSelectDate(todayStr)
}
const monthLabel = new Date(viewYear, viewMonth).toLocaleDateString('en-US', {
month: 'long',
year: 'numeric',
})
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={prevMonth}>
<ChevronLeft className="h-4 w-4" />
</Button>
<button
onClick={goToToday}
className="text-sm font-medium hover:underline"
>
{monthLabel}
</button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={nextMonth}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-7 gap-0">
{WEEKDAYS.map((wd) => (
<div
key={wd}
className="py-1 text-center text-[10px] font-medium text-muted-foreground"
>
{wd}
</div>
))}
{days.map((day, idx) => {
if (day === null) {
return <div key={`empty-${idx}`} className="h-7" />
}
const dateStr = formatDate(viewYear, viewMonth, day)
const isToday = dateStr === todayStr
const isSelected = dateStr === selectedStr
return (
<button
key={dateStr}
onClick={() => onSelectDate(dateStr)}
className={cn(
'flex h-7 w-full items-center justify-center rounded-md text-xs transition-colors hover:bg-accent',
isToday && !isSelected && 'font-bold text-primary',
isSelected && 'bg-primary text-primary-foreground hover:bg-primary/90'
)}
>
{day}
</button>
)
})}
</div>
<Button
variant="outline"
size="sm"
className="w-full text-xs"
onClick={goToToday}
>
Today
</Button>
</div>
)
}

View File

@ -0,0 +1,103 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Mail, Calendar, CheckSquare, Clock } from 'lucide-react'
import Link from 'next/link'
interface ActivityItem {
id: string
type: 'email' | 'calendar' | 'task'
title: string
description: string
time: string
href: string
}
const placeholderActivities: ActivityItem[] = [
{
id: '1',
type: 'email',
title: '3 unread emails',
description: 'Latest from team@example.com',
time: '5 min ago',
href: '/email',
},
{
id: '2',
type: 'calendar',
title: 'Team standup',
description: 'Meeting in 2 hours',
time: 'Today 10:00',
href: '/calendar',
},
{
id: '3',
type: 'task',
title: '5 tasks due today',
description: 'Review project docs, Update landing page...',
time: 'Due today',
href: '/tasks',
},
{
id: '4',
type: 'email',
title: 'Newsletter report ready',
description: 'From analytics@example.com',
time: '1 hour ago',
href: '/email',
},
{
id: '5',
type: 'calendar',
title: 'Client call',
description: 'Demo presentation tomorrow at 14:00',
time: 'Tomorrow',
href: '/calendar',
},
]
const typeConfig = {
email: { icon: Mail, color: 'text-blue-600', bg: 'bg-blue-100 dark:bg-blue-900' },
calendar: { icon: Calendar, color: 'text-orange-600', bg: 'bg-orange-100 dark:bg-orange-900' },
task: { icon: CheckSquare, color: 'text-green-600', bg: 'bg-green-100 dark:bg-green-900' },
}
export function ActivityFeed() {
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Recent Activity</CardTitle>
<Badge variant="secondary" className="text-xs">
<Clock className="mr-1 h-3 w-3" />
Live
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{placeholderActivities.map((activity) => {
const config = typeConfig[activity.type]
const Icon = config.icon
return (
<Link
key={activity.id}
href={activity.href}
className="flex items-start gap-3 rounded-lg p-2 -mx-2 hover:bg-accent transition-colors"
>
<div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${config.bg}`}>
<Icon className={`h-4 w-4 ${config.color}`} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium leading-tight">{activity.title}</p>
<p className="text-xs text-muted-foreground truncate">{activity.description}</p>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">{activity.time}</span>
</Link>
)
})}
</div>
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,58 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Mail, Calendar, CheckSquare, Plus } from 'lucide-react'
import Link from 'next/link'
const actions = [
{
name: 'Compose Email',
description: 'Write and send a new email',
icon: Mail,
href: '/email',
color: 'text-blue-600',
bg: 'bg-blue-50 dark:bg-blue-950',
},
{
name: 'Create Event',
description: 'Add a new calendar event',
icon: Calendar,
href: '/calendar',
color: 'text-orange-600',
bg: 'bg-orange-50 dark:bg-orange-950',
},
{
name: 'Add Task',
description: 'Create a new task',
icon: CheckSquare,
href: '/tasks',
color: 'text-green-600',
bg: 'bg-green-50 dark:bg-green-950',
},
]
export function QuickActions() {
return (
<div>
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2>
<div className="grid gap-3 sm:grid-cols-3">
{actions.map((action) => (
<Card key={action.name} className="hover:border-primary/50 transition-colors">
<Link href={action.href} className="block">
<CardContent className="pt-6 pb-6">
<div className="flex items-center gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${action.bg}`}>
<action.icon className={`h-5 w-5 ${action.color}`} />
</div>
<div>
<p className="text-sm font-medium">{action.name}</p>
<p className="text-xs text-muted-foreground">{action.description}</p>
</div>
</div>
</CardContent>
</Link>
</Card>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,120 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import {
Cpu,
HardDrive,
MemoryStick,
Clock,
Activity,
XCircle,
} from 'lucide-react'
import { getHealth } from '@/lib/orchestrator'
function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (days > 0) return `${days}d ${hours}h ${minutes}m`
if (hours > 0) return `${hours}h ${minutes}m`
return `${minutes}m`
}
function getUsageColor(percent: number): string {
if (percent < 60) return 'text-green-600'
if (percent < 80) return 'text-yellow-600'
return 'text-red-600'
}
export async function ServerHealth() {
let health = null
let healthError = null
try {
health = await getHealth()
} catch (e) {
healthError = e instanceof Error ? e.message : 'Failed to fetch server health'
}
if (healthError) {
return (
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3 text-muted-foreground">
<XCircle className="h-5 w-5 text-destructive" />
<div>
<p className="font-medium">Unable to connect to server</p>
<p className="text-sm">{healthError}</p>
</div>
</div>
</CardContent>
</Card>
)
}
if (!health) return null
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">CPU Usage</CardTitle>
<Cpu className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${getUsageColor(health.cpu_percent)}`}>
{health.cpu_percent.toFixed(1)}%
</div>
<Progress value={health.cpu_percent} className="mt-2 h-2" />
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Memory</CardTitle>
<MemoryStick className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${getUsageColor(health.memory_percent)}`}>
{health.memory_percent.toFixed(1)}%
</div>
<Progress value={health.memory_percent} className="mt-2 h-2" />
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Disk Usage</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${getUsageColor(health.disk_percent)}`}>
{health.disk_percent.toFixed(1)}%
</div>
<p className="text-xs text-muted-foreground mt-1">
{health.disk_used_gb.toFixed(1)} / {health.disk_total_gb.toFixed(1)} GB
</p>
<Progress value={health.disk_percent} className="mt-1 h-2" />
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Uptime</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatUptime(health.uptime_seconds)}
</div>
<div className="flex items-center gap-1.5 mt-2">
<Badge variant="success" className="text-xs">
<Activity className="mr-1 h-3 w-3" />
Online
</Badge>
<span className="text-xs text-muted-foreground">
{health.agent_count} agent{health.agent_count !== 1 ? 's' : ''}
</span>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,201 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import {
LayoutDashboard,
Mail,
Calendar,
CheckSquare,
Grid3X3,
Settings,
Globe,
Server,
LogOut,
Menu,
X,
} from 'lucide-react'
import { useState } from 'react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
const navigation = [
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
{
name: 'Email',
href: '/email',
icon: Mail,
badge: true,
},
{ name: 'Calendar', href: '/calendar', icon: Calendar },
{ name: 'Tasks', href: '/tasks', icon: CheckSquare },
{ name: 'Tools', href: '/tools', icon: Grid3X3 },
{
name: 'Settings',
href: '/settings',
icon: Settings,
children: [
{ name: 'Overview', href: '/settings', icon: Settings },
{ name: 'Email', href: '/settings/email', icon: Mail },
{ name: 'Domain', href: '/settings/domain', icon: Globe },
{ name: 'Server', href: '/settings/server', icon: Server },
],
},
]
interface SidebarProps {
userName?: string | null
userEmail?: string | null
}
export function Sidebar({ userName, userEmail }: SidebarProps) {
const pathname = usePathname()
const [mobileOpen, setMobileOpen] = useState(false)
const isActive = (href: string) => {
if (href === '/') return pathname === '/'
return pathname.startsWith(href)
}
const navContent = (
<>
<div className="flex h-16 items-center gap-2 border-b px-6">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground font-bold text-sm">
LB
</div>
<div>
<p className="text-sm font-semibold">LetsBe</p>
<p className="text-xs text-muted-foreground">Hub Dashboard</p>
</div>
</div>
<nav className="flex-1 space-y-1 px-3 py-4">
{navigation.map((item) => {
if (item.children) {
const parentActive = isActive(item.href)
return (
<div key={item.name}>
<Link
href={item.href}
onClick={() => setMobileOpen(false)}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
parentActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
{parentActive && (
<div className="ml-7 mt-1 space-y-1">
{item.children.map((child) => (
<Link
key={child.href}
href={child.href}
onClick={() => setMobileOpen(false)}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-1.5 text-sm transition-colors',
pathname === child.href
? 'text-primary font-medium'
: 'text-muted-foreground hover:text-accent-foreground'
)}
>
<child.icon className="h-3.5 w-3.5" />
{child.name}
</Link>
))}
</div>
)}
</div>
)
}
return (
<Link
key={item.name}
href={item.href}
onClick={() => setMobileOpen(false)}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive(item.href)
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.name}
{item.badge && (
<Badge variant="default" className="ml-auto h-5 min-w-[20px] px-1.5 text-[10px]">
0
</Badge>
)}
</Link>
)
})}
</nav>
<div className="border-t p-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-medium">
{userName?.charAt(0)?.toUpperCase() || 'U'}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{userName || 'User'}</p>
<p className="text-xs text-muted-foreground truncate">{userEmail || ''}</p>
</div>
<form action="/api/auth/signout" method="POST">
<Button variant="ghost" size="icon" type="submit" title="Sign out">
<LogOut className="h-4 w-4" />
</Button>
</form>
</div>
</div>
</>
)
return (
<>
{/* Mobile menu button */}
<div className="fixed top-0 left-0 right-0 z-40 flex h-14 items-center border-b bg-background px-4 lg:hidden">
<Button
variant="ghost"
size="icon"
onClick={() => setMobileOpen(!mobileOpen)}
>
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</Button>
<div className="ml-3 flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary text-primary-foreground font-bold text-xs">
LB
</div>
<span className="text-sm font-semibold">LetsBe Hub</span>
</div>
</div>
{/* Mobile sidebar overlay */}
{mobileOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setMobileOpen(false)}
/>
)}
{/* Mobile sidebar */}
<aside
className={cn(
'fixed inset-y-0 left-0 z-50 w-64 flex-col bg-background border-r transition-transform duration-300 lg:hidden flex',
mobileOpen ? 'translate-x-0' : '-translate-x-full'
)}
>
{navContent}
</aside>
{/* Desktop sidebar */}
<aside className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col lg:border-r lg:bg-background">
{navContent}
</aside>
</>
)
}

View File

@ -0,0 +1,134 @@
'use client'
import { useState, useEffect } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
ExternalLink,
CheckCircle2,
XCircle,
AlertCircle,
Cloud,
Shield,
MessageSquare,
Mail,
Container,
Workflow,
Database,
BarChart3,
Calendar,
FileText,
Globe,
Lock,
GitBranch,
Activity,
Send,
Table,
AlertTriangle,
Paintbrush,
} from 'lucide-react'
import type { ToolInfo } from '@/types'
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
Cloud,
Shield,
MessageSquare,
Mail,
Container,
Workflow,
Database,
BarChart3,
Calendar,
FileText,
Globe,
Lock,
GitBranch,
Activity,
Send,
Table,
AlertTriangle,
Paintbrush,
}
function StatusIcon({ status }: { status: string }) {
switch (status) {
case 'running':
return <CheckCircle2 className="h-4 w-4 text-green-600" />
case 'stopped':
return <XCircle className="h-4 w-4 text-red-500" />
case 'error':
return <AlertCircle className="h-4 w-4 text-yellow-500" />
default:
return <AlertCircle className="h-4 w-4 text-muted-foreground" />
}
}
function StatusBadge({ status }: { status: string }) {
switch (status) {
case 'running':
return <Badge variant="success">Running</Badge>
case 'stopped':
return <Badge variant="destructive">Stopped</Badge>
case 'error':
return <Badge variant="warning">Error</Badge>
default:
return <Badge variant="secondary">Unknown</Badge>
}
}
interface ToolLauncherProps {
tools: Omit<ToolInfo, 'status'>[]
}
export function ToolLauncher({ tools }: ToolLauncherProps) {
const [toolStatuses, setToolStatuses] = useState<Record<string, string>>({})
useEffect(() => {
const statuses: Record<string, string> = {}
tools.forEach((tool) => {
statuses[tool.id] = 'running'
})
setToolStatuses(statuses)
}, [tools])
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{tools.map((tool) => {
const Icon = iconMap[tool.icon] || Globe
const status = toolStatuses[tool.id] || 'unknown'
return (
<Card key={tool.id} className="hover:shadow-md transition-shadow">
<CardContent className="pt-4 pb-4">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2.5">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-muted">
<Icon className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium leading-tight">{tool.name}</p>
<p className="text-xs text-muted-foreground">{tool.subdomain}.domain</p>
</div>
</div>
<StatusIcon status={status} />
</div>
<p className="text-xs text-muted-foreground mb-3 line-clamp-1">
{tool.description}
</p>
<div className="flex items-center justify-between">
<StatusBadge status={status} />
<Button variant="ghost" size="sm" asChild className="h-7 text-xs">
<a href={tool.url} target="_blank" rel="noopener noreferrer">
Open
<ExternalLink className="ml-1 h-3 w-3" />
</a>
</Button>
</div>
</CardContent>
</Card>
)
})}
</div>
)
}

View File

@ -0,0 +1,193 @@
'use client'
import { useState, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { Send, X, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
interface ComposeFormProps {
defaultTo?: string
defaultSubject?: string
defaultBody?: string
inReplyTo?: string
references?: string[]
onCancel?: () => void
}
export function ComposeForm({
defaultTo = '',
defaultSubject = '',
defaultBody = '',
inReplyTo,
references,
onCancel,
}: ComposeFormProps) {
const router = useRouter()
const [to, setTo] = useState(defaultTo)
const [cc, setCc] = useState('')
const [bcc, setBcc] = useState('')
const [subject, setSubject] = useState(defaultSubject)
const [body, setBody] = useState(defaultBody)
const [showCc, setShowCc] = useState(false)
const [sending, setSending] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSend = useCallback(async () => {
if (!to.trim()) {
setError('Recipient is required')
return
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const toAddresses = to.split(',').map((e) => e.trim()).filter(Boolean)
for (const addr of toAddresses) {
if (!emailRegex.test(addr)) {
setError(`Invalid email address: ${addr}`)
return
}
}
if (!subject.trim()) {
setError('Subject is required')
return
}
setSending(true)
setError(null)
try {
const html = body
.split('\n')
.map((line) => `<p>${line || '&nbsp;'}</p>`)
.join('')
const res = await fetch('/api/email/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to: toAddresses[0],
cc: cc.trim() || undefined,
bcc: bcc.trim() || undefined,
subject: subject.trim(),
html,
text: body,
inReplyTo: inReplyTo || undefined,
references: references || undefined,
}),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Failed to send')
}
router.push('/email')
router.refresh()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to send email')
} finally {
setSending(false)
}
}, [to, cc, bcc, subject, body, inReplyTo, references, router])
return (
<div className="space-y-4">
<div className="space-y-3">
<div className="flex items-center gap-2">
<Label htmlFor="to" className="w-12 text-sm text-muted-foreground shrink-0">
To:
</Label>
<Input
id="to"
value={to}
onChange={(e) => setTo(e.target.value)}
placeholder="recipient@example.com"
className="flex-1"
/>
{!showCc && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowCc(true)}
className="text-xs text-muted-foreground"
>
CC/BCC
</Button>
)}
</div>
{showCc && (
<>
<div className="flex items-center gap-2">
<Label htmlFor="cc" className="w-12 text-sm text-muted-foreground shrink-0">
CC:
</Label>
<Input
id="cc"
value={cc}
onChange={(e) => setCc(e.target.value)}
placeholder="cc@example.com"
className="flex-1"
/>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="bcc" className="w-12 text-sm text-muted-foreground shrink-0">
BCC:
</Label>
<Input
id="bcc"
value={bcc}
onChange={(e) => setBcc(e.target.value)}
placeholder="bcc@example.com"
className="flex-1"
/>
</div>
</>
)}
<div className="flex items-center gap-2">
<Label htmlFor="subject" className="w-12 text-sm text-muted-foreground shrink-0">
Subj:
</Label>
<Input
id="subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Subject"
className="flex-1"
/>
</div>
</div>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Write your message..."
className="w-full min-h-[300px] rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-y"
/>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<div className="flex items-center gap-2">
<Button onClick={handleSend} disabled={sending}>
{sending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
Send
</Button>
{onCancel && (
<Button variant="ghost" onClick={onCancel}>
<X className="mr-2 h-4 w-4" />
Discard
</Button>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,172 @@
'use client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import {
Reply,
Forward,
Trash2,
Mail,
MailOpen,
Star,
StarOff,
Loader2,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
interface EmailToolbarProps {
uid: number
folder: string
flags: string[]
from?: { name: string; address: string } | null
subject?: string
messageId?: string | null
references?: string[]
onFlagsChange?: (flags: string[]) => void
}
export function EmailToolbar({
uid,
folder,
flags,
from,
subject,
messageId,
references,
onFlagsChange,
}: EmailToolbarProps) {
const router = useRouter()
const [loading, setLoading] = useState<string | null>(null)
const isRead = flags.includes('\\Seen')
const isStarred = flags.includes('\\Flagged')
async function patchAction(action: string) {
setLoading(action)
try {
const res = await fetch(`/api/email/messages/${uid}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, folder }),
})
if (res.ok && onFlagsChange) {
const newFlags = [...flags]
if (action === 'markRead' && !newFlags.includes('\\Seen')) newFlags.push('\\Seen')
if (action === 'markUnread') {
const idx = newFlags.indexOf('\\Seen')
if (idx >= 0) newFlags.splice(idx, 1)
}
if (action === 'star' && !newFlags.includes('\\Flagged')) newFlags.push('\\Flagged')
if (action === 'unstar') {
const idx = newFlags.indexOf('\\Flagged')
if (idx >= 0) newFlags.splice(idx, 1)
}
onFlagsChange(newFlags)
}
} catch {
// Silently handle
} finally {
setLoading(null)
}
}
async function handleDelete() {
setLoading('delete')
try {
const res = await fetch(
`/api/email/messages/${uid}?folder=${encodeURIComponent(folder)}`,
{ method: 'DELETE' }
)
if (res.ok) {
router.push(`/email/${encodeURIComponent(folder)}`)
router.refresh()
}
} catch {
// Silently handle
} finally {
setLoading(null)
}
}
function handleReply() {
const params = new URLSearchParams()
if (from?.address) params.set('to', from.address)
if (subject) params.set('subject', subject.startsWith('Re:') ? subject : `Re: ${subject}`)
if (messageId) params.set('inReplyTo', messageId)
if (references?.length) {
params.set('references', JSON.stringify([...references, messageId].filter(Boolean)))
}
router.push(`/email/compose?${params.toString()}`)
}
function handleForward() {
const params = new URLSearchParams()
if (subject) params.set('subject', subject.startsWith('Fwd:') ? subject : `Fwd: ${subject}`)
router.push(`/email/compose?${params.toString()}`)
}
return (
<div className="flex items-center gap-1 flex-wrap">
<Button variant="ghost" size="sm" onClick={handleReply}>
<Reply className="mr-1.5 h-4 w-4" />
Reply
</Button>
<Button variant="ghost" size="sm" onClick={handleForward}>
<Forward className="mr-1.5 h-4 w-4" />
Forward
</Button>
<div className="w-px h-5 bg-border mx-1" />
<Button
variant="ghost"
size="sm"
onClick={() => patchAction(isRead ? 'markUnread' : 'markRead')}
disabled={!!loading}
>
{loading === 'markRead' || loading === 'markUnread' ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : isRead ? (
<Mail className="mr-1.5 h-4 w-4" />
) : (
<MailOpen className="mr-1.5 h-4 w-4" />
)}
{isRead ? 'Unread' : 'Read'}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => patchAction(isStarred ? 'unstar' : 'star')}
disabled={!!loading}
>
{loading === 'star' || loading === 'unstar' ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : isStarred ? (
<StarOff className="mr-1.5 h-4 w-4" />
) : (
<Star className="mr-1.5 h-4 w-4" />
)}
{isStarred ? 'Unstar' : 'Star'}
</Button>
<div className="w-px h-5 bg-border mx-1" />
<Button
variant="ghost"
size="sm"
onClick={handleDelete}
className="text-destructive hover:text-destructive"
disabled={!!loading}
>
{loading === 'delete' ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<Trash2 className="mr-1.5 h-4 w-4" />
)}
Delete
</Button>
</div>
)
}

View File

@ -0,0 +1,145 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import {
Inbox,
Send,
FileEdit,
Trash2,
Archive,
Folder,
Star,
AlertCircle,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Skeleton } from '@/components/ui/skeleton'
export interface FolderInfo {
path: string
name: string
specialUse: string | null
messages: number
unseen: number
}
interface FolderListProps {
folders: FolderInfo[]
loading?: boolean
error?: string | null
}
const FOLDER_ICONS: Record<string, typeof Inbox> = {
'\\Inbox': Inbox,
'\\Sent': Send,
'\\Drafts': FileEdit,
'\\Trash': Trash2,
'\\Archive': Archive,
'\\Junk': AlertCircle,
'\\Flagged': Star,
}
const FOLDER_ORDER: string[] = [
'\\Inbox',
'\\Sent',
'\\Drafts',
'\\Archive',
'\\Junk',
'\\Trash',
]
function getFolderSlug(folder: FolderInfo): string {
if (folder.path === 'INBOX' || folder.specialUse === '\\Inbox') return 'INBOX'
return encodeURIComponent(folder.path)
}
function sortFolders(folders: FolderInfo[]): FolderInfo[] {
const special: FolderInfo[] = []
const custom: FolderInfo[] = []
for (const f of folders) {
if (f.specialUse && FOLDER_ORDER.includes(f.specialUse)) {
special.push(f)
} else if (f.path === 'INBOX') {
special.push({ ...f, specialUse: '\\Inbox' })
} else {
custom.push(f)
}
}
special.sort((a, b) => {
const ai = FOLDER_ORDER.indexOf(a.specialUse || '')
const bi = FOLDER_ORDER.indexOf(b.specialUse || '')
return ai - bi
})
custom.sort((a, b) => a.name.localeCompare(b.name))
return [...special, ...custom]
}
export function FolderList({ folders, loading, error }: FolderListProps) {
const pathname = usePathname()
if (loading) {
return (
<div className="space-y-2 p-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
)
}
if (error) {
return (
<div className="p-4 text-sm text-destructive">
<AlertCircle className="mb-2 h-4 w-4" />
{error}
</div>
)
}
const sorted = sortFolders(folders)
return (
<ScrollArea className="h-full">
<nav className="space-y-1 p-2">
{sorted.map((folder) => {
const slug = getFolderSlug(folder)
const href = `/email/${slug}`
const isActive =
pathname === href ||
(slug === 'INBOX' && pathname === '/email')
const Icon = FOLDER_ICONS[folder.specialUse || ''] || Folder
return (
<Link
key={folder.path}
href={href}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
isActive
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
>
<Icon className="h-4 w-4 shrink-0" />
<span className="truncate flex-1">{folder.name}</span>
{folder.unseen > 0 && (
<Badge
variant="default"
className="ml-auto h-5 min-w-[20px] px-1.5 text-[10px]"
>
{folder.unseen}
</Badge>
)}
</Link>
)
})}
</nav>
</ScrollArea>
)
}

View File

@ -0,0 +1,142 @@
'use client'
import Link from 'next/link'
import { Star, Paperclip } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Skeleton } from '@/components/ui/skeleton'
export interface MessageListItem {
uid: number
from: { name: string; address: string } | null
subject: string
date: string
flags: string[]
preview: string
}
interface MessageListProps {
messages: MessageListItem[]
folder: string
loading?: boolean
selectedUid?: number
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (diffDays === 0) {
return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
}
if (diffDays === 1) return 'Yesterday'
if (diffDays < 7) {
return date.toLocaleDateString(undefined, { weekday: 'short' })
}
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
function senderDisplay(from: { name: string; address: string } | null): string {
if (!from) return 'Unknown'
return from.name || from.address
}
function senderInitial(from: { name: string; address: string } | null): string {
const display = senderDisplay(from)
return display.charAt(0).toUpperCase()
}
export function MessageList({ messages, folder, loading, selectedUid }: MessageListProps) {
if (loading) {
return (
<div className="divide-y">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-start gap-3 p-4">
<Skeleton className="h-9 w-9 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-64" />
</div>
<Skeleton className="h-3 w-12" />
</div>
))}
</div>
)
}
if (messages.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<p className="text-sm">No messages</p>
</div>
)
}
return (
<div className="divide-y">
{messages.map((msg) => {
const isRead = msg.flags.includes('\\Seen')
const isStarred = msg.flags.includes('\\Flagged')
const encodedFolder = encodeURIComponent(folder)
return (
<Link
key={msg.uid}
href={`/email/${encodedFolder}/${msg.uid}`}
className={cn(
'flex items-start gap-3 p-4 transition-colors hover:bg-accent/50',
!isRead && 'bg-accent/20',
selectedUid === msg.uid && 'bg-accent'
)}
>
<div
className={cn(
'flex h-9 w-9 items-center justify-center rounded-full shrink-0 text-sm font-medium',
isRead
? 'bg-muted text-muted-foreground'
: 'bg-primary text-primary-foreground'
)}
>
{senderInitial(msg.from)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span
className={cn(
'text-sm truncate',
!isRead && 'font-semibold'
)}
>
{senderDisplay(msg.from)}
</span>
{isStarred && (
<Star className="h-3.5 w-3.5 fill-yellow-400 text-yellow-400 shrink-0" />
)}
</div>
<p
className={cn(
'text-sm truncate',
!isRead ? 'font-medium text-foreground' : 'text-muted-foreground'
)}
>
{msg.subject}
</p>
{msg.preview && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{msg.preview}
</p>
)}
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap shrink-0">
{formatDate(msg.date)}
</span>
</Link>
)
})}
</div>
)
}

View File

@ -0,0 +1,194 @@
'use client'
import { useMemo } from 'react'
import DOMPurify from 'dompurify'
import {
Paperclip,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton'
import { ScrollArea } from '@/components/ui/scroll-area'
import { ArrowLeft } from 'lucide-react'
interface Address {
name: string
address: string
}
interface Attachment {
filename: string
contentType: string
size: number
contentId: string | null
contentDisposition: string
}
export interface FullMessageData {
uid: number
from: Address | null
to: Address[]
cc: Address[]
bcc: Address[]
replyTo: Address | null
subject: string
date: string
flags: string[]
html: string
text: string
attachments: Attachment[]
messageId: string | null
inReplyTo: string | null
references: string[]
}
interface MessageViewProps {
message: FullMessageData | null
loading?: boolean
onBack?: () => void
}
function formatAddress(addr: Address | null): string {
if (!addr) return ''
if (addr.name) return `${addr.name} <${addr.address}>`
return addr.address
}
function formatAddressList(addrs: Address[]): string {
return addrs.map(formatAddress).join(', ')
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function formatFullDate(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
export function MessageView({ message, loading, onBack }: MessageViewProps) {
const safeHtml = useMemo(() => {
if (!message) return ''
if (message.html) {
return DOMPurify.sanitize(message.html, {
ALLOWED_TAGS: [
'p', 'br', 'b', 'i', 'u', 'em', 'strong', 'a', 'img',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li', 'blockquote', 'pre', 'code',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
'div', 'span', 'hr', 'sub', 'sup',
],
ALLOWED_ATTR: [
'href', 'src', 'alt', 'title', 'style', 'class',
'width', 'height', 'target', 'rel',
'colspan', 'rowspan', 'cellpadding', 'cellspacing',
],
ALLOW_DATA_ATTR: false,
ADD_ATTR: ['target'],
})
}
// Convert plain text to safe HTML
const escaped = message.text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
return `<pre style="white-space: pre-wrap; font-family: inherit;">${escaped}</pre>`
}, [message])
if (loading) {
return (
<div className="space-y-4 p-6">
<Skeleton className="h-8 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-1/3" />
<Separator />
<Skeleton className="h-64 w-full" />
</div>
)
}
if (!message) {
return (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<p className="text-sm">Select a message to read</p>
</div>
)
}
const fileAttachments = message.attachments.filter(
(a) => a.contentDisposition !== 'inline'
)
return (
<ScrollArea className="h-full">
<div className="p-6 space-y-4">
{onBack && (
<Button variant="ghost" size="sm" onClick={onBack} className="mb-2">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
)}
<div>
<h1 className="text-xl font-semibold">{message.subject}</h1>
<p className="text-xs text-muted-foreground mt-1">
{formatFullDate(message.date)}
</p>
</div>
<div className="space-y-1 text-sm">
<div className="flex gap-2">
<span className="text-muted-foreground w-12 shrink-0">From:</span>
<span className="font-medium">{formatAddress(message.from)}</span>
</div>
<div className="flex gap-2">
<span className="text-muted-foreground w-12 shrink-0">To:</span>
<span>{formatAddressList(message.to)}</span>
</div>
{message.cc.length > 0 && (
<div className="flex gap-2">
<span className="text-muted-foreground w-12 shrink-0">CC:</span>
<span>{formatAddressList(message.cc)}</span>
</div>
)}
</div>
{fileAttachments.length > 0 && (
<div className="flex flex-wrap gap-2">
{fileAttachments.map((att, i) => (
<Badge
key={i}
variant="secondary"
className="gap-1.5 py-1 px-2.5"
>
<Paperclip className="h-3 w-3" />
<span className="text-xs">{att.filename}</span>
<span className="text-xs text-muted-foreground">
({formatSize(att.size)})
</span>
</Badge>
))}
</div>
)}
<Separator />
<div
className="prose prose-sm max-w-none dark:prose-invert"
dangerouslySetInnerHTML={{ __html: safeHtml }}
/>
</div>
</ScrollArea>
)
}

View File

@ -0,0 +1,58 @@
'use client'
import { useState, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { Search, X } from 'lucide-react'
import { Input } from '@/components/ui/input'
interface SearchBarProps {
folder: string
defaultQuery?: string
}
export function SearchBar({ folder, defaultQuery = '' }: SearchBarProps) {
const router = useRouter()
const [query, setQuery] = useState(defaultQuery)
const handleSearch = useCallback(
(e: React.FormEvent) => {
e.preventDefault()
if (query.trim()) {
const params = new URLSearchParams({
q: query.trim(),
folder,
})
router.push(`/email/${encodeURIComponent(folder)}?${params.toString()}`)
}
},
[query, folder, router]
)
const handleClear = useCallback(() => {
setQuery('')
router.push(`/email/${encodeURIComponent(folder)}`)
}, [folder, router])
return (
<form onSubmit={handleSearch} className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search emails..."
className="pl-9 pr-8"
/>
{query && (
<button
type="button"
onClick={handleClear}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</form>
)
}

View File

@ -0,0 +1,66 @@
'use client'
import { useRef } from 'react'
import { KanbanColumn } from './kanban-column'
import type { VikunjaTask, VikunjaBucket } from '@/lib/vikunja-client'
interface KanbanBoardProps {
buckets: VikunjaBucket[]
tasks: VikunjaTask[]
onEditTask?: (task: VikunjaTask) => void
onToggleDone?: (task: VikunjaTask) => void
onMoveTask?: (taskId: number, bucketId: number) => void
onQuickAdd?: (title: string, bucketId: number) => void
}
export function KanbanBoard({
buckets,
tasks,
onEditTask,
onToggleDone,
onMoveTask,
onQuickAdd,
}: KanbanBoardProps) {
const dragTaskRef = useRef<VikunjaTask | null>(null)
function handleDragStart(_e: React.DragEvent, task: VikunjaTask) {
dragTaskRef.current = task
}
function handleDrop(bucketId: number) {
const task = dragTaskRef.current
if (task && task.bucket_id !== bucketId) {
onMoveTask?.(task.id, bucketId)
}
dragTaskRef.current = null
}
function getTasksForBucket(bucketId: number): VikunjaTask[] {
return tasks.filter((t) => t.bucket_id === bucketId)
}
if (buckets.length === 0) {
return (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<p className="text-sm">No kanban buckets found. Create a kanban view in Vikunja to use the board.</p>
</div>
)
}
return (
<div className="flex gap-4 overflow-x-auto pb-4">
{buckets.map((bucket) => (
<KanbanColumn
key={bucket.id}
bucket={bucket}
tasks={getTasksForBucket(bucket.id)}
onEditTask={onEditTask}
onToggleDone={onToggleDone}
onDragStart={handleDragStart}
onDrop={handleDrop}
onQuickAdd={onQuickAdd}
/>
))}
</div>
)
}

View File

@ -0,0 +1,134 @@
'use client'
import { useState } from 'react'
import { cn } from '@/lib/utils'
import { TaskCard } from './task-card'
import { Plus } from 'lucide-react'
import type { VikunjaTask, VikunjaBucket } from '@/lib/vikunja-client'
interface KanbanColumnProps {
bucket: VikunjaBucket
tasks: VikunjaTask[]
onEditTask?: (task: VikunjaTask) => void
onToggleDone?: (task: VikunjaTask) => void
onDragStart?: (e: React.DragEvent, task: VikunjaTask) => void
onDrop?: (bucketId: number) => void
onQuickAdd?: (title: string, bucketId: number) => void
}
export function KanbanColumn({
bucket,
tasks,
onEditTask,
onToggleDone,
onDragStart,
onDrop,
onQuickAdd,
}: KanbanColumnProps) {
const [isDragOver, setIsDragOver] = useState(false)
const [isAdding, setIsAdding] = useState(false)
const [quickTitle, setQuickTitle] = useState('')
function handleDragOver(e: React.DragEvent) {
e.preventDefault()
setIsDragOver(true)
}
function handleDragLeave() {
setIsDragOver(false)
}
function handleDrop(e: React.DragEvent) {
e.preventDefault()
setIsDragOver(false)
onDrop?.(bucket.id)
}
function handleQuickAdd() {
const title = quickTitle.trim()
if (title) {
onQuickAdd?.(title, bucket.id)
setQuickTitle('')
setIsAdding(false)
}
}
return (
<div
className={cn(
'flex w-72 shrink-0 flex-col rounded-lg bg-muted/50',
isDragOver && 'ring-2 ring-primary/50'
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className="flex items-center justify-between px-3 py-2.5">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">{bucket.title}</h3>
<span className="flex h-5 min-w-[20px] items-center justify-center rounded-full bg-muted px-1.5 text-xs font-medium text-muted-foreground">
{tasks.length}
</span>
</div>
<button
type="button"
onClick={() => setIsAdding(true)}
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
<Plus className="h-4 w-4" />
</button>
</div>
<div className="flex-1 space-y-2 overflow-y-auto px-2 pb-2" style={{ maxHeight: 'calc(100vh - 280px)' }}>
{isAdding && (
<div className="rounded-lg border bg-card p-2">
<input
type="text"
value={quickTitle}
onChange={(e) => setQuickTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleQuickAdd()
if (e.key === 'Escape') { setIsAdding(false); setQuickTitle('') }
}}
placeholder="Task title..."
className="w-full rounded bg-transparent px-1 py-0.5 text-sm outline-none placeholder:text-muted-foreground"
autoFocus
/>
<div className="mt-1.5 flex justify-end gap-1">
<button
type="button"
onClick={() => { setIsAdding(false); setQuickTitle('') }}
className="rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent"
>
Cancel
</button>
<button
type="button"
onClick={handleQuickAdd}
className="rounded bg-primary px-2 py-1 text-xs text-primary-foreground hover:bg-primary/90"
>
Add
</button>
</div>
</div>
)}
{tasks.map((task) => (
<TaskCard
key={task.id}
task={task}
onEdit={onEditTask}
onToggleDone={onToggleDone}
onDragStart={onDragStart}
/>
))}
{tasks.length === 0 && !isAdding && (
<div className="py-8 text-center text-xs text-muted-foreground">
Drop tasks here
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,46 @@
import { cn } from '@/lib/utils'
const priorityConfig: Record<number, { label: string; className: string }> = {
0: { label: 'None', className: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' },
1: { label: 'Low', className: 'bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-400' },
2: { label: 'Medium', className: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-950 dark:text-yellow-400' },
3: { label: 'High', className: 'bg-orange-100 text-orange-700 dark:bg-orange-950 dark:text-orange-400' },
4: { label: 'Urgent', className: 'bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400' },
5: { label: 'Do Now', className: 'bg-red-200 text-red-800 dark:bg-red-900 dark:text-red-300 font-semibold' },
}
interface PriorityBadgeProps {
priority: number
className?: string
}
export function PriorityBadge({ priority, className }: PriorityBadgeProps) {
const config = priorityConfig[priority] || priorityConfig[0]
return (
<span
className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
config.className,
className
)}
>
{config.label}
</span>
)
}
export function getPriorityLabel(priority: number): string {
return priorityConfig[priority]?.label || 'None'
}
export function getPriorityColor(priority: number): string {
const colors: Record<number, string> = {
0: '#9ca3af',
1: '#3b82f6',
2: '#eab308',
3: '#f97316',
4: '#ef4444',
5: '#dc2626',
}
return colors[priority] || colors[0]
}

View File

@ -0,0 +1,121 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { cn } from '@/lib/utils'
import { FolderOpen, Plus, ListTodo } from 'lucide-react'
import { ScrollArea } from '@/components/ui/scroll-area'
import type { VikunjaProject } from '@/lib/vikunja-client'
interface ProjectSidebarProps {
projects: VikunjaProject[]
onCreateProject?: (title: string) => void
}
export function ProjectSidebar({ projects, onCreateProject }: ProjectSidebarProps) {
const pathname = usePathname()
const [isCreating, setIsCreating] = useState(false)
const [newTitle, setNewTitle] = useState('')
function handleCreate() {
const title = newTitle.trim()
if (title) {
onCreateProject?.(title)
setNewTitle('')
setIsCreating(false)
}
}
const activeProjects = projects.filter((p) => !p.is_archived)
return (
<div className="flex h-full w-56 flex-col border-r bg-muted/30">
<div className="flex items-center justify-between border-b px-3 py-3">
<Link
href="/tasks"
className={cn(
'flex items-center gap-2 text-sm font-semibold transition-colors',
pathname === '/tasks' ? 'text-primary' : 'text-foreground hover:text-primary'
)}
>
<ListTodo className="h-4 w-4" />
All Tasks
</Link>
<button
type="button"
onClick={() => setIsCreating(true)}
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
title="New project"
>
<Plus className="h-4 w-4" />
</button>
</div>
<ScrollArea className="flex-1">
<div className="space-y-0.5 p-2">
{isCreating && (
<div className="rounded-md border bg-card p-2">
<input
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreate()
if (e.key === 'Escape') { setIsCreating(false); setNewTitle('') }
}}
placeholder="Project name..."
className="w-full rounded bg-transparent px-1 py-0.5 text-sm outline-none placeholder:text-muted-foreground"
autoFocus
/>
<div className="mt-1 flex justify-end gap-1">
<button
type="button"
onClick={() => { setIsCreating(false); setNewTitle('') }}
className="rounded px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent"
>
Cancel
</button>
<button
type="button"
onClick={handleCreate}
className="rounded bg-primary px-2 py-0.5 text-xs text-primary-foreground hover:bg-primary/90"
>
Create
</button>
</div>
</div>
)}
{activeProjects.map((project) => {
const isActive = pathname === `/tasks/project/${project.id}`
return (
<Link
key={project.id}
href={`/tasks/project/${project.id}`}
className={cn(
'flex items-center gap-2 rounded-md px-2.5 py-2 text-sm transition-colors',
isActive
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
>
<FolderOpen
className="h-4 w-4 shrink-0"
style={project.hex_color ? { color: project.hex_color } : undefined}
/>
<span className="truncate">{project.title}</span>
</Link>
)
})}
{activeProjects.length === 0 && !isCreating && (
<p className="px-2.5 py-4 text-center text-xs text-muted-foreground">
No projects yet
</p>
)}
</div>
</ScrollArea>
</div>
)
}

View File

@ -0,0 +1,115 @@
'use client'
import { cn } from '@/lib/utils'
import { PriorityBadge } from './priority-badge'
import { Calendar, GripVertical } from 'lucide-react'
import type { VikunjaTask } from '@/lib/vikunja-client'
interface TaskCardProps {
task: VikunjaTask
onEdit?: (task: VikunjaTask) => void
onToggleDone?: (task: VikunjaTask) => void
onDragStart?: (e: React.DragEvent, task: VikunjaTask) => void
}
function getDueDateStatus(dueDate: string | null): 'overdue' | 'today' | 'week' | 'future' | null {
if (!dueDate) return null
const due = new Date(dueDate)
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const dueDay = new Date(due.getFullYear(), due.getMonth(), due.getDate())
const diffDays = Math.floor((dueDay.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays < 0) return 'overdue'
if (diffDays === 0) return 'today'
if (diffDays <= 7) return 'week'
return 'future'
}
const dueDateStyles: Record<string, string> = {
overdue: 'text-red-600 dark:text-red-400',
today: 'text-orange-600 dark:text-orange-400',
week: 'text-yellow-600 dark:text-yellow-400',
future: 'text-muted-foreground',
}
export function TaskCard({ task, onEdit, onToggleDone, onDragStart }: TaskCardProps) {
const dueStatus = getDueDateStatus(task.due_date)
return (
<div
draggable
onDragStart={(e) => onDragStart?.(e, task)}
onClick={() => onEdit?.(task)}
className={cn(
'group cursor-pointer rounded-lg border bg-card p-3 shadow-sm transition-shadow hover:shadow-md',
task.done && 'opacity-60'
)}
>
<div className="flex items-start gap-2">
<GripVertical className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground/40 opacity-0 group-hover:opacity-100 cursor-grab" />
<div className="flex-1 min-w-0">
<div className="flex items-start gap-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onToggleDone?.(task)
}}
className={cn(
'mt-0.5 h-4 w-4 shrink-0 rounded border-2 transition-colors',
task.done
? 'border-primary bg-primary'
: 'border-muted-foreground/30 hover:border-primary'
)}
>
{task.done && (
<svg viewBox="0 0 16 16" className="h-full w-full text-primary-foreground">
<path fill="currentColor" d="M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0z" />
</svg>
)}
</button>
<p className={cn(
'text-sm font-medium leading-snug',
task.done && 'line-through text-muted-foreground'
)}>
{task.title}
</p>
</div>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{task.priority > 0 && (
<PriorityBadge priority={task.priority} />
)}
{task.due_date && (
<span className={cn(
'inline-flex items-center gap-1 text-xs',
dueStatus ? dueDateStyles[dueStatus] : 'text-muted-foreground'
)}>
<Calendar className="h-3 w-3" />
{new Date(task.due_date).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
})}
</span>
)}
{task.labels?.map((label) => (
<span
key={label.id}
className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
style={{
backgroundColor: label.hex_color ? `${label.hex_color}20` : '#e5e7eb',
color: label.hex_color || '#6b7280',
}}
>
{label.title}
</span>
))}
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,224 @@
'use client'
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Trash2 } from 'lucide-react'
import type { VikunjaTask, VikunjaLabel } from '@/lib/vikunja-client'
interface TaskDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
task?: VikunjaTask | null
labels: VikunjaLabel[]
projectId: number | null
onSave: (data: TaskFormData) => void
onDelete?: (taskId: number) => void
}
export interface TaskFormData {
title: string
description: string
due_date: string | null
priority: number
labels: { id: number }[]
projectId: number
}
const priorityOptions = [
{ value: 0, label: 'None' },
{ value: 1, label: 'Low' },
{ value: 2, label: 'Medium' },
{ value: 3, label: 'High' },
{ value: 4, label: 'Urgent' },
{ value: 5, label: 'Do Now' },
]
export function TaskDialog({
open,
onOpenChange,
task,
labels,
projectId,
onSave,
onDelete,
}: TaskDialogProps) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [dueDate, setDueDate] = useState('')
const [priority, setPriority] = useState(0)
const [selectedLabels, setSelectedLabels] = useState<number[]>([])
const [saving, setSaving] = useState(false)
const isEditing = !!task
useEffect(() => {
if (task) {
setTitle(task.title)
setDescription(task.description || '')
setDueDate(task.due_date ? task.due_date.split('T')[0] : '')
setPriority(task.priority)
setSelectedLabels(task.labels?.map((l) => l.id) || [])
} else {
setTitle('')
setDescription('')
setDueDate('')
setPriority(0)
setSelectedLabels([])
}
}, [task, open])
function toggleLabel(labelId: number) {
setSelectedLabels((prev) =>
prev.includes(labelId)
? prev.filter((id) => id !== labelId)
: [...prev, labelId]
)
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!title.trim() || !projectId) return
setSaving(true)
try {
await onSave({
title: title.trim(),
description,
due_date: dueDate ? new Date(dueDate).toISOString() : null,
priority,
labels: selectedLabels.map((id) => ({ id })),
projectId,
})
onOpenChange(false)
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{isEditing ? 'Edit Task' : 'New Task'}</DialogTitle>
<DialogDescription>
{isEditing ? 'Update the task details below.' : 'Fill in the details to create a new task.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="task-title">Title</Label>
<Input
id="task-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Task title..."
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="task-desc">Description</Label>
<textarea
id="task-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional description..."
rows={3}
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="task-due">Due Date</Label>
<Input
id="task-due"
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="task-priority">Priority</Label>
<select
id="task-priority"
value={priority}
onChange={(e) => setPriority(Number(e.target.value))}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{priorityOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
{labels.length > 0 && (
<div className="space-y-2">
<Label>Labels</Label>
<div className="flex flex-wrap gap-1.5">
{labels.map((label) => {
const selected = selectedLabels.includes(label.id)
return (
<button
key={label.id}
type="button"
onClick={() => toggleLabel(label.id)}
className="rounded-full px-2.5 py-1 text-xs font-medium transition-opacity"
style={{
backgroundColor: label.hex_color ? `${label.hex_color}${selected ? '40' : '15'}` : (selected ? '#e5e7eb' : '#f3f4f6'),
color: label.hex_color || '#6b7280',
outline: selected ? `2px solid ${label.hex_color || '#9ca3af'}` : 'none',
outlineOffset: '1px',
}}
>
{label.title}
</button>
)
})}
</div>
</div>
)}
<DialogFooter className="gap-2">
{isEditing && onDelete && task && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
onDelete(task.id)
onOpenChange(false)
}}
className="mr-auto text-destructive hover:text-destructive"
>
<Trash2 className="mr-1 h-4 w-4" />
Delete
</Button>
)}
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!title.trim() || saving}>
{saving ? 'Saving...' : isEditing ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,131 @@
'use client'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { Filter, X } from 'lucide-react'
import type { VikunjaLabel } from '@/lib/vikunja-client'
export interface TaskFilters {
priority: number | null
labelId: number | null
dueDate: 'overdue' | 'today' | 'week' | null
status: 'all' | 'open' | 'done'
}
interface TaskFiltersBarProps {
filters: TaskFilters
onChange: (filters: TaskFilters) => void
labels: VikunjaLabel[]
}
export function TaskFiltersBar({ filters, onChange, labels }: TaskFiltersBarProps) {
const hasFilters = filters.priority !== null || filters.labelId !== null || filters.dueDate !== null || filters.status !== 'all'
function clearFilters() {
onChange({ priority: null, labelId: null, dueDate: null, status: 'all' })
}
return (
<div className="flex flex-wrap items-center gap-2">
<Filter className="h-4 w-4 text-muted-foreground" />
{/* Status filter */}
<div className="flex rounded-md border">
{(['all', 'open', 'done'] as const).map((status) => (
<button
key={status}
type="button"
onClick={() => onChange({ ...filters, status })}
className={cn(
'px-2.5 py-1 text-xs font-medium capitalize transition-colors first:rounded-l-md last:rounded-r-md',
filters.status === status
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent'
)}
>
{status}
</button>
))}
</div>
{/* Priority filter */}
<select
value={filters.priority ?? ''}
onChange={(e) => onChange({ ...filters, priority: e.target.value ? Number(e.target.value) : null })}
className="h-7 rounded-md border bg-background px-2 text-xs"
>
<option value="">Any priority</option>
<option value="5">Do Now</option>
<option value="4">Urgent</option>
<option value="3">High</option>
<option value="2">Medium</option>
<option value="1">Low</option>
</select>
{/* Due date filter */}
<select
value={filters.dueDate ?? ''}
onChange={(e) => onChange({ ...filters, dueDate: (e.target.value || null) as TaskFilters['dueDate'] })}
className="h-7 rounded-md border bg-background px-2 text-xs"
>
<option value="">Any due date</option>
<option value="overdue">Overdue</option>
<option value="today">Due today</option>
<option value="week">Due this week</option>
</select>
{/* Label filter */}
{labels.length > 0 && (
<select
value={filters.labelId ?? ''}
onChange={(e) => onChange({ ...filters, labelId: e.target.value ? Number(e.target.value) : null })}
className="h-7 rounded-md border bg-background px-2 text-xs"
>
<option value="">Any label</option>
{labels.map((label) => (
<option key={label.id} value={label.id}>
{label.title}
</option>
))}
</select>
)}
{hasFilters && (
<Button variant="ghost" size="sm" onClick={clearFilters} className="h-7 gap-1 px-2 text-xs">
<X className="h-3 w-3" />
Clear
</Button>
)}
</div>
)
}
export function applyFilters(tasks: { done: boolean; priority: number; due_date: string | null; labels?: { id: number }[] }[], filters: TaskFilters) {
return tasks.filter((task) => {
// Status
if (filters.status === 'open' && task.done) return false
if (filters.status === 'done' && !task.done) return false
// Priority
if (filters.priority !== null && task.priority !== filters.priority) return false
// Label
if (filters.labelId !== null && !task.labels?.some((l) => l.id === filters.labelId)) return false
// Due date
if (filters.dueDate) {
if (!task.due_date) return false
const due = new Date(task.due_date)
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const dueDay = new Date(due.getFullYear(), due.getMonth(), due.getDate())
const diffDays = Math.floor((dueDay.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
if (filters.dueDate === 'overdue' && diffDays >= 0) return false
if (filters.dueDate === 'today' && diffDays !== 0) return false
if (filters.dueDate === 'week' && (diffDays < 0 || diffDays > 7)) return false
}
return true
})
}

View File

@ -0,0 +1,167 @@
'use client'
import { useState } from 'react'
import { cn } from '@/lib/utils'
import { PriorityBadge } from './priority-badge'
import { Calendar, ArrowUpDown } from 'lucide-react'
import type { VikunjaTask } from '@/lib/vikunja-client'
interface TaskListViewProps {
tasks: VikunjaTask[]
onEditTask?: (task: VikunjaTask) => void
onToggleDone?: (task: VikunjaTask) => void
}
type SortField = 'title' | 'priority' | 'due_date' | 'created'
type SortDir = 'asc' | 'desc'
export function TaskListView({ tasks, onEditTask, onToggleDone }: TaskListViewProps) {
const [sortField, setSortField] = useState<SortField>('priority')
const [sortDir, setSortDir] = useState<SortDir>('desc')
function toggleSort(field: SortField) {
if (sortField === field) {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
} else {
setSortField(field)
setSortDir(field === 'title' ? 'asc' : 'desc')
}
}
const sorted = [...tasks].sort((a, b) => {
const dir = sortDir === 'asc' ? 1 : -1
switch (sortField) {
case 'title':
return dir * a.title.localeCompare(b.title)
case 'priority':
return dir * (a.priority - b.priority)
case 'due_date': {
if (!a.due_date && !b.due_date) return 0
if (!a.due_date) return 1
if (!b.due_date) return -1
return dir * (new Date(a.due_date).getTime() - new Date(b.due_date).getTime())
}
case 'created':
return dir * (new Date(a.created).getTime() - new Date(b.created).getTime())
default:
return 0
}
})
function getDueDateClass(dueDate: string | null): string {
if (!dueDate) return 'text-muted-foreground'
const due = new Date(dueDate)
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const dueDay = new Date(due.getFullYear(), due.getMonth(), due.getDate())
const diffDays = Math.floor((dueDay.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays < 0) return 'text-red-600 dark:text-red-400'
if (diffDays === 0) return 'text-orange-600 dark:text-orange-400'
if (diffDays <= 7) return 'text-yellow-600 dark:text-yellow-400'
return 'text-muted-foreground'
}
const SortHeader = ({ field, children }: { field: SortField; children: React.ReactNode }) => (
<button
type="button"
onClick={() => toggleSort(field)}
className="flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
>
{children}
<ArrowUpDown className={cn('h-3 w-3', sortField === field && 'text-foreground')} />
</button>
)
if (tasks.length === 0) {
return (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<p className="text-sm">No tasks found.</p>
</div>
)
}
return (
<div className="rounded-lg border">
<div className="grid grid-cols-[auto_1fr_100px_120px_auto] items-center gap-3 border-b px-4 py-2.5">
<div className="w-5" />
<SortHeader field="title">Title</SortHeader>
<SortHeader field="priority">Priority</SortHeader>
<SortHeader field="due_date">Due Date</SortHeader>
<div className="w-20 text-xs font-medium text-muted-foreground">Labels</div>
</div>
<div className="divide-y">
{sorted.map((task) => (
<div
key={task.id}
onClick={() => onEditTask?.(task)}
className="grid cursor-pointer grid-cols-[auto_1fr_100px_120px_auto] items-center gap-3 px-4 py-2.5 transition-colors hover:bg-muted/50"
>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onToggleDone?.(task)
}}
className={cn(
'h-5 w-5 shrink-0 rounded border-2 transition-colors',
task.done
? 'border-primary bg-primary'
: 'border-muted-foreground/30 hover:border-primary'
)}
>
{task.done && (
<svg viewBox="0 0 16 16" className="h-full w-full text-primary-foreground">
<path fill="currentColor" d="M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0z" />
</svg>
)}
</button>
<span className={cn(
'truncate text-sm',
task.done && 'line-through text-muted-foreground'
)}>
{task.title}
</span>
<div>
{task.priority > 0 && <PriorityBadge priority={task.priority} />}
</div>
<div>
{task.due_date && (
<span className={cn('inline-flex items-center gap-1 text-xs', getDueDateClass(task.due_date))}>
<Calendar className="h-3 w-3" />
{new Date(task.due_date).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
})}
</span>
)}
</div>
<div className="flex w-20 flex-wrap gap-1">
{task.labels?.slice(0, 2).map((label) => (
<span
key={label.id}
className="inline-block max-w-[4rem] truncate rounded-full px-1.5 py-0.5 text-[10px] font-medium"
style={{
backgroundColor: label.hex_color ? `${label.hex_color}20` : '#e5e7eb',
color: label.hex_color || '#6b7280',
}}
>
{label.title}
</span>
))}
{(task.labels?.length || 0) > 2 && (
<span className="text-[10px] text-muted-foreground">
+{task.labels!.length - 2}
</span>
)}
</div>
</div>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,59 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const alertVariants = cva(
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
{
variants: {
variant: {
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
},
},
defaultVariants: {
variant: 'default',
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = 'Alert'
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
{...props}
/>
))
AlertTitle.displayName = 'AlertTitle'
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('text-sm [&_p]:leading-relaxed', className)}
{...props}
/>
))
AlertDescription.displayName = 'AlertDescription'
export { Alert, AlertTitle, AlertDescription }

View File

@ -0,0 +1,48 @@
import * as React from 'react'
import * as AvatarPrimitive from '@radix-ui/react-avatar'
import { cn } from '@/lib/utils'
const Avatar = React.forwardRef<
React.ComponentRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ComponentRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn('aspect-square h-full w-full', className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ComponentRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
'flex h-full w-full items-center justify-center rounded-full bg-muted',
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@ -0,0 +1,40 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
success:
'border-transparent bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100',
warning:
'border-transparent bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100',
},
},
defaultVariants: {
variant: 'default',
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,56 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View File

@ -0,0 +1,79 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-lg border bg-card text-card-foreground shadow-sm',
className
)}
{...props}
/>
))
Card.displayName = 'Card'
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
))
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
'text-2xl font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
))
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
))
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
))
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -0,0 +1,117 @@
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className
)}
{...props}
/>
)
DialogHeader.displayName = 'DialogHeader'
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
)
DialogFooter.displayName = 'DialogFooter'
const DialogTitle = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -0,0 +1,84 @@
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { cn } from '@/lib/utils'
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuContent = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuLabel = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuLabel,
DropdownMenuGroup,
}

View File

@ -0,0 +1,22 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = 'Input'
export { Input }

View File

@ -0,0 +1,24 @@
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
)
const Label = React.forwardRef<
React.ComponentRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@ -0,0 +1,26 @@
import * as React from 'react'
import * as ProgressPrimitive from '@radix-ui/react-progress'
import { cn } from '@/lib/utils'
const Progress = React.forwardRef<
React.ComponentRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@ -0,0 +1,46 @@
import * as React from 'react'
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import { cn } from '@/lib/utils'
const ScrollArea = React.forwardRef<
React.ComponentRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ComponentRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@ -0,0 +1,29 @@
import * as React from 'react'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import { cn } from '@/lib/utils'
const Separator = React.forwardRef<
React.ComponentRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = 'horizontal', decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@ -0,0 +1,90 @@
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const Sheet = DialogPrimitive.Root
const SheetTrigger = DialogPrimitive.Trigger
const SheetClose = DialogPrimitive.Close
const SheetPortal = DialogPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = DialogPrimitive.Overlay.displayName
const SheetContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
side?: 'top' | 'bottom' | 'left' | 'right'
}
>(({ side = 'right', className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
side === 'left' &&
'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
side === 'right' &&
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = DialogPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className
)}
{...props}
/>
)
SheetHeader.displayName = 'SheetHeader'
const SheetTitle = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold text-foreground', className)}
{...props}
/>
))
SheetTitle.displayName = DialogPrimitive.Title.displayName
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetTitle,
}

View File

@ -0,0 +1,15 @@
import { cn } from '@/lib/utils'
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('animate-pulse rounded-md bg-muted', className)}
{...props}
/>
)
}
export { Skeleton }

View File

@ -0,0 +1,53 @@
import * as React from 'react'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { cn } from '@/lib/utils'
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -0,0 +1,28 @@
import * as React from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '@/lib/utils'
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ComponentRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

427
src/lib/caldav-client.ts Normal file
View File

@ -0,0 +1,427 @@
import { DAVClient, DAVCalendar, DAVCalendarObject } from 'tsdav'
const CALDAV_URL = process.env.CALDAV_URL || 'https://cloud.localhost/remote.php/dav'
export interface CalendarInfo {
url: string
displayName: string
description?: string
color?: string
ctag?: string
}
export interface CalendarEvent {
id: string
url: string
etag?: string
title: string
start: string
end: string
allDay: boolean
location?: string
description?: string
calendarId: string
calendarUrl: string
recurrence?: string
status?: string
raw?: string
}
async function getClient(accessToken: string): Promise<DAVClient> {
// Nextcloud CalDAV with Keycloak OIDC: the access token from Keycloak
// can be used as a Bearer token if Nextcloud is configured with OIDC.
// We pass it as the password with "Bearer" as username for the Basic auth
// header, which tsdav will encode. Alternatively, Nextcloud may accept
// the token directly via the user_oidc app.
//
// The most reliable approach for Nextcloud: use the OAuth access token
// as a password with an empty username, since Nextcloud user_oidc maps
// the token to the user automatically.
const client = new DAVClient({
serverUrl: CALDAV_URL,
credentials: {
username: '',
password: '',
accessToken,
refreshToken: '',
tokenUrl: '',
},
authMethod: 'Oauth',
defaultAccountType: 'caldav',
})
await client.login()
return client
}
function parseICalDate(value: string): { date: string; allDay: boolean } {
if (!value) return { date: new Date().toISOString(), allDay: false }
// All-day event: DTSTART;VALUE=DATE:20250115 or just 20250115
const dateOnly = value.replace(/[^\d]/g, '')
if (dateOnly.length === 8) {
const year = dateOnly.slice(0, 4)
const month = dateOnly.slice(4, 6)
const day = dateOnly.slice(6, 8)
return { date: `${year}-${month}-${day}`, allDay: true }
}
// DateTime: 20250115T090000Z or 20250115T090000
if (dateOnly.length >= 14) {
const year = dateOnly.slice(0, 4)
const month = dateOnly.slice(4, 6)
const day = dateOnly.slice(6, 8)
const hour = dateOnly.slice(8, 10)
const min = dateOnly.slice(10, 12)
return {
date: `${year}-${month}-${day} ${hour}:${min}`,
allDay: false,
}
}
return { date: value, allDay: false }
}
function extractICalField(ical: string, field: string): string {
const lines = ical.split(/\r?\n/)
let result = ''
let capturing = false
for (const line of lines) {
if (line.startsWith(`${field}:`) || line.startsWith(`${field};`)) {
const colonIdx = line.indexOf(':')
result = line.slice(colonIdx + 1)
capturing = true
continue
}
if (capturing) {
if (line.startsWith(' ') || line.startsWith('\t')) {
result += line.slice(1)
} else {
break
}
}
}
return result
.replace(/\\n/g, '\n')
.replace(/\\,/g, ',')
.replace(/\\\\/g, '\\')
.trim()
}
function extractICalDateField(ical: string, field: string): string {
const lines = ical.split(/\r?\n/)
for (const line of lines) {
if (line.startsWith(`${field}:`) || line.startsWith(`${field};`)) {
const colonIdx = line.indexOf(':')
return line.slice(colonIdx + 1).trim()
}
}
return ''
}
function extractUID(ical: string): string {
return extractICalField(ical, 'UID') || `event-${Date.now()}`
}
function parseCalendarObject(
obj: DAVCalendarObject,
calendarUrl: string
): CalendarEvent | null {
const ical = obj.data
if (!ical || !ical.includes('VEVENT')) return null
const uid = extractUID(ical)
const title = extractICalField(ical, 'SUMMARY') || '(No title)'
const location = extractICalField(ical, 'LOCATION')
const description = extractICalField(ical, 'DESCRIPTION')
const status = extractICalField(ical, 'STATUS')
const rrule = extractICalField(ical, 'RRULE')
const startRaw = extractICalDateField(ical, 'DTSTART')
const endRaw = extractICalDateField(ical, 'DTEND')
const startParsed = parseICalDate(startRaw)
const endParsed = parseICalDate(endRaw || startRaw)
return {
id: uid,
url: obj.url,
etag: obj.etag || undefined,
title,
start: startParsed.date,
end: endParsed.date,
allDay: startParsed.allDay,
location: location || undefined,
description: description || undefined,
calendarId: calendarUrl,
calendarUrl,
recurrence: rrule || undefined,
status: status || undefined,
raw: ical,
}
}
export async function getCalendars(
accessToken: string
): Promise<CalendarInfo[]> {
const client = await getClient(accessToken)
const calendars = await client.fetchCalendars()
return calendars.map((cal: DAVCalendar) => ({
url: cal.url,
displayName: cal.displayName || 'Calendar',
description: cal.description || undefined,
color:
(cal as Record<string, unknown>).calendarColor as string | undefined,
ctag: cal.ctag || undefined,
}))
}
export async function getEvents(
accessToken: string,
start: string,
end: string,
calendarUrl?: string
): Promise<CalendarEvent[]> {
const client = await getClient(accessToken)
const calendars = await client.fetchCalendars()
const targetCalendars = calendarUrl
? calendars.filter((c: DAVCalendar) => c.url === calendarUrl)
: calendars
const allEvents: CalendarEvent[] = []
for (const cal of targetCalendars) {
try {
const objects = await client.fetchCalendarObjects({
calendar: cal,
timeRange: {
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
},
expand: true,
})
for (const obj of objects) {
const event = parseCalendarObject(obj, cal.url)
if (event) {
allEvents.push(event)
}
}
} catch (err) {
console.error(`Failed to fetch events from ${cal.displayName}:`, err)
}
}
return allEvents
}
function formatICalDate(dateStr: string, allDay: boolean): string {
if (allDay) {
const d = new Date(dateStr)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}${month}${day}`
}
const d = new Date(dateStr.replace(' ', 'T'))
if (isNaN(d.getTime())) {
// Try parsing "YYYY-MM-DD HH:mm" format directly
const [datePart, timePart] = dateStr.split(' ')
const [y, m, dd] = datePart.split('-')
const [hh, mm] = (timePart || '00:00').split(':')
return `${y}${m}${dd}T${hh}${mm}00`
}
const year = d.getUTCFullYear()
const month = String(d.getUTCMonth() + 1).padStart(2, '0')
const day = String(d.getUTCDate()).padStart(2, '0')
const hour = String(d.getUTCHours()).padStart(2, '0')
const min = String(d.getUTCMinutes()).padStart(2, '0')
const sec = String(d.getUTCSeconds()).padStart(2, '0')
return `${year}${month}${day}T${hour}${min}${sec}Z`
}
function escapeICalText(text: string): string {
return text
.replace(/\\/g, '\\\\')
.replace(/,/g, '\\,')
.replace(/;/g, '\\;')
.replace(/\n/g, '\\n')
}
function buildICalEvent(event: {
title: string
start: string
end: string
allDay?: boolean
location?: string
description?: string
recurrence?: string
uid?: string
}): string {
const uid = event.uid || `${Date.now()}-${Math.random().toString(36).slice(2)}@letsbe`
const now = formatICalDate(new Date().toISOString(), false)
const allDay = event.allDay ?? false
const dtStartParam = allDay ? 'DTSTART;VALUE=DATE' : 'DTSTART'
const dtEndParam = allDay ? 'DTEND;VALUE=DATE' : 'DTEND'
const startVal = formatICalDate(event.start, allDay)
const endVal = formatICalDate(event.end, allDay)
let ical = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//LetsBe//Hub Dashboard//EN',
'CALSCALE:GREGORIAN',
'BEGIN:VEVENT',
`UID:${uid}`,
`DTSTAMP:${now}`,
`${dtStartParam}:${startVal}`,
`${dtEndParam}:${endVal}`,
`SUMMARY:${escapeICalText(event.title)}`,
]
if (event.location) {
ical.push(`LOCATION:${escapeICalText(event.location)}`)
}
if (event.description) {
ical.push(`DESCRIPTION:${escapeICalText(event.description)}`)
}
if (event.recurrence) {
ical.push(`RRULE:${event.recurrence}`)
}
ical.push('END:VEVENT', 'END:VCALENDAR')
return ical.join('\r\n')
}
export async function createEvent(
accessToken: string,
event: {
title: string
start: string
end: string
allDay?: boolean
location?: string
description?: string
calendarUrl: string
recurrence?: string
}
): Promise<CalendarEvent> {
const client = await getClient(accessToken)
const calendars = await client.fetchCalendars()
const calendar = calendars.find(
(c: DAVCalendar) => c.url === event.calendarUrl
)
if (!calendar) {
throw new Error('Calendar not found')
}
const uid = `${Date.now()}-${Math.random().toString(36).slice(2)}@letsbe`
const iCalString = buildICalEvent({ ...event, uid })
const filename = `${uid.replace('@', '-')}.ics`
await client.createCalendarObject({
calendar,
iCalString,
filename,
})
return {
id: uid,
url: `${calendar.url}${filename}`,
title: event.title,
start: event.start,
end: event.end,
allDay: event.allDay ?? false,
location: event.location,
description: event.description,
calendarId: event.calendarUrl,
calendarUrl: event.calendarUrl,
recurrence: event.recurrence,
}
}
export async function updateEvent(
accessToken: string,
eventUrl: string,
event: {
title: string
start: string
end: string
allDay?: boolean
location?: string
description?: string
recurrence?: string
}
): Promise<void> {
const client = await getClient(accessToken)
const calendars = await client.fetchCalendars()
let existingObj: DAVCalendarObject | undefined
for (const cal of calendars) {
try {
const objects = await client.fetchCalendarObjects({
calendar: cal,
objectUrls: [eventUrl],
})
if (objects.length > 0) {
existingObj = objects[0]
break
}
} catch {
// Try next calendar
}
}
if (!existingObj) {
throw new Error('Event not found')
}
const uid = extractUID(existingObj.data || '')
const iCalString = buildICalEvent({ ...event, uid })
await client.updateCalendarObject({
calendarObject: {
...existingObj,
data: iCalString,
},
})
}
export async function deleteEvent(
accessToken: string,
eventUrl: string
): Promise<void> {
const client = await getClient(accessToken)
const calendars = await client.fetchCalendars()
let existingObj: DAVCalendarObject | undefined
for (const cal of calendars) {
try {
const objects = await client.fetchCalendarObjects({
calendar: cal,
objectUrls: [eventUrl],
})
if (objects.length > 0) {
existingObj = objects[0]
break
}
} catch {
// Try next calendar
}
}
if (!existingObj) {
throw new Error('Event not found')
}
await client.deleteCalendarObject({
calendarObject: existingObj,
})
}

342
src/lib/imap-client.ts Normal file
View File

@ -0,0 +1,342 @@
import { ImapFlow } from 'imapflow'
import { simpleParser, ParsedMail } from 'mailparser'
function getImapConfig() {
return {
host: process.env.IMAP_HOST || 'localhost',
port: Number(process.env.IMAP_PORT) || 993,
secure: (Number(process.env.IMAP_PORT) || 993) === 993,
auth: {
user: process.env.MAIL_USER || '',
pass: process.env.MAIL_PASSWORD || '',
},
logger: false as const,
}
}
async function withClient<T>(fn: (client: ImapFlow) => Promise<T>): Promise<T> {
const client = new ImapFlow(getImapConfig())
await client.connect()
try {
return await fn(client)
} finally {
await client.logout()
}
}
export interface MailFolder {
path: string
name: string
delimiter: string
specialUse: string | null
subscribed: boolean
messages: number
unseen: number
}
export interface MessageSummary {
uid: number
from: { name: string; address: string } | null
to: { name: string; address: string }[]
subject: string
date: string
flags: string[]
preview: string
}
export interface FullMessage {
uid: number
from: { name: string; address: string } | null
to: { name: string; address: string }[]
cc: { name: string; address: string }[]
bcc: { name: string; address: string }[]
replyTo: { name: string; address: string } | null
subject: string
date: string
flags: string[]
html: string
text: string
attachments: {
filename: string
contentType: string
size: number
contentId: string | null
contentDisposition: string
}[]
messageId: string | null
inReplyTo: string | null
references: string[]
}
function parseAddress(addr: { name?: string; address?: string } | undefined) {
if (!addr) return null
return { name: addr.name || '', address: addr.address || '' }
}
function parseAddressList(
addrs: ParsedMail['to']
): { name: string; address: string }[] {
if (!addrs) return []
const list = Array.isArray(addrs) ? addrs : [addrs]
const result: { name: string; address: string }[] = []
for (const group of list) {
if ('value' in group) {
for (const a of group.value) {
result.push({ name: a.name || '', address: a.address || '' })
}
}
}
return result
}
export async function listFolders(): Promise<MailFolder[]> {
return withClient(async (client) => {
const mailboxes = await client.list()
const folders: MailFolder[] = []
for (const mb of mailboxes) {
let messages = 0
let unseen = 0
try {
const status = await client.status(mb.path, { messages: true, unseen: true })
messages = status.messages ?? 0
unseen = status.unseen ?? 0
} catch {
// Some folders may not support STATUS
}
const parts = mb.path.split(mb.delimiter || '/')
folders.push({
path: mb.path,
name: parts[parts.length - 1] || mb.path,
delimiter: mb.delimiter || '/',
specialUse: (mb as Record<string, unknown>).specialUse as string | null ?? null,
subscribed: mb.subscribed ?? false,
messages,
unseen,
})
}
return folders
})
}
export async function listMessages(
folder: string,
page: number,
limit: number
): Promise<{ messages: MessageSummary[]; total: number }> {
return withClient(async (client) => {
const lock = await client.getMailboxLock(folder)
try {
const total = client.mailbox?.exists ?? 0
if (total === 0) {
return { messages: [], total: 0 }
}
// Calculate range (newest first)
const end = total - (page - 1) * limit
const start = Math.max(1, end - limit + 1)
if (end < 1) {
return { messages: [], total }
}
const messages: MessageSummary[] = []
for await (const msg of client.fetch(`${start}:${end}`, {
uid: true,
envelope: true,
flags: true,
bodyStructure: true,
headers: ['content-type'],
})) {
const env = msg.envelope
messages.push({
uid: msg.uid,
from: env.from?.[0]
? { name: env.from[0].name || '', address: env.from[0].address || '' }
: null,
to: (env.to || []).map((a: { name?: string; address?: string }) => ({
name: a.name || '',
address: a.address || '',
})),
subject: env.subject || '(No Subject)',
date: env.date ? new Date(env.date).toISOString() : new Date().toISOString(),
flags: Array.from(msg.flags || []),
preview: '',
})
}
// Reverse so newest first
messages.reverse()
return { messages, total }
} finally {
lock.release()
}
})
}
export async function getMessage(
folder: string,
uid: number
): Promise<FullMessage | null> {
return withClient(async (client) => {
const lock = await client.getMailboxLock(folder)
try {
const raw = await client.fetchOne(String(uid), { source: true, flags: true, uid: true }, { uid: true })
if (!raw?.source) return null
const parsed = await simpleParser(raw.source)
return {
uid: raw.uid,
from: parseAddress(parsed.from?.value?.[0]),
to: parseAddressList(parsed.to),
cc: parseAddressList(parsed.cc),
bcc: parseAddressList(parsed.bcc),
replyTo: parsed.replyTo?.value?.[0]
? parseAddress(parsed.replyTo.value[0])
: null,
subject: parsed.subject || '(No Subject)',
date: parsed.date ? parsed.date.toISOString() : new Date().toISOString(),
flags: Array.from(raw.flags || []),
html: parsed.html || '',
text: parsed.text || '',
attachments: (parsed.attachments || []).map((att) => ({
filename: att.filename || 'attachment',
contentType: att.contentType || 'application/octet-stream',
size: att.size || 0,
contentId: att.contentId || null,
contentDisposition: att.contentDisposition || 'attachment',
})),
messageId: parsed.messageId || null,
inReplyTo: (parsed.inReplyTo as string) || null,
references: Array.isArray(parsed.references)
? parsed.references
: parsed.references
? [parsed.references]
: [],
}
} finally {
lock.release()
}
})
}
export async function updateFlags(
folder: string,
uid: number,
action: 'add' | 'remove',
flags: string[]
): Promise<void> {
return withClient(async (client) => {
const lock = await client.getMailboxLock(folder)
try {
if (action === 'add') {
await client.messageFlagsAdd(String(uid), flags, { uid: true })
} else {
await client.messageFlagsRemove(String(uid), flags, { uid: true })
}
} finally {
lock.release()
}
})
}
export async function moveMessage(
folder: string,
uid: number,
destination: string
): Promise<void> {
return withClient(async (client) => {
const lock = await client.getMailboxLock(folder)
try {
await client.messageMove(String(uid), destination, { uid: true })
} finally {
lock.release()
}
})
}
export async function deleteMessage(
folder: string,
uid: number
): Promise<void> {
return withClient(async (client) => {
const lock = await client.getMailboxLock(folder)
try {
// Try to move to Trash first
const mailboxes = await client.list()
const trash = mailboxes.find(
(mb) => (mb as Record<string, unknown>).specialUse === '\\Trash'
)
if (trash && folder !== trash.path) {
await client.messageMove(String(uid), trash.path, { uid: true })
} else {
// Already in trash or no trash folder -- permanently delete
await client.messageFlagsAdd(String(uid), ['\\Deleted'], { uid: true })
await client.messageDelete(String(uid), { uid: true })
}
} finally {
lock.release()
}
})
}
export async function searchMessages(
query: string,
folder: string
): Promise<MessageSummary[]> {
return withClient(async (client) => {
const lock = await client.getMailboxLock(folder)
try {
const uids = await client.search(
{
or: [
{ subject: query },
{ from: query },
{ to: query },
{ body: query },
],
},
{ uid: true }
)
if (uids.length === 0) return []
// Limit results
const limitedUids = uids.slice(-100)
const uidRange = limitedUids.join(',')
const messages: MessageSummary[] = []
for await (const msg of client.fetch(uidRange, {
uid: true,
envelope: true,
flags: true,
}, { uid: true })) {
const env = msg.envelope
messages.push({
uid: msg.uid,
from: env.from?.[0]
? { name: env.from[0].name || '', address: env.from[0].address || '' }
: null,
to: (env.to || []).map((a: { name?: string; address?: string }) => ({
name: a.name || '',
address: a.address || '',
})),
subject: env.subject || '(No Subject)',
date: env.date ? new Date(env.date).toISOString() : new Date().toISOString(),
flags: Array.from(msg.flags || []),
preview: '',
})
}
messages.reverse()
return messages
} finally {
lock.release()
}
})
}

67
src/lib/orchestrator.ts Normal file
View File

@ -0,0 +1,67 @@
const ORCHESTRATOR_URL = process.env.ORCHESTRATOR_URL || 'http://orchestrator:8100'
export interface OrchestratorHealth {
status: string
uptime_seconds: number
cpu_percent: number
memory_percent: number
disk_percent: number
disk_used_gb: number
disk_total_gb: number
agent_count: number
task_count: number
}
export interface Agent {
id: string
name: string
status: string
last_heartbeat: string
capabilities: string[]
}
export interface Task {
id: string
type: string
status: string
created_at: string
completed_at: string | null
result: string | null
error: string | null
}
async function orchestratorFetch(path: string, options?: RequestInit) {
const url = `${ORCHESTRATOR_URL}${path}`
const res = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
})
if (!res.ok) {
throw new Error(`Orchestrator API error: ${res.status} ${res.statusText}`)
}
return res.json()
}
export async function getHealth(): Promise<OrchestratorHealth> {
return orchestratorFetch('/api/v1/health')
}
export async function getAgents(): Promise<Agent[]> {
return orchestratorFetch('/api/v1/agents')
}
export async function getTasks(limit = 20): Promise<Task[]> {
return orchestratorFetch(`/api/v1/tasks?limit=${limit}`)
}
export async function triggerPlaybook(playbook: string, params?: Record<string, string>) {
return orchestratorFetch(`/api/v1/playbooks/${playbook}`, {
method: 'POST',
body: JSON.stringify(params || {}),
})
}

53
src/lib/smtp-client.ts Normal file
View File

@ -0,0 +1,53 @@
import nodemailer from 'nodemailer'
function getTransporter() {
return nodemailer.createTransport({
host: process.env.SMTP_HOST || 'localhost',
port: Number(process.env.SMTP_PORT) || 587,
secure: (Number(process.env.SMTP_PORT) || 587) === 465,
auth: {
user: process.env.MAIL_USER || '',
pass: process.env.MAIL_PASSWORD || '',
},
})
}
export interface SendEmailOptions {
to: string
cc?: string
bcc?: string
subject: string
html: string
text?: string
inReplyTo?: string
references?: string[]
attachments?: {
filename: string
content: string // base64
contentType: string
}[]
}
export async function sendEmail(options: SendEmailOptions): Promise<{ messageId: string }> {
const transporter = getTransporter()
const from = process.env.MAIL_USER || ''
const info = await transporter.sendMail({
from,
to: options.to,
cc: options.cc || undefined,
bcc: options.bcc || undefined,
subject: options.subject,
html: options.html,
text: options.text || undefined,
inReplyTo: options.inReplyTo || undefined,
references: options.references?.join(' ') || undefined,
attachments: options.attachments?.map((att) => ({
filename: att.filename,
content: Buffer.from(att.content, 'base64'),
contentType: att.contentType,
})),
})
return { messageId: info.messageId }
}

168
src/lib/tools.ts Normal file
View File

@ -0,0 +1,168 @@
import type { ToolInfo } from '@/types'
const domain = process.env.TENANT_DOMAIN || 'example.com'
export const AVAILABLE_TOOLS: Omit<ToolInfo, 'status'>[] = [
{
id: 'nextcloud',
name: 'Nextcloud',
description: 'File sync, sharing, and collaboration platform',
subdomain: 'cloud',
icon: 'Cloud',
category: 'productivity',
url: `https://cloud.${domain}`,
},
{
id: 'keycloak',
name: 'Keycloak',
description: 'Identity and access management (SSO)',
subdomain: 'auth',
icon: 'Shield',
category: 'security',
url: `https://auth.${domain}`,
},
{
id: 'chatwoot',
name: 'Chatwoot',
description: 'Customer engagement and live chat platform',
subdomain: 'chat',
icon: 'MessageSquare',
category: 'communication',
url: `https://chat.${domain}`,
},
{
id: 'poste',
name: 'Poste.io',
description: 'Full-featured email server',
subdomain: 'mail',
icon: 'Mail',
category: 'communication',
url: `https://mail.${domain}`,
},
{
id: 'portainer',
name: 'Portainer',
description: 'Container management dashboard',
subdomain: 'portainer',
icon: 'Container',
category: 'monitoring',
url: `https://portainer.${domain}`,
},
{
id: 'n8n',
name: 'n8n',
description: 'Workflow automation tool',
subdomain: 'n8n',
icon: 'Workflow',
category: 'productivity',
url: `https://n8n.${domain}`,
},
{
id: 'minio',
name: 'MinIO',
description: 'S3-compatible object storage',
subdomain: 'storage',
icon: 'Database',
category: 'storage',
url: `https://storage.${domain}`,
},
{
id: 'umami',
name: 'Umami',
description: 'Privacy-focused web analytics',
subdomain: 'analytics',
icon: 'BarChart3',
category: 'monitoring',
url: `https://analytics.${domain}`,
},
{
id: 'calcom',
name: 'Cal.com',
description: 'Scheduling and appointment booking',
subdomain: 'cal',
icon: 'Calendar',
category: 'productivity',
url: `https://cal.${domain}`,
},
{
id: 'ghost',
name: 'Ghost',
description: 'Publishing and newsletter platform',
subdomain: 'blog',
icon: 'FileText',
category: 'marketing',
url: `https://blog.${domain}`,
},
{
id: 'wordpress',
name: 'WordPress',
description: 'Content management system',
subdomain: 'www',
icon: 'Globe',
category: 'marketing',
url: `https://www.${domain}`,
},
{
id: 'vaultwarden',
name: 'Vaultwarden',
description: 'Password manager (Bitwarden compatible)',
subdomain: 'vault',
icon: 'Lock',
category: 'security',
url: `https://vault.${domain}`,
},
{
id: 'gitea',
name: 'Gitea',
description: 'Self-hosted Git service',
subdomain: 'git',
icon: 'GitBranch',
category: 'development',
url: `https://git.${domain}`,
},
{
id: 'uptime-kuma',
name: 'Uptime Kuma',
description: 'Uptime monitoring tool',
subdomain: 'status',
icon: 'Activity',
category: 'monitoring',
url: `https://status.${domain}`,
},
{
id: 'listmonk',
name: 'Listmonk',
description: 'Newsletter and mailing list manager',
subdomain: 'newsletter',
icon: 'Send',
category: 'marketing',
url: `https://newsletter.${domain}`,
},
{
id: 'nocodb',
name: 'NocoDB',
description: 'Open-source Airtable alternative',
subdomain: 'db',
icon: 'Table',
category: 'productivity',
url: `https://db.${domain}`,
},
{
id: 'glitchtip',
name: 'GlitchTip',
description: 'Error tracking and monitoring',
subdomain: 'errors',
icon: 'AlertTriangle',
category: 'monitoring',
url: `https://errors.${domain}`,
},
{
id: 'penpot',
name: 'Penpot',
description: 'Design and prototyping platform',
subdomain: 'design',
icon: 'Paintbrush',
category: 'productivity',
url: `https://design.${domain}`,
},
]

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

184
src/lib/vikunja-client.ts Normal file
View File

@ -0,0 +1,184 @@
const VIKUNJA_URL = process.env.VIKUNJA_URL || 'https://tasks.example.com/api/v1'
const VIKUNJA_API_TOKEN = process.env.VIKUNJA_API_TOKEN || ''
export interface VikunjaProject {
id: number
title: string
description: string
hex_color: string
is_archived: boolean
is_favorite: boolean
position: number
created: string
updated: string
views: VikunjaProjectView[]
}
export interface VikunjaProjectView {
id: number
title: string
project_id: number
view_kind: number // 0=list, 1=gantt, 2=table, 3=kanban
position: number
bucket_configuration_mode: number
default_bucket_id: number
done_bucket_id: number
created: string
updated: string
}
export interface VikunjaTask {
id: number
title: string
description: string
done: boolean
done_at: string | null
due_date: string | null
priority: number // 0=unset, 1=low, 2=medium, 3=high, 4=urgent, 5=do now
project_id: number
bucket_id: number
position: number
labels: VikunjaLabel[]
created: string
updated: string
created_by: { id: number; name: string; username: string } | null
}
export interface VikunjaLabel {
id: number
title: string
hex_color: string
created: string
updated: string
}
export interface VikunjaBucket {
id: number
title: string
project_view_id: number
position: number
limit: number
count: number
created: string
updated: string
}
async function vikunjaFetch<T>(path: string, options?: RequestInit): Promise<T> {
const url = `${VIKUNJA_URL}${path}`
const res = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${VIKUNJA_API_TOKEN}`,
...options?.headers,
},
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`Vikunja API error: ${res.status} ${res.statusText} - ${text}`)
}
const text = await res.text()
if (!text) return null as T
return JSON.parse(text)
}
// Projects
export async function getProjects(): Promise<VikunjaProject[]> {
return vikunjaFetch<VikunjaProject[]>('/projects')
}
export async function getProject(id: number): Promise<VikunjaProject> {
return vikunjaFetch<VikunjaProject>(`/projects/${id}`)
}
export async function createProject(data: { title: string; description?: string; hex_color?: string }): Promise<VikunjaProject> {
return vikunjaFetch<VikunjaProject>('/projects', {
method: 'PUT',
body: JSON.stringify(data),
})
}
// Tasks
export async function getAllTasks(params?: {
page?: number
per_page?: number
sort_by?: string
order_by?: string
filter?: string
}): Promise<VikunjaTask[]> {
const searchParams = new URLSearchParams()
if (params?.page) searchParams.set('page', String(params.page))
if (params?.per_page) searchParams.set('per_page', String(params.per_page))
if (params?.sort_by) searchParams.set('sort_by', params.sort_by)
if (params?.order_by) searchParams.set('order_by', params.order_by)
if (params?.filter) searchParams.set('filter', params.filter)
const qs = searchParams.toString()
return vikunjaFetch<VikunjaTask[]>(`/tasks/all${qs ? `?${qs}` : ''}`)
}
export async function getProjectTasks(projectId: number, viewId: number): Promise<VikunjaTask[]> {
return vikunjaFetch<VikunjaTask[]>(`/projects/${projectId}/views/${viewId}/tasks`)
}
export async function createTask(projectId: number, data: {
title: string
description?: string
due_date?: string | null
priority?: number
bucket_id?: number
labels?: { id: number }[]
}): Promise<VikunjaTask> {
return vikunjaFetch<VikunjaTask>(`/projects/${projectId}/tasks`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function updateTask(taskId: number, data: Partial<{
title: string
description: string
done: boolean
due_date: string | null
priority: number
bucket_id: number
labels: { id: number }[]
}>): Promise<VikunjaTask> {
return vikunjaFetch<VikunjaTask>(`/tasks/${taskId}`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function deleteTask(taskId: number): Promise<void> {
await vikunjaFetch<unknown>(`/tasks/${taskId}`, {
method: 'DELETE',
})
}
// Buckets
export async function getProjectBuckets(projectId: number, viewId: number): Promise<VikunjaBucket[]> {
return vikunjaFetch<VikunjaBucket[]>(`/projects/${projectId}/views/${viewId}/buckets`)
}
export async function createBucket(projectId: number, viewId: number, data: { title: string }): Promise<VikunjaBucket> {
return vikunjaFetch<VikunjaBucket>(`/projects/${projectId}/views/${viewId}/buckets`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
// Labels
export async function getLabels(): Promise<VikunjaLabel[]> {
return vikunjaFetch<VikunjaLabel[]>('/labels')
}
// Task position / bucket move
export async function moveTaskToBucket(projectId: number, viewId: number, taskId: number, bucketId: number, position: number): Promise<VikunjaTask> {
return vikunjaFetch<VikunjaTask>(`/projects/${projectId}/views/${viewId}/tasks/${taskId}/position`, {
method: 'POST',
body: JSON.stringify({ bucket_id: bucketId, position }),
})
}

7
src/middleware.ts Normal file
View File

@ -0,0 +1,7 @@
export { auth as middleware } from '@/auth'
export const config = {
matcher: [
'/((?!api/auth|login|_next/static|_next/image|favicon.ico).*)',
],
}

36
src/types/index.ts Normal file
View File

@ -0,0 +1,36 @@
export interface ToolInfo {
id: string
name: string
description: string
subdomain: string
icon: string
category: 'communication' | 'productivity' | 'development' | 'monitoring' | 'storage' | 'marketing' | 'security' | 'other'
status: 'running' | 'stopped' | 'error' | 'unknown'
url: string
}
export interface ServerInfo {
hostname: string
ip: string
os: string
uptime: string
cpuUsage: number
memoryUsage: number
diskUsage: number
diskUsedGb: number
diskTotalGb: number
}
export interface SmtpConfig {
host: string
port: number
username: string
password: string
fromEmail: string
fromName: string
}
export interface DomainInfo {
domain: string
subdomains: { name: string; target: string; status: 'active' | 'pending' | 'error' }[]
}

57
tailwind.config.ts Normal file
View File

@ -0,0 +1,57 @@
import type { Config } from 'tailwindcss'
const config: Config = {
darkMode: ['class'],
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [require('tailwindcss-animate')],
}
export default config

28
tsconfig.json Normal file
View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
},
"target": "ES2022"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": ["node_modules"]
}