commit 615a36eb20fba2d8980b9312a5ffa62aec8f9f26 Author: Matt Date: Sat Feb 7 08:03:25 2026 +0100 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2937ed0 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8ccc874 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7df310b --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5da69fb --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..8388335 --- /dev/null +++ b/next.config.ts @@ -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 diff --git a/package.json b/package.json new file mode 100644 index 0000000..b8cc7d8 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..b90a152 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,9 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} + +module.exports = config diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..c865f77 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,2 @@ +import { handlers } from '@/auth' +export const { GET, POST } = handlers diff --git a/src/app/api/calendar/calendars/route.ts b/src/app/api/calendar/calendars/route.ts new file mode 100644 index 0000000..a8d6d20 --- /dev/null +++ b/src/app/api/calendar/calendars/route.ts @@ -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 } + ) + } +} diff --git a/src/app/api/calendar/events/[id]/route.ts b/src/app/api/calendar/events/[id]/route.ts new file mode 100644 index 0000000..573075b --- /dev/null +++ b/src/app/api/calendar/events/[id]/route.ts @@ -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 } + ) + } +} diff --git a/src/app/api/calendar/events/route.ts b/src/app/api/calendar/events/route.ts new file mode 100644 index 0000000..a0185c2 --- /dev/null +++ b/src/app/api/calendar/events/route.ts @@ -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 } + ) + } +} diff --git a/src/app/api/email/folders/route.ts b/src/app/api/email/folders/route.ts new file mode 100644 index 0000000..d9fabbf --- /dev/null +++ b/src/app/api/email/folders/route.ts @@ -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 }) + } +} diff --git a/src/app/api/email/messages/[uid]/route.ts b/src/app/api/email/messages/[uid]/route.ts new file mode 100644 index 0000000..846669c --- /dev/null +++ b/src/app/api/email/messages/[uid]/route.ts @@ -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 }) + } +} diff --git a/src/app/api/email/messages/route.ts b/src/app/api/email/messages/route.ts new file mode 100644 index 0000000..d703609 --- /dev/null +++ b/src/app/api/email/messages/route.ts @@ -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 }) + } +} diff --git a/src/app/api/email/search/route.ts b/src/app/api/email/search/route.ts new file mode 100644 index 0000000..f11465b --- /dev/null +++ b/src/app/api/email/search/route.ts @@ -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 }) + } +} diff --git a/src/app/api/proxy/[...path]/route.ts b/src/app/api/proxy/[...path]/route.ts new file mode 100644 index 0000000..0b13770 --- /dev/null +++ b/src/app/api/proxy/[...path]/route.ts @@ -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 = { + '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 diff --git a/src/app/api/tasks/[id]/move/route.ts b/src/app/api/tasks/[id]/move/route.ts new file mode 100644 index 0000000..8aabfb8 --- /dev/null +++ b/src/app/api/tasks/[id]/move/route.ts @@ -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 }) + } +} diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts new file mode 100644 index 0000000..47b6c95 --- /dev/null +++ b/src/app/api/tasks/[id]/route.ts @@ -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 }) + } +} diff --git a/src/app/api/tasks/labels/route.ts b/src/app/api/tasks/labels/route.ts new file mode 100644 index 0000000..3cf7628 --- /dev/null +++ b/src/app/api/tasks/labels/route.ts @@ -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 }) + } +} diff --git a/src/app/api/tasks/projects/[id]/route.ts b/src/app/api/tasks/projects/[id]/route.ts new file mode 100644 index 0000000..00eeea7 --- /dev/null +++ b/src/app/api/tasks/projects/[id]/route.ts @@ -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 }) + } +} diff --git a/src/app/api/tasks/projects/route.ts b/src/app/api/tasks/projects/route.ts new file mode 100644 index 0000000..08632e8 --- /dev/null +++ b/src/app/api/tasks/projects/route.ts @@ -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 }) + } +} diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts new file mode 100644 index 0000000..eb69d62 --- /dev/null +++ b/src/app/api/tasks/route.ts @@ -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 }) + } +} diff --git a/src/app/calendar/event/[id]/page.tsx b/src/app/calendar/event/[id]/page.tsx new file mode 100644 index 0000000..3eb3c66 --- /dev/null +++ b/src/app/calendar/event/[id]/page.tsx @@ -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(null) + const [calendars, setCalendars] = useState([]) + 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 ( +
+ +
+ ) + } + + if (!event) { + return ( +
+ +
+ Event not found +
+
+ ) + } + + 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 = { + 'FREQ=DAILY': 'Repeats daily', + 'FREQ=WEEKLY': 'Repeats weekly', + 'FREQ=MONTHLY': 'Repeats monthly', + 'FREQ=YEARLY': 'Repeats yearly', + } + + return ( +
+ + + + +
+
+ {event.title} +
+ + + {calendarName} + + {event.allDay && ( + All day + )} + {event.status && ( + {event.status} + )} +
+
+
+ + +
+
+
+ + +
+ +
+

+ {formatEventDate(event.start, event.allDay)} +

+ {event.start !== event.end && ( +

+ to {formatEventDate(event.end, event.allDay)} +

+ )} +
+
+ + {event.location && ( +
+ +

{event.location}

+
+ )} + + {event.recurrence && ( +
+ +

+ {recurrenceLabel[event.recurrence] || event.recurrence} +

+
+ )} + + {event.description && ( +
+

{event.description}

+
+ )} +
+
+ + setEditOpen(false)} + onSave={handleSave} + onDelete={handleDelete} + event={event} + calendars={calendars} + /> +
+ ) +} diff --git a/src/app/calendar/layout.tsx b/src/app/calendar/layout.tsx new file mode 100644 index 0000000..cd052a3 --- /dev/null +++ b/src/app/calendar/layout.tsx @@ -0,0 +1,7 @@ +export default function CalendarLayout({ + children, +}: { + children: React.ReactNode +}) { + return <>{children} +} diff --git a/src/app/calendar/page.tsx b/src/app/calendar/page.tsx new file mode 100644 index 0000000..96e163d --- /dev/null +++ b/src/app/calendar/page.tsx @@ -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([]) + const [events, setEvents] = useState([]) + const [loading, setLoading] = useState(true) + const [eventsLoading, setEventsLoading] = useState(false) + const [error, setError] = useState(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>( + new Set() + ) + const [calendarColors, setCalendarColors] = useState>( + {} + ) + + // Dialog state + const [dialogOpen, setDialogOpen] = useState(false) + const [editingEvent, setEditingEvent] = useState(null) + const [defaultDialogDate, setDefaultDialogDate] = useState('') + + // Build color map + useEffect(() => { + const colors: Record = {} + 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 ( +
+ +
+ ) + } + + if (error) { + return ( +
+
+
+

Calendar

+

+ Manage your schedule and events +

+
+
+
+

{error}

+ +
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+

Calendar

+

+ Manage your schedule and events +

+
+
+ + + CalDAV + + + +
+
+ + {/* Main layout */} +
+ {/* Left sidebar */} +
+ + + {todayEvents.length > 0 && ( +
+

+ Today's Events +

+
+ {todayEvents.map((evt) => ( + + ))} +
+
+ )} +
+ + {/* Calendar view */} +
+ +
+
+ + {/* Event dialog */} + { + setDialogOpen(false) + setEditingEvent(null) + }} + onSave={handleSaveEvent} + onDelete={editingEvent ? handleDeleteEvent : undefined} + event={editingEvent} + calendars={calendars} + defaultDate={defaultDialogDate} + defaultCalendarUrl={calendars[0]?.url} + /> +
+ ) +} + +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() +} diff --git a/src/app/email/[folder]/[uid]/page.tsx b/src/app/email/[folder]/[uid]/page.tsx new file mode 100644 index 0000000..d0dbdb0 --- /dev/null +++ b/src/app/email/[folder]/[uid]/page.tsx @@ -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(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( +
{error}
+ ) + } + + return ( +
+ {message && ( +
+ + setMessage((prev) => (prev ? { ...prev, flags } : prev)) + } + /> +
+ )} +
+ router.push(`/email/${encodeURIComponent(folder)}`)} + /> +
+
+ ) +} diff --git a/src/app/email/[folder]/page.tsx b/src/app/email/[folder]/page.tsx new file mode 100644 index 0000000..60a174f --- /dev/null +++ b/src/app/email/[folder]/page.tsx @@ -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([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [loading, setLoading] = useState(true) + const [loadingMore, setLoadingMore] = useState(false) + const [error, setError] = useState(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 ( +
+
+

{folderName}

+
+ +
+ +
+ + {searchQuery && ( +
+ Search results for "{searchQuery}" + {!loading && ` - ${messages.length} found`} +
+ )} + + {error &&
{error}
} + +
+ +
+ + {!searchQuery && messages.length < total && !loading && ( +
+ +
+ )} +
+ ) +} diff --git a/src/app/email/compose/page.tsx b/src/app/email/compose/page.tsx new file mode 100644 index 0000000..53c2256 --- /dev/null +++ b/src/app/email/compose/page.tsx @@ -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 ( +
+

+ {inReplyTo ? 'Reply' : subject?.startsWith('Fwd:') ? 'Forward' : 'New Message'} +

+ router.back()} + /> +
+ ) +} + +export default function ComposePage() { + return ( + Loading...}> + + + ) +} diff --git a/src/app/email/layout.tsx b/src/app/email/layout.tsx new file mode 100644 index 0000000..ae5f016 --- /dev/null +++ b/src/app/email/layout.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( +
+ {/* Folder sidebar */} +
+
+ + + + +
+ +
+ + {/* Main content */} +
+ {children} +
+
+ ) +} diff --git a/src/app/email/page.tsx b/src/app/email/page.tsx new file mode 100644 index 0000000..54b2a20 --- /dev/null +++ b/src/app/email/page.tsx @@ -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([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [loading, setLoading] = useState(true) + const [loadingMore, setLoadingMore] = useState(false) + const [error, setError] = useState(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 ( +
+
+

Inbox

+
+ +
+ +
+ + {error && ( +
{error}
+ )} + +
+ +
+ + {messages.length < total && !loading && ( +
+ +
+ )} +
+ ) +} diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 0000000..64281dd --- /dev/null +++ b/src/app/error.tsx @@ -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 ( +
+ +

Something went wrong

+

+ {error.message || 'An unexpected error occurred. Please try again.'} +

+ +
+ ) +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..00b08e3 --- /dev/null +++ b/src/app/globals.css @@ -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; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..6f7100f --- /dev/null +++ b/src/app/layout.tsx @@ -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 ( + + + + {session?.user ? ( +
+ +
+
+
{children}
+
+
+
+ ) : ( + children + )} +
+ + + ) +} diff --git a/src/app/loading.tsx b/src/app/loading.tsx new file mode 100644 index 0000000..671729b --- /dev/null +++ b/src/app/loading.tsx @@ -0,0 +1,31 @@ +import { Skeleton } from '@/components/ui/skeleton' + +export default function Loading() { + return ( +
+
+ + +
+
+ + + +
+
+ + + + +
+
+ + + + + + +
+
+ ) +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..547ea0b --- /dev/null +++ b/src/app/login/page.tsx @@ -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 ( +
+ + +
+ LB +
+ LetsBe Hub Dashboard + + Sign in to access your email, calendar, tasks, and tools + +
+ +
{ + 'use server' + await signIn('keycloak', { redirectTo: '/' }) + }} + > + +
+

+ Your identity is managed through Keycloak on this server. + Contact your administrator if you need access. +

+
+
+
+ ) +} diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..ab6484e --- /dev/null +++ b/src/app/not-found.tsx @@ -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 ( +
+

404

+

Page not found

+ +
+ ) +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..087d812 --- /dev/null +++ b/src/app/page.tsx @@ -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 ( +
+
+

Dashboard

+

+ Your business command center -- email, calendar, tasks, and server management +

+
+ + {/* Quick Actions */} + + + {/* Activity Feed + Server Health side by side on large screens */} +
+
+ +
+
+

Server Health

+ +
+
+ + {/* Tool Launcher */} +
+
+

Deployed Tools

+ +
+ +
+
+ ) +} diff --git a/src/app/settings/domain/page.tsx b/src/app/settings/domain/page.tsx new file mode 100644 index 0000000..f01bb70 --- /dev/null +++ b/src/app/settings/domain/page.tsx @@ -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 + case 'pending': + return + default: + return + } +} + +function DnsStatusBadge({ status }: { status: string }) { + switch (status) { + case 'active': + return Active + case 'pending': + return Pending + default: + return Error + } +} + +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 ( +
+
+

Domain & DNS

+

+ View your domain configuration and DNS record status +

+
+ + + + + + Domain Information + + Your primary domain and configuration + + +
+
+

Primary Domain

+

{domain}

+
+
+

Wildcard DNS

+
+ Configured + *.{domain} +
+
+
+
+
+ + + + Subdomain Records + + All subdomains pointing to your server ({subdomains.length} total) + + + +
+ {subdomains.map((sub) => ( +
+
+ +
+

{sub.fullDomain}

+

{sub.purpose}

+
+
+ +
+ ))} +
+
+
+ + + + DNS Configuration + Required DNS records for your domain + + +
+

; A record for root domain

+

{domain}. IN A YOUR_SERVER_IP

+

+

; Wildcard A record for all subdomains

+

*.{domain}. IN A YOUR_SERVER_IP

+

+

; MX record for email (if using Poste.io)

+

{domain}. IN MX 10 mail.{domain}.

+

+

; SPF record

+

{domain}. IN TXT "v=spf1 a mx ip4:YOUR_SERVER_IP ~all"

+
+
+
+
+ ) +} diff --git a/src/app/settings/email/page.tsx b/src/app/settings/email/page.tsx new file mode 100644 index 0000000..6937815 --- /dev/null +++ b/src/app/settings/email/page.tsx @@ -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(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 ( +
+
+

Email Configuration

+

+ Configure SMTP settings for outbound email from your tools +

+
+ + {saved && ( + + + Saved + + SMTP configuration has been updated. Email will be reconfigured shortly. + + + )} + + {error && ( + + + Error + {error} + + )} + + + + + + SMTP Settings + + + These settings are used by Poste.io and other tools to send outbound email. + Configure your noreply address and relay settings. + + + +
+
+ + setConfig({ ...config, host: e.target.value })} + /> +
+
+ + setConfig({ ...config, port: e.target.value })} + /> +
+
+ +
+
+ + setConfig({ ...config, username: e.target.value })} + /> +
+
+ + setConfig({ ...config, password: e.target.value })} + /> +
+
+ +
+
+ + setConfig({ ...config, fromEmail: e.target.value })} + /> +
+
+ + setConfig({ ...config, fromName: e.target.value })} + /> +
+
+ + +
+
+
+ ) +} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx new file mode 100644 index 0000000..b3b8479 --- /dev/null +++ b/src/app/settings/page.tsx @@ -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 ( +
+
+

Settings

+

+ Manage your server configuration and preferences +

+
+ +
+ {settingsSections.map((section) => ( + + +
+
+ +
+
+ {section.title} +
+
+ + {section.description} + +
+ + + +
+ ))} +
+
+ ) +} diff --git a/src/app/settings/server/page.tsx b/src/app/settings/server/page.tsx new file mode 100644 index 0000000..e49b223 --- /dev/null +++ b/src/app/settings/server/page.tsx @@ -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 ( +
+
+

Server Information

+

+ View server details, resource usage, and connection information +

+
+ + {healthError && ( + + + Connection Error + {healthError} + + )} + + + + + + Server Details + + Connection and identification information + + +
+
+

Server IP

+

{serverIp}

+
+
+

Domain

+

{domain}

+
+
+

Status

+ + {health ? 'Online' : 'Unreachable'} + +
+
+

Uptime

+

+ {health ? formatUptime(health.uptime_seconds) : 'N/A'} +

+
+
+
+
+ + {health && ( + + + Resource Usage + Current server resource utilization + + +
+
+
+ + CPU +
+ {health.cpu_percent.toFixed(1)}% +
+ +
+ +
+
+
+ + Memory +
+ {health.memory_percent.toFixed(1)}% +
+ +
+ +
+
+
+ + Disk +
+ + {health.disk_used_gb.toFixed(1)} / {health.disk_total_gb.toFixed(1)} GB ({health.disk_percent.toFixed(1)}%) + +
+ +
+
+
+ )} + + + + + + SSH Access + + + Connect to your server via SSH for advanced administration + + + +
+

# Connect via SSH

+

ssh root@{serverIp}

+

# Application directory

+

cd /opt/letsbe/

+
+

+ SSH credentials were provided during server provisioning. + Contact your administrator if you need access. +

+
+
+ + {health && ( + + + + + Orchestrator + + Automation agent status + + +
+
+

Status

+
+ + Connected +
+
+
+

Active Agents

+

{health.agent_count}

+
+
+

Total Tasks

+

{health.task_count}

+
+
+
+
+ )} +
+ ) +} diff --git a/src/app/tasks/layout.tsx b/src/app/tasks/layout.tsx new file mode 100644 index 0000000..06b5280 --- /dev/null +++ b/src/app/tasks/layout.tsx @@ -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([]) + + 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 ( +
+ +
+ {children} +
+
+ ) +} diff --git a/src/app/tasks/page.tsx b/src/app/tasks/page.tsx new file mode 100644 index 0000000..bc13a26 --- /dev/null +++ b/src/app/tasks/page.tsx @@ -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([]) + const [labels, setLabels] = useState([]) + const [projects, setProjects] = useState([]) + const [loading, setLoading] = useState(true) + const [dialogOpen, setDialogOpen] = useState(false) + const [editingTask, setEditingTask] = useState(null) + const [filters, setFilters] = useState({ + 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 ( +
+
+
+

All Tasks

+

+ Tasks across all projects +

+
+
+ + + Vikunja + + + +
+
+ + + + {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : ( + { setEditingTask(task); setDialogOpen(true) }} + onToggleDone={handleToggleDone} + /> + )} + + +
+ ) +} diff --git a/src/app/tasks/project/[id]/page.tsx b/src/app/tasks/project/[id]/page.tsx new file mode 100644 index 0000000..e5b6dbb --- /dev/null +++ b/src/app/tasks/project/[id]/page.tsx @@ -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(null) + const [tasks, setTasks] = useState([]) + const [labels, setLabels] = useState([]) + const [loading, setLoading] = useState(true) + const [dialogOpen, setDialogOpen] = useState(false) + const [editingTask, setEditingTask] = useState(null) + const [filters, setFilters] = useState({ + 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 ( +
+ + +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+
+ ) + } + + if (!project) { + return ( +
+

Project not found.

+
+ ) + } + + return ( +
+
+
+ +
+

{project.title}

+ {project.description && ( +

{project.description}

+ )} +
+
+
+ + {filteredTasks.length} tasks + + + +
+
+ + + + {hasBuckets ? ( + + + + + Board + + + + List + + + + + { setEditingTask(task); setDialogOpen(true) }} + onToggleDone={handleToggleDone} + onMoveTask={handleMoveTask} + onQuickAdd={handleQuickAdd} + /> + + + + { setEditingTask(task); setDialogOpen(true) }} + onToggleDone={handleToggleDone} + /> + + + ) : ( + { setEditingTask(task); setDialogOpen(true) }} + onToggleDone={handleToggleDone} + /> + )} + + +
+ ) +} diff --git a/src/app/tools/page.tsx b/src/app/tools/page.tsx new file mode 100644 index 0000000..f1ce500 --- /dev/null +++ b/src/app/tools/page.tsx @@ -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 = { + 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 + ) + + return ( +
+
+

Tools

+

+ Access all your deployed tools. Click "Open" to go to each tool's web interface. +

+
+ + {Object.entries(grouped).map(([category, tools]) => ( +
+

+ {categoryLabels[category] || category} +

+
+ {tools.map((tool) => ( + + +
+ {tool.name} + + {tool.subdomain} + +
+ {tool.description} +
+ + + +
+ ))} +
+
+ ))} +
+ ) +} diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..167d2d7 --- /dev/null +++ b/src/auth.ts @@ -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 + } +} diff --git a/src/components/calendar/calendar-sidebar.tsx b/src/components/calendar/calendar-sidebar.tsx new file mode 100644 index 0000000..78e81e8 --- /dev/null +++ b/src/components/calendar/calendar-sidebar.tsx @@ -0,0 +1,77 @@ +'use client' + +import { cn } from '@/lib/utils' +import type { CalendarInfo } from '@/lib/caldav-client' + +interface CalendarSidebarProps { + calendars: CalendarInfo[] + visibleCalendars: Set + onToggleCalendar: (url: string) => void + calendarColors: Record +} + +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 ( +
+

+ Calendars +

+
+ {calendars.map((cal, idx) => { + const color = calendarColors[cal.url] || cal.color || DEFAULT_COLORS[idx % DEFAULT_COLORS.length] + const isVisible = visibleCalendars.has(cal.url) + + return ( + + ) + })} +
+
+ ) +} diff --git a/src/components/calendar/calendar-view.tsx b/src/components/calendar/calendar-view.tsx new file mode 100644 index 0000000..b107322 --- /dev/null +++ b/src/components/calendar/calendar-view.tsx @@ -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 + calendarColors: Record +} + +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> = {} + 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) { + 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) { + 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 ( +
+ +
+ ) +} diff --git a/src/components/calendar/event-card.tsx b/src/components/calendar/event-card.tsx new file mode 100644 index 0000000..b0b0208 --- /dev/null +++ b/src/components/calendar/event-card.tsx @@ -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 ( + + ) + } + + return ( + + ) +} diff --git a/src/components/calendar/event-dialog.tsx b/src/components/calendar/event-dialog.tsx new file mode 100644 index 0000000..79a1745 --- /dev/null +++ b/src/components/calendar/event-dialog.tsx @@ -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 ( + !isOpen && onClose()}> + + + + {isEditing ? 'Edit Event' : 'New Event'} + + + {isEditing + ? 'Update the event details below.' + : 'Fill in the details to create a new event.'} + + + +
+
+ + setTitle(e.target.value)} + placeholder="Event title" + required + autoFocus + /> +
+ +
+ { + 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" + /> + +
+ +
+
+ + setStartDate(e.target.value)} + required + /> +
+
+ + setEndDate(e.target.value)} + required + /> +
+
+ +
+ + setLocation(e.target.value)} + placeholder="Event location" + /> +
+ +
+ +