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:
commit
615a36eb20
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = config
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
import { handlers } from '@/auth'
|
||||||
|
export const { GET, POST } = handlers
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
export default function CalendarLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
@ -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'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()
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 "{searchQuery}"
|
||||||
|
{!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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 "v=spf1 a mx ip4:YOUR_SERVER_IP ~all"</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 "Open" to go to each tool'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 || ' '}</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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]
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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 || {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
@ -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}`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { type ClassValue, clsx } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
@ -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 }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
export { auth as middleware } from '@/auth'
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
'/((?!api/auth|login|_next/static|_next/image|favicon.ico).*)',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
@ -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' }[]
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue