commit a508ece6e78f3ae06be84b8680f7bec697cfa3e0 Author: Randall Stillwell Date: Thu Mar 26 22:39:16 2026 -0500 feat: Full project management application scaffold Complete architecture for a ClickUp/Notion/Miro-class project management app: - Turborepo monorepo with Next.js 15, TypeScript, PostgreSQL (Drizzle ORM) - Object-centered database schema (everything is an Object: tasks, projects, docs, whiteboards) - NextAuth v5 authentication with credentials + OAuth providers - tRPC v11 API layer with full CRUD for objects, properties, relations, templates, search - Three-panel UI: collapsible sidebar, center content area, push-in right panel - Purple/teal theme with light/dark mode via Shadcn/ui + Tailwind CSS - Multiple views: List, Kanban board (dnd-kit), Table (spreadsheet), Embedded iframe - TipTap rich text editor with slash commands, custom blocks (callout, toggle, mention, embed, divider), AI block - Real-time collaboration via Yjs + Hocuspocus with presence/cursors - tldraw whiteboard with custom shape cards (task, document, project) - MCP server exposing all app data/tools for AI agents - AI chat panel, editor AI slash commands, Cmd+K command palette - Template system with built-in templates (Bug Report, Meeting Notes, Sprint) - Full-text search with result highlighting - Docker Compose for full-stack deployment (web + collab + postgres + redis) Made-with: Cursor diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..888fe92 --- /dev/null +++ b/.env.example @@ -0,0 +1,40 @@ +# Database +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/tasks" + +# Auth (NextAuth) +NEXTAUTH_URL="http://localhost:3000" +NEXTAUTH_SECRET="your-secret-here-generate-with-openssl-rand-base64-32" + +# OAuth Providers (optional) +GITHUB_CLIENT_ID="" +GITHUB_CLIENT_SECRET="" +GOOGLE_CLIENT_ID="" +GOOGLE_CLIENT_SECRET="" + +# Redis +REDIS_URL="redis://localhost:6379" + +# Collaboration Server (browser / clients) +COLLAB_SERVER_URL="ws://localhost:1234" + +# AI Providers +OPENAI_API_KEY="" +ANTHROPIC_API_KEY="" + +# MCP Server (stdio transport today; port reserved for future HTTP/SSE) +MCP_SERVER_PORT=3001 + +# --- Docker Compose (optional; copy to .env and adjust) --- +# When all app services run in Docker, use service hostnames: +# DATABASE_URL="postgresql://postgres:postgres@postgres:5432/tasks" +# REDIS_URL="redis://redis:6379" +# COLLAB_SERVER_URL="ws://localhost:1234" +# (Browsers still reach the collab service via the published host port.) + +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DB=tasks +POSTGRES_PORT=5432 +REDIS_PORT=6379 +WEB_PORT=3000 +COLLAB_PORT=1234 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ac5fd4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# dependencies +node_modules +.pnpm-store + +# next.js +.next/ +out/ + +# build +dist/ +*.tsbuildinfo + +# env +.env +.env.local +.env.*.local + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store + +# turbo +.turbo + +# docker +docker/**/data/ + +# misc +*.pem +coverage/ diff --git a/apps/collab-server/package.json b/apps/collab-server/package.json new file mode 100644 index 0000000..48eb050 --- /dev/null +++ b/apps/collab-server/package.json @@ -0,0 +1,25 @@ +{ + "name": "@tasks/collab-server", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsup src/index.ts --format esm --dts", + "start": "node dist/index.js", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@hocuspocus/server": "^2.15.0", + "@hocuspocus/extension-database": "^2.15.0", + "@hocuspocus/extension-redis": "^2.15.0", + "@tasks/database": "workspace:*", + "ioredis": "^5.4.2", + "yjs": "^13.6.22", + "zod": "^3.24.0" + }, + "devDependencies": { + "tsup": "^8.3.5", + "tsx": "^4.19.2", + "typescript": "^5.7.0" + } +} diff --git a/apps/collab-server/src/index.ts b/apps/collab-server/src/index.ts new file mode 100644 index 0000000..5fa9f72 --- /dev/null +++ b/apps/collab-server/src/index.ts @@ -0,0 +1,249 @@ +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createHash } from "node:crypto"; +import { Database } from "@hocuspocus/extension-database"; +import { Redis as HocuspocusRedis } from "@hocuspocus/extension-redis"; +import { Hocuspocus, type Extension, type onAuthenticatePayload } from "@hocuspocus/server"; +import { db } from "@tasks/database/client"; +import { objects } from "@tasks/database/schema"; +import IoRedis from "ioredis"; +import { z } from "zod"; +import { + base64ToYUpdate, + extractObjectId, + yDocToBase64, +} from "./utils.js"; + +/** + * Resolve transitive deps (drizzle-orm, @hocuspocus/common) without listing them in this package. + * Uses workspace package roots so `createRequire` works under `tsx` (no `import.meta.resolve`). + */ +const __dirname = dirname(fileURLToPath(import.meta.url)); +const requireFromDb = createRequire( + join(__dirname, "../../../packages/database/package.json"), +); +const requireFromServer = createRequire( + join(__dirname, "../node_modules/@hocuspocus/server/package.json"), +); + +// Resolved at runtime via @tasks/database's dependency graph (see `requireFromDb` above). +const { eq } = requireFromDb("drizzle-orm") as { + eq: (...args: unknown[]) => any; +}; +const { Forbidden } = requireFromServer("@hocuspocus/common") as { + Forbidden: { code: number; reason: string }; +}; + +const contentSchema = z + .object({ + yjs: z.string().optional(), + }) + .passthrough(); + +function parseYjsBase64FromContent(content: unknown): string | null { + if (content == null) { + return null; + } + if (typeof content === "string" && content.length > 0) { + return content; + } + const parsed = contentSchema.safeParse(content); + if (!parsed.success || !parsed.data.yjs) { + return null; + } + return parsed.data.yjs; +} + +async function tryCreateRedisExtension(): Promise { + const url = process.env.REDIS_URL?.trim(); + if (!url) { + return null; + } + + const client = new IoRedis(url, { + maxRetriesPerRequest: null, + enableReadyCheck: true, + lazyConnect: false, + }); + + try { + await client.ping(); + } catch (error) { + console.warn( + "[collab] Redis unavailable, continuing without Redis extension:", + error, + ); + try { + client.disconnect(); + } catch { + /* ignore */ + } + return null; + } + + return new HocuspocusRedis({ + redis: client, + identifier: `collab-${process.pid}-${Date.now()}`, + }); +} + +function buildDatabaseExtension() { + return new Database({ + fetch: async ({ documentName, document }) => { + const objectId = extractObjectId(documentName); + if (!objectId) { + console.error( + `[collab] fetch: invalid document name (expected object:{id}): ${documentName}`, + ); + return null; + } + + try { + const rows = await db + .select({ content: objects.content }) + .from(objects) + .where(eq(objects.id, objectId)) + .limit(1); + + const row = rows[0]; + if (!row) { + return null; + } + + const base64 = parseYjsBase64FromContent(row.content); + if (!base64) { + return null; + } + + try { + return base64ToYUpdate(base64); + } catch (e) { + console.error( + `[collab] fetch: invalid Yjs base64 for object ${objectId}:`, + e, + ); + return null; + } + } catch (error) { + console.error( + `[collab] fetch: database error for object ${objectId}:`, + error, + ); + return null; + } + }, + + store: async ({ documentName, document }) => { + const objectId = extractObjectId(documentName); + if (!objectId) { + console.error( + `[collab] store: invalid document name (expected object:{id}): ${documentName}`, + ); + return; + } + + const base64 = yDocToBase64(document); + + try { + const existing = await db + .select({ content: objects.content }) + .from(objects) + .where(eq(objects.id, objectId)) + .limit(1); + + const prev = existing[0]?.content; + const merged = + prev !== null && + prev !== undefined && + typeof prev === "object" && + !Array.isArray(prev) + ? { ...(prev as Record), yjs: base64 } + : { yjs: base64 }; + + const updated = await db + .update(objects) + .set({ + content: merged, + updatedAt: new Date(), + }) + .where(eq(objects.id, objectId)) + .returning({ id: objects.id }); + + if (updated.length === 0) { + console.warn( + `[collab] store: object ${objectId} not found, skipping persist`, + ); + } + } catch (error) { + console.error( + `[collab] store: database error for object ${objectId}:`, + error, + ); + } + }, + }); +} + +async function main() { + const port = Number(process.env.PORT) || 1234; + + const extensions: Extension[] = [buildDatabaseExtension()]; + const redis = await tryCreateRedisExtension(); + if (redis) { + extensions.push(redis); + } + + const hocuspocus = new Hocuspocus({ + quiet: true, + stopOnSignals: false, + address: "0.0.0.0", + port, + extensions, + + async onAuthenticate({ token }: onAuthenticatePayload) { + if (!token?.trim()) { + throw Forbidden; + } + + const id = createHash("sha256").update(token).digest("hex"); + return { + user: { id, name: `User ${id.slice(0, 8)}` }, + }; + }, + + async onConnect({ documentName }) { + console.log(`[collab] connect document=${documentName}`); + }, + + async onDisconnect({ documentName }) { + console.log(`[collab] disconnect document=${documentName}`); + }, + + async afterUnloadDocument({ documentName }) { + console.log(`[collab] afterUnloadDocument document=${documentName}`); + }, + }); + + const shutdown = async (signal: NodeJS.Signals) => { + console.log(`[collab] received ${signal}, shutting down...`); + try { + await hocuspocus.destroy(); + } catch (error) { + console.error("[collab] error during shutdown:", error); + } + process.exit(0); + }; + + process.once("SIGINT", () => void shutdown("SIGINT")); + process.once("SIGTERM", () => void shutdown("SIGTERM")); + + await hocuspocus.listen(); + + console.log(`Collaboration server running on port ${port}`); +} + +main().catch((error) => { + console.error("[collab] fatal error:", error); + process.exit(1); +}); diff --git a/apps/collab-server/src/utils.ts b/apps/collab-server/src/utils.ts new file mode 100644 index 0000000..071a0ab --- /dev/null +++ b/apps/collab-server/src/utils.ts @@ -0,0 +1,28 @@ +import * as Y from "yjs"; + +/** + * Encode full Yjs document state as a base64 string for JSONB storage. + */ +export function yDocToBase64(doc: Y.Doc): string { + return Buffer.from(Y.encodeStateAsUpdate(doc)).toString("base64"); +} + +/** + * Decode a base64 string to a Yjs update payload. + */ +export function base64ToYUpdate(base64: string): Uint8Array { + return new Uint8Array(Buffer.from(base64, "base64")); +} + +const OBJECT_PREFIX = "object:"; + +/** + * Parse collaboration document names of the form `object:{uuid}`. + */ +export function extractObjectId(documentName: string): string | null { + if (!documentName.startsWith(OBJECT_PREFIX)) { + return null; + } + const id = documentName.slice(OBJECT_PREFIX.length).trim(); + return id.length > 0 ? id : null; +} diff --git a/apps/collab-server/tsconfig.json b/apps/collab-server/tsconfig.json new file mode 100644 index 0000000..8f1da89 --- /dev/null +++ b/apps/collab-server/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "incremental": false + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/mcp-server/package.json b/apps/mcp-server/package.json new file mode 100644 index 0000000..a716c2d --- /dev/null +++ b/apps/mcp-server/package.json @@ -0,0 +1,23 @@ +{ + "name": "@tasks/mcp-server", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsup src/index.ts --format esm --dts", + "start": "node dist/index.js", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.4.0", + "@tasks/database": "workspace:*", + "@tasks/shared": "workspace:*", + "zod": "^3.24.0" + }, + "devDependencies": { + "tsup": "^8.3.5", + "tsx": "^4.19.2", + "typescript": "^5.7.0" + } +} diff --git a/apps/mcp-server/src/db.ts b/apps/mcp-server/src/db.ts new file mode 100644 index 0000000..758ae8c --- /dev/null +++ b/apps/mcp-server/src/db.ts @@ -0,0 +1,2 @@ +/** Re-export so tsup bundles DB client instead of leaving a runtime import of `@tasks/database` (`.ts` entry). */ +export { db } from "../../../packages/database/src/client.ts"; diff --git a/apps/mcp-server/src/drizzle.ts b/apps/mcp-server/src/drizzle.ts new file mode 100644 index 0000000..2106053 --- /dev/null +++ b/apps/mcp-server/src/drizzle.ts @@ -0,0 +1,22 @@ +/** + * Resolves drizzle-orm from @tasks/database's dependency tree so the MCP app + * does not need drizzle-orm as a direct dependency (bundler + runtime). + */ +// @ts-nocheck — Node built-ins; package lacks @types/node in this package's tsconfig +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const databaseDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "../../../packages/database"); +const drizzlePath = require.resolve("drizzle-orm", { paths: [databaseDir] }); +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any +const d: any = require(drizzlePath); + +export const and = d.and; +export const asc = d.asc; +export const eq = d.eq; +export const ilike = d.ilike; +export const inArray = d.inArray; +export const isNull = d.isNull; +export const or = d.or; diff --git a/apps/mcp-server/src/index.ts b/apps/mcp-server/src/index.ts new file mode 100644 index 0000000..8d5b0f6 --- /dev/null +++ b/apps/mcp-server/src/index.ts @@ -0,0 +1,24 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { registerResources } from "./resources/index.js"; +import { registerTools } from "./tools/index.js"; + +const mcp = new McpServer( + { + name: "tasks-mcp-server", + version: "0.1.0", + }, + { + instructions: + "Tools and resources for the Tasks project management app: objects, workspaces, views, and templates.", + }, +); + +registerTools(mcp); +registerResources(mcp); + +const transport = new StdioServerTransport(); + +await mcp.connect(transport); + +console.error("[tasks-mcp-server] MCP server listening on stdio"); diff --git a/apps/mcp-server/src/resources/index.ts b/apps/mcp-server/src/resources/index.ts new file mode 100644 index 0000000..74f8198 --- /dev/null +++ b/apps/mcp-server/src/resources/index.ts @@ -0,0 +1,14 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerObjectResource } from "./object-resource.js"; +import { registerViewResource } from "./view-resource.js"; +import { registerWorkspaceTreeResource } from "./workspace-tree-resource.js"; + +export function registerResources(mcp: McpServer): void { + registerObjectResource(mcp); + registerWorkspaceTreeResource(mcp); + registerViewResource(mcp); +} + +export { registerObjectResource } from "./object-resource.js"; +export { registerWorkspaceTreeResource } from "./workspace-tree-resource.js"; +export { registerViewResource } from "./view-resource.js"; diff --git a/apps/mcp-server/src/resources/object-resource.ts b/apps/mcp-server/src/resources/object-resource.ts new file mode 100644 index 0000000..6d08a10 --- /dev/null +++ b/apps/mcp-server/src/resources/object-resource.ts @@ -0,0 +1,70 @@ +import { ResourceTemplate, type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { asc, eq } from "../drizzle.js"; +import { db } from "../db.js"; +import { objects } from "../schema.js"; + +export function registerObjectResource(mcp: McpServer): void { + mcp.registerResource( + "object", + new ResourceTemplate("object://{id}", { list: undefined }), + { + description: "Full object details with properties, assignees, and children.", + mimeType: "application/json", + }, + async (uri, variables) => { + const id = variables.id; + if (!id) { + return { + contents: [ + { + uri: uri.toString(), + mimeType: "application/json", + text: JSON.stringify({ error: "Missing id" }), + }, + ], + }; + } + + const obj = await db.query.objects.findFirst({ + where: eq(objects.id, id), + with: { + children: { + orderBy: [asc(objects.sortOrder), asc(objects.id)], + }, + assignees: { + with: { + user: true, + }, + }, + propertyValues: { + with: { + propertyDefinition: true, + }, + }, + }, + }); + + if (!obj) { + return { + contents: [ + { + uri: uri.toString(), + mimeType: "application/json", + text: JSON.stringify({ error: "Object not found", id }), + }, + ], + }; + } + + return { + contents: [ + { + uri: uri.toString(), + mimeType: "application/json", + text: JSON.stringify(obj, null, 2), + }, + ], + }; + }, + ); +} diff --git a/apps/mcp-server/src/resources/view-resource.ts b/apps/mcp-server/src/resources/view-resource.ts new file mode 100644 index 0000000..76642a1 --- /dev/null +++ b/apps/mcp-server/src/resources/view-resource.ts @@ -0,0 +1,82 @@ +import { ResourceTemplate, type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { eq } from "../drizzle.js"; +import { db } from "../db.js"; +import { views } from "../schema.js"; + +export function registerViewResource(mcp: McpServer): void { + mcp.registerResource( + "view", + new ResourceTemplate("view://{id}", { list: undefined }), + { + description: "View configuration, metadata, and parent object summary.", + mimeType: "application/json", + }, + async (uri, variables) => { + const id = variables.id; + if (!id) { + return { + contents: [ + { + uri: uri.toString(), + mimeType: "application/json", + text: JSON.stringify({ error: "Missing view id" }), + }, + ], + }; + } + + const view = await db.query.views.findFirst({ + where: eq(views.id, id), + with: { + object: true, + }, + }); + + if (!view) { + return { + contents: [ + { + uri: uri.toString(), + mimeType: "application/json", + text: JSON.stringify({ error: "View not found", id }), + }, + ], + }; + } + + const parent = view.object; + const objectSummary = + parent && !Array.isArray(parent) + ? { + id: parent.id, + title: parent.title, + type: parent.type, + } + : null; + + const payload = { + view: { + id: view.id, + objectId: view.objectId, + viewType: view.viewType, + name: view.name, + config: view.config, + sortOrder: view.sortOrder, + createdAt: view.createdAt, + updatedAt: view.updatedAt, + }, + object: objectSummary, + }; + + return { + contents: [ + { + uri: uri.toString(), + mimeType: "application/json", + text: JSON.stringify(payload, null, 2), + }, + ], + }; + }, + ); +} diff --git a/apps/mcp-server/src/resources/workspace-tree-resource.ts b/apps/mcp-server/src/resources/workspace-tree-resource.ts new file mode 100644 index 0000000..b9600d9 --- /dev/null +++ b/apps/mcp-server/src/resources/workspace-tree-resource.ts @@ -0,0 +1,106 @@ +import { ResourceTemplate, type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { and, asc, eq, inArray, isNull } from "../drizzle.js"; +import { db } from "../db.js"; +import { objects } from "../schema.js"; + +const TREE_TYPES = ["project", "group", "document", "whiteboard"] as const; + +type TreeNode = { + id: string; + title: string; + type: string; + icon: string | null; + parentId: string | null; + childCount: number; + children: TreeNode[]; +}; + +export function registerWorkspaceTreeResource(mcp: McpServer): void { + mcp.registerResource( + "workspace_tree", + new ResourceTemplate("workspace://{id}/tree", { list: undefined }), + { + description: "Hierarchy tree of projects, groups, documents, and whiteboards in a workspace.", + mimeType: "application/json", + }, + async (uri, variables) => { + const workspaceId = variables.id; + if (!workspaceId) { + return { + contents: [ + { + uri: uri.toString(), + mimeType: "application/json", + text: JSON.stringify({ error: "Missing workspace id" }), + }, + ], + }; + } + + const maxDepth = 50; + + const rows = await db + .select() + .from(objects) + .where( + and( + eq(objects.workspaceId, workspaceId), + inArray(objects.type, [...TREE_TYPES]), + isNull(objects.archivedAt), + ), + ) + .orderBy(asc(objects.sortOrder), asc(objects.id)); + + const ids = new Set(rows.map((r) => r.id)); + + const childCountMap = new Map(); + for (const row of rows) { + if (row.parentId) { + childCountMap.set(row.parentId, (childCountMap.get(row.parentId) ?? 0) + 1); + } + } + + function buildTree(parentId: string | null, depth: number): TreeNode[] { + if (depth > maxDepth) { + return []; + } + + const directChildren = rows.filter((r) => r.parentId === parentId); + + return directChildren.map((r) => ({ + id: r.id, + title: r.title, + type: r.type, + icon: r.icon, + parentId: r.parentId, + childCount: childCountMap.get(r.id) ?? 0, + children: buildTree(r.id, depth + 1), + })); + } + + const roots = rows.filter((r) => r.parentId === null || !ids.has(r.parentId)); + + const tree: TreeNode[] = roots.map((r) => ({ + id: r.id, + title: r.title, + type: r.type, + icon: r.icon, + parentId: r.parentId, + childCount: childCountMap.get(r.id) ?? 0, + children: buildTree(r.id, 1), + })); + + const payload = { workspaceId, tree }; + + return { + contents: [ + { + uri: uri.toString(), + mimeType: "application/json", + text: JSON.stringify(payload, null, 2), + }, + ], + }; + }, + ); +} diff --git a/apps/mcp-server/src/schema.ts b/apps/mcp-server/src/schema.ts new file mode 100644 index 0000000..a7ab4da --- /dev/null +++ b/apps/mcp-server/src/schema.ts @@ -0,0 +1,2 @@ +/** Re-export so tsup bundles schema with the MCP server. */ +export * from "../../../packages/database/src/schema/index.ts"; diff --git a/apps/mcp-server/src/shared-types.ts b/apps/mcp-server/src/shared-types.ts new file mode 100644 index 0000000..5c71fae --- /dev/null +++ b/apps/mcp-server/src/shared-types.ts @@ -0,0 +1 @@ +export { objectTypes, viewTypes } from "../../../packages/shared/src/types/index.ts"; diff --git a/apps/mcp-server/src/tools/create-object.ts b/apps/mcp-server/src/tools/create-object.ts new file mode 100644 index 0000000..e856b18 --- /dev/null +++ b/apps/mcp-server/src/tools/create-object.ts @@ -0,0 +1,53 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { db } from "../db.js"; +import { objects } from "../schema.js"; +import { objectTypes } from "../shared-types.js"; +import { toolCatch, toolErr, toolOk } from "./tool-result.js"; + +const objectTypeSchema = z.enum(objectTypes as unknown as [string, ...string[]]); + +const createObjectInputSchema = z.object({ + type: objectTypeSchema, + title: z.string().min(1).max(500), + parentId: z.string().uuid().nullable().optional(), + workspaceId: z.string().uuid(), + description: z.string().optional(), + status: z.string().optional(), + icon: z.string().optional(), +}); + +export function registerCreateObjectTool(mcp: McpServer): void { + mcp.registerTool( + "create_object", + { + description: + "Create a new object (task, project, document, whiteboard, group, workspace).", + inputSchema: createObjectInputSchema, + }, + async (args) => { + try { + const input = createObjectInputSchema.parse(args); + const [created] = await db + .insert(objects) + .values({ + type: input.type, + title: input.title, + parentId: input.parentId ?? null, + workspaceId: input.workspaceId, + description: input.description, + status: input.status, + icon: input.icon, + }) + .returning(); + + if (!created) { + return toolErr("Failed to create object"); + } + return toolOk(created); + } catch (e) { + return toolCatch(e); + } + }, + ); +} diff --git a/apps/mcp-server/src/tools/index.ts b/apps/mcp-server/src/tools/index.ts new file mode 100644 index 0000000..e7c8860 --- /dev/null +++ b/apps/mcp-server/src/tools/index.ts @@ -0,0 +1,20 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerCreateObjectTool } from "./create-object.js"; +import { registerListObjectsTool } from "./list-objects.js"; +import { registerManageObjectTool } from "./manage-object.js"; +import { registerSearchObjectsTool } from "./search-objects.js"; +import { registerUpdateObjectTool } from "./update-object.js"; + +export function registerTools(mcp: McpServer): void { + registerCreateObjectTool(mcp); + registerUpdateObjectTool(mcp); + registerSearchObjectsTool(mcp); + registerListObjectsTool(mcp); + registerManageObjectTool(mcp); +} + +export { registerCreateObjectTool } from "./create-object.js"; +export { registerUpdateObjectTool } from "./update-object.js"; +export { registerSearchObjectsTool } from "./search-objects.js"; +export { registerListObjectsTool } from "./list-objects.js"; +export { registerManageObjectTool } from "./manage-object.js"; diff --git a/apps/mcp-server/src/tools/list-objects.ts b/apps/mcp-server/src/tools/list-objects.ts new file mode 100644 index 0000000..19ca934 --- /dev/null +++ b/apps/mcp-server/src/tools/list-objects.ts @@ -0,0 +1,68 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { and, asc, eq, isNull } from "../drizzle.js"; +import { z } from "zod"; +import { db } from "../db.js"; +import { objects } from "../schema.js"; +import { objectTypes } from "../shared-types.js"; +import { toolCatch, toolOk } from "./tool-result.js"; + +const objectTypeSchema = z.enum(objectTypes as unknown as [string, ...string[]]); + +const listObjectsInputSchema = z.object({ + workspaceId: z.string().uuid(), + parentId: z.string().uuid().nullable().optional(), + type: objectTypeSchema.optional(), + status: z.string().optional(), + limit: z.number().int().positive().max(500).optional(), + offset: z.number().int().nonnegative().optional(), +}); + +export function registerListObjectsTool(mcp: McpServer): void { + mcp.registerTool( + "list_objects", + { + description: + "List objects in a workspace with optional filters (parent, type, status) and pagination.", + inputSchema: listObjectsInputSchema, + }, + async (args) => { + try { + const input = listObjectsInputSchema.parse(args); + const limit = input.limit ?? 50; + const offset = input.offset ?? 0; + + const conditions = [eq(objects.workspaceId, input.workspaceId), isNull(objects.archivedAt)]; + + if (input.parentId === null) { + conditions.push(isNull(objects.parentId)); + } else if (input.parentId !== undefined) { + conditions.push(eq(objects.parentId, input.parentId)); + } + + if (input.type !== undefined) { + conditions.push(eq(objects.type, input.type)); + } + if (input.status !== undefined) { + conditions.push(eq(objects.status, input.status)); + } + + const rows = await db + .select() + .from(objects) + .where(and(...conditions)) + .orderBy(asc(objects.sortOrder), asc(objects.id)) + .limit(limit) + .offset(offset); + + return toolOk({ + objects: rows, + count: rows.length, + limit, + offset, + }); + } catch (e) { + return toolCatch(e); + } + }, + ); +} diff --git a/apps/mcp-server/src/tools/manage-object.ts b/apps/mcp-server/src/tools/manage-object.ts new file mode 100644 index 0000000..e884a75 --- /dev/null +++ b/apps/mcp-server/src/tools/manage-object.ts @@ -0,0 +1,125 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { and, eq } from "../drizzle.js"; +import { z } from "zod"; +import { db } from "../db.js"; +import { objectAssignees, objects, views } from "../schema.js"; +import { viewTypes } from "../shared-types.js"; +import { toolCatch, toolErr, toolOk } from "./tool-result.js"; + +const viewTypeSchema = z.enum(viewTypes as unknown as [string, ...string[]]); + +const manageObjectInputSchema = z.discriminatedUnion("operation", [ + z.object({ + operation: z.literal("move_object"), + id: z.string().uuid(), + newParentId: z.string().uuid().nullable(), + }), + z.object({ + operation: z.literal("assign_object"), + objectId: z.string().uuid(), + userId: z.string().uuid(), + role: z.string().optional(), + action: z.enum(["add", "remove"]), + }), + z.object({ + operation: z.literal("create_view"), + objectId: z.string().uuid(), + viewType: viewTypeSchema, + name: z.string().min(1).max(255), + config: z.record(z.unknown()).optional(), + }), + z.object({ + operation: z.literal("apply_template"), + objectId: z.string().uuid(), + templateId: z.string().uuid(), + }), +]); + +export function registerManageObjectTool(mcp: McpServer): void { + mcp.registerTool( + "manage_object", + { + description: + "Manage objects: move_object (change parent), assign_object (add/remove assignee), create_view, or apply_template (set template on object).", + inputSchema: manageObjectInputSchema, + }, + async (args) => { + try { + const input = manageObjectInputSchema.parse(args); + + if (input.operation === "move_object") { + const [updated] = await db + .update(objects) + .set({ parentId: input.newParentId, updatedAt: new Date() }) + .where(eq(objects.id, input.id)) + .returning(); + if (!updated) { + return toolErr(`Object not found: ${input.id}`); + } + return toolOk({ operation: input.operation, object: updated }); + } + + if (input.operation === "assign_object") { + if (input.action === "add") { + const role = input.role ?? "assignee"; + await db + .insert(objectAssignees) + .values({ + objectId: input.objectId, + userId: input.userId, + role, + }) + .onConflictDoUpdate({ + target: [objectAssignees.objectId, objectAssignees.userId], + set: { role }, + }); + return toolOk({ operation: input.operation, action: input.action, ok: true }); + } + + const deleted = await db + .delete(objectAssignees) + .where( + and( + eq(objectAssignees.objectId, input.objectId), + eq(objectAssignees.userId, input.userId), + ), + ) + .returning(); + return toolOk({ + operation: input.operation, + action: input.action, + removed: deleted[0] ?? null, + }); + } + + if (input.operation === "create_view") { + const [created] = await db + .insert(views) + .values({ + objectId: input.objectId, + viewType: input.viewType, + name: input.name, + config: input.config ?? null, + }) + .returning(); + if (!created) { + return toolErr("Failed to create view"); + } + return toolOk({ operation: input.operation, view: created }); + } + + const [updated] = await db + .update(objects) + .set({ templateId: input.templateId, updatedAt: new Date() }) + .where(eq(objects.id, input.objectId)) + .returning(); + if (!updated) { + return toolErr(`Object not found: ${input.objectId}`); + } + return toolOk({ operation: input.operation, object: updated }); + } catch (e) { + return toolCatch(e); + } + }, + ); +} diff --git a/apps/mcp-server/src/tools/search-objects.ts b/apps/mcp-server/src/tools/search-objects.ts new file mode 100644 index 0000000..4afaba8 --- /dev/null +++ b/apps/mcp-server/src/tools/search-objects.ts @@ -0,0 +1,62 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { and, asc, eq, ilike, isNull, or } from "../drizzle.js"; +import { z } from "zod"; +import { db } from "../db.js"; +import { objects } from "../schema.js"; +import { toolCatch, toolOk } from "./tool-result.js"; + +function escapeLikePattern(q: string): string { + return q.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_"); +} + +const searchObjectsInputSchema = z.object({ + query: z.string().min(1), + workspaceId: z.string().uuid().optional(), + type: z.string().optional(), + status: z.string().optional(), + limit: z.number().int().positive().max(500).optional(), +}); + +export function registerSearchObjectsTool(mcp: McpServer): void { + mcp.registerTool( + "search_objects", + { + description: + "Search objects by text query (case-insensitive match on title and description) with optional filters.", + inputSchema: searchObjectsInputSchema, + }, + async (args) => { + try { + const input = searchObjectsInputSchema.parse(args); + const limit = input.limit ?? 50; + const pattern = `%${escapeLikePattern(input.query)}%`; + + const conditions = [ + isNull(objects.archivedAt), + or(ilike(objects.title, pattern), ilike(objects.description, pattern)), + ]; + + if (input.workspaceId) { + conditions.push(eq(objects.workspaceId, input.workspaceId)); + } + if (input.type !== undefined) { + conditions.push(eq(objects.type, input.type)); + } + if (input.status !== undefined) { + conditions.push(eq(objects.status, input.status)); + } + + const rows = await db + .select() + .from(objects) + .where(and(...conditions)) + .orderBy(asc(objects.sortOrder), asc(objects.id)) + .limit(limit); + + return toolOk({ objects: rows, count: rows.length }); + } catch (e) { + return toolCatch(e); + } + }, + ); +} diff --git a/apps/mcp-server/src/tools/tool-result.ts b/apps/mcp-server/src/tools/tool-result.ts new file mode 100644 index 0000000..a3e3bc8 --- /dev/null +++ b/apps/mcp-server/src/tools/tool-result.ts @@ -0,0 +1,29 @@ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export function toolOk(data: unknown): CallToolResult { + return { + content: [ + { + type: "text", + text: JSON.stringify({ ok: true, data }, null, 2), + }, + ], + }; +} + +export function toolErr(message: string): CallToolResult { + return { + content: [ + { + type: "text", + text: JSON.stringify({ ok: false, error: message }, null, 2), + }, + ], + isError: true, + }; +} + +export function toolCatch(err: unknown): CallToolResult { + const message = err instanceof Error ? err.message : String(err); + return toolErr(message); +} diff --git a/apps/mcp-server/src/tools/update-object.ts b/apps/mcp-server/src/tools/update-object.ts new file mode 100644 index 0000000..cf9ab23 --- /dev/null +++ b/apps/mcp-server/src/tools/update-object.ts @@ -0,0 +1,49 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { eq } from "../drizzle.js"; +import { z } from "zod"; +import { db } from "../db.js"; +import { objects } from "../schema.js"; +import { toolCatch, toolErr, toolOk } from "./tool-result.js"; + +const updateObjectInputSchema = z.object({ + id: z.string().uuid(), + title: z.string().min(1).max(500).optional(), + description: z.string().nullable().optional(), + status: z.string().nullable().optional(), + icon: z.string().nullable().optional(), + coverImage: z.string().nullable().optional(), +}); + +export function registerUpdateObjectTool(mcp: McpServer): void { + mcp.registerTool( + "update_object", + { + description: "Update an existing object (only provided fields are changed).", + inputSchema: updateObjectInputSchema, + }, + async (args) => { + try { + const input = updateObjectInputSchema.parse(args); + const [updated] = await db + .update(objects) + .set({ + ...(input.title !== undefined ? { title: input.title } : {}), + ...(input.description !== undefined ? { description: input.description } : {}), + ...(input.status !== undefined ? { status: input.status } : {}), + ...(input.icon !== undefined ? { icon: input.icon } : {}), + ...(input.coverImage !== undefined ? { coverImage: input.coverImage } : {}), + updatedAt: new Date(), + }) + .where(eq(objects.id, input.id)) + .returning(); + + if (!updated) { + return toolErr(`Object not found: ${input.id}`); + } + return toolOk(updated); + } catch (e) { + return toolCatch(e); + } + }, + ); +} diff --git a/apps/mcp-server/tsconfig.json b/apps/mcp-server/tsconfig.json new file mode 100644 index 0000000..8f1da89 --- /dev/null +++ b/apps/mcp-server/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "incremental": false + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/web/app/(app)/[workspaceSlug]/[projectId]/layout.tsx b/apps/web/app/(app)/[workspaceSlug]/[projectId]/layout.tsx new file mode 100644 index 0000000..c5e50b6 --- /dev/null +++ b/apps/web/app/(app)/[workspaceSlug]/[projectId]/layout.tsx @@ -0,0 +1,20 @@ +"use client"; + +import type { ReactNode } from "react"; +import { ViewToolbar } from "@/components/views/config"; +import { useViewStore } from "@/lib/stores/view-store"; +import { useViewData } from "@/lib/hooks/use-view-data"; + +export default function ProjectLayout({ children }: { children: ReactNode }) { + const config = useViewStore((s) => s.config); + const { total } = useViewData(config); + + return ( +
+
+ +
+
{children}
+
+ ); +} diff --git a/apps/web/app/(app)/[workspaceSlug]/[projectId]/page.tsx b/apps/web/app/(app)/[workspaceSlug]/[projectId]/page.tsx new file mode 100644 index 0000000..d0490a7 --- /dev/null +++ b/apps/web/app/(app)/[workspaceSlug]/[projectId]/page.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { useViewStore } from "@/lib/stores/view-store"; +import { ListView } from "@/components/views/list"; +import { BoardView } from "@/components/views/board"; +import { TableView } from "@/components/views/table"; +import { EmbedView } from "@/components/views/embed"; + +export default function ProjectPage() { + const activeView = useViewStore((s) => s.activeView); + const config = useViewStore((s) => s.config); + + switch (activeView) { + case "board": + return ; + case "table": + return ; + case "embed": + return ; + case "list": + default: + return ; + } +} diff --git a/apps/web/app/(app)/[workspaceSlug]/docs/page.tsx b/apps/web/app/(app)/[workspaceSlug]/docs/page.tsx new file mode 100644 index 0000000..284b821 --- /dev/null +++ b/apps/web/app/(app)/[workspaceSlug]/docs/page.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { BlockEditor } from "@/components/editor"; + +export default function DocsPage() { + return ( +
+

Documents

+
+ { + // Will persist via tRPC in later phase + }} + /> +
+
+ ); +} diff --git a/apps/web/app/(app)/[workspaceSlug]/layout.tsx b/apps/web/app/(app)/[workspaceSlug]/layout.tsx new file mode 100644 index 0000000..0475b4f --- /dev/null +++ b/apps/web/app/(app)/[workspaceSlug]/layout.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from "react"; + +import { WorkspaceSync } from "@/components/layout/workspace-sync"; + +export default async function WorkspaceLayout({ + children, + params, +}: { + children: ReactNode; + params: Promise<{ workspaceSlug: string }>; +}) { + const { workspaceSlug } = await params; + + return ( + {children} + ); +} diff --git a/apps/web/app/(app)/[workspaceSlug]/page.tsx b/apps/web/app/(app)/[workspaceSlug]/page.tsx new file mode 100644 index 0000000..74e2839 --- /dev/null +++ b/apps/web/app/(app)/[workspaceSlug]/page.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { + ArrowRight, + CheckCircle2, + CircleDashed, + LayoutDashboard, + Sparkles, +} from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { usePanelStore } from "@/lib/stores/panel-store"; +import { useWorkspaceStore } from "@/lib/stores/workspace-store"; +import { cn } from "@/lib/utils"; + +const stats = [ + { label: "Open tasks", value: "24", delta: "+3 this week" }, + { label: "Due this week", value: "8", delta: "2 overdue" }, + { label: "Lists", value: "12", delta: "Across teams" }, +]; + +const recent = [ + { title: "Sprint planning", meta: "List · Updated 2h ago", status: "done" as const }, + { title: "Design review — navigation", meta: "Task · Updated yesterday", status: "progress" as const }, + { title: "Q1 roadmap doc", meta: "Doc · Edited 3d ago", status: "progress" as const }, +]; + +export default function WorkspaceHomePage() { + const workspace = useWorkspaceStore((s) => s.currentWorkspace); + const openPanel = usePanelStore((s) => s.open); + + const name = workspace?.name ?? "Workspace"; + + return ( +
+
+
+
+
+
+ + + Home + +
+

+ {name} +

+

+ Your command center for tasks, docs, and boards. Pick up where you + left off or open the assistant to plan the day. +

+
+ + +
+
+
+ +
+
+

Quick stats

+

+ Placeholder metrics until your data layer is connected. +

+
+ {stats.map((s) => ( +
+

+ {s.label} +

+

+ {s.value} +

+

{s.delta}

+
+ ))} +
+
+ + + +
+
+
+

+ Recent activity +

+

+ Latest updates across this workspace (sample rows). +

+
+ + Beta + +
+
    + {recent.map((item) => ( +
  • + + {item.status === "done" ? ( + + ) : ( + + )} + +
    +

    {item.title}

    +

    {item.meta}

    +
    + + {item.status === "done" ? "Done" : "In progress"} + +
  • + ))} +
+
+
+
+ ); +} diff --git a/apps/web/app/(app)/[workspaceSlug]/whiteboards/page.tsx b/apps/web/app/(app)/[workspaceSlug]/whiteboards/page.tsx new file mode 100644 index 0000000..e44fe4b --- /dev/null +++ b/apps/web/app/(app)/[workspaceSlug]/whiteboards/page.tsx @@ -0,0 +1,21 @@ +"use client"; + +import dynamic from "next/dynamic"; + +const WhiteboardCanvas = dynamic( + () => import("@/components/whiteboard/canvas").then((m) => m.WhiteboardCanvas), + { ssr: false, loading: () =>
Loading whiteboard...
}, +); + +export default function WhiteboardsPage() { + return ( +
+
+

Whiteboard

+
+
+ +
+
+ ); +} diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx new file mode 100644 index 0000000..2592f8c --- /dev/null +++ b/apps/web/app/(app)/layout.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from "react"; + +import { AppShell } from "@/components/layout/app-shell"; + +export default function AppLayout({ + children, +}: { + children: ReactNode; +}) { + return {children}; +} diff --git a/apps/web/app/(auth)/layout.tsx b/apps/web/app/(auth)/layout.tsx new file mode 100644 index 0000000..8bb18ac --- /dev/null +++ b/apps/web/app/(auth)/layout.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from "react"; + +export default function AuthLayout({ children }: { children: ReactNode }) { + return ( +
+
+
+
{children}
+
+
+ ); +} diff --git a/apps/web/app/(auth)/sign-in/page.tsx b/apps/web/app/(auth)/sign-in/page.tsx new file mode 100644 index 0000000..83b41f6 --- /dev/null +++ b/apps/web/app/(auth)/sign-in/page.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { Suspense, useState } from "react"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { signIn } from "next-auth/react"; +import { Loader2, Lock, Mail, Sparkles } from "lucide-react"; +import { cn } from "@/lib/utils"; + +function SignInForm() { + const searchParams = useSearchParams(); + const callbackUrl = searchParams.get("callbackUrl") ?? "/"; + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + try { + const result = await signIn("credentials", { + email, + password, + redirect: false, + callbackUrl, + }); + if (result?.error) { + setError("Invalid email or password."); + return; + } + window.location.href = callbackUrl; + } finally { + setLoading(false); + } + } + + return ( +
+
+
+ +
+

Welcome to Tasks

+

Sign in to organize your work with clarity.

+
+ +
+ {error ? ( +
+ {error} +
+ ) : null} + +
+ +
+ + setEmail(e.target.value)} + className={cn( + "w-full rounded-lg border border-input bg-background py-2.5 pl-10 pr-3 text-sm outline-none transition", + "placeholder:text-muted-foreground focus:border-[hsl(var(--primary))] focus:ring-2 focus:ring-[hsl(var(--primary)/0.25)]", + )} + placeholder="you@example.com" + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + className={cn( + "w-full rounded-lg border border-input bg-background py-2.5 pl-10 pr-3 text-sm outline-none transition", + "placeholder:text-muted-foreground focus:border-[hsl(var(--primary))] focus:ring-2 focus:ring-[hsl(var(--primary)/0.25)]", + )} + placeholder="••••••••" + /> +
+
+ + +
+ +
+
+ +
+
+ Or continue with +
+
+ +
+ + +
+ +

+ New to Tasks?{" "} + + Create an account + +

+
+ ); +} + +export default function SignInPage() { + return ( + + +
+ } + > + + + ); +} diff --git a/apps/web/app/(auth)/sign-up/page.tsx b/apps/web/app/(auth)/sign-up/page.tsx new file mode 100644 index 0000000..8038cdf --- /dev/null +++ b/apps/web/app/(auth)/sign-up/page.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { Loader2, Mail, Sparkles, User, Lock } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export default function SignUpPage() { + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [message, setMessage] = useState(null); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setMessage(null); + setLoading(true); + try { + await new Promise((r) => setTimeout(r, 600)); + setMessage("Thanks — account creation will connect to your API soon. For now, use dev sign-in."); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+ +
+

Join Tasks

+

Create your workspace — registration is a preview for now.

+
+ +
+ {message ? ( +
+ {message} +
+ ) : null} + +
+ +
+ + setName(e.target.value)} + className={cn( + "w-full rounded-lg border border-input bg-background py-2.5 pl-10 pr-3 text-sm outline-none transition", + "placeholder:text-muted-foreground focus:border-[hsl(var(--teal))] focus:ring-2 focus:ring-[hsl(var(--teal)/0.25)]", + )} + placeholder="Alex Doe" + /> +
+
+ +
+ +
+ + setEmail(e.target.value)} + className={cn( + "w-full rounded-lg border border-input bg-background py-2.5 pl-10 pr-3 text-sm outline-none transition", + "placeholder:text-muted-foreground focus:border-[hsl(var(--teal))] focus:ring-2 focus:ring-[hsl(var(--teal)/0.25)]", + )} + placeholder="you@example.com" + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + className={cn( + "w-full rounded-lg border border-input bg-background py-2.5 pl-10 pr-3 text-sm outline-none transition", + "placeholder:text-muted-foreground focus:border-[hsl(var(--teal))] focus:ring-2 focus:ring-[hsl(var(--teal)/0.25)]", + )} + placeholder="At least 8 characters" + /> +
+
+ + +
+ +

+ Already have an account?{" "} + + Sign in + +

+
+ ); +} diff --git a/apps/web/app/api/auth/[...nextauth]/route.ts b/apps/web/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..68efd7e --- /dev/null +++ b/apps/web/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,5 @@ +import { handlers } from "@/lib/auth"; + +export const runtime = "nodejs"; + +export const { GET, POST } = handlers; diff --git a/apps/web/app/api/trpc/[trpc]/route.ts b/apps/web/app/api/trpc/[trpc]/route.ts new file mode 100644 index 0000000..f0723e6 --- /dev/null +++ b/apps/web/app/api/trpc/[trpc]/route.ts @@ -0,0 +1,13 @@ +import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; +import { appRouter } from "@/server/root"; +import { createContext } from "@/server/trpc"; + +const handler = (req: Request) => + fetchRequestHandler({ + endpoint: "/api/trpc", + req, + router: appRouter, + createContext, + }); + +export { handler as GET, handler as POST }; diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx new file mode 100644 index 0000000..647a25a --- /dev/null +++ b/apps/web/app/layout.tsx @@ -0,0 +1,33 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "@/styles/globals.css"; +import { ThemeProvider } from "@/components/providers/theme-provider"; +import { TRPCProvider } from "@/components/providers/trpc-provider"; + +const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }); + +export const metadata: Metadata = { + title: "Tasks", + description: "Project management, docs, and whiteboards — all in one place.", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + {children} + + + + ); +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx new file mode 100644 index 0000000..97a64bf --- /dev/null +++ b/apps/web/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function HomePage() { + redirect("/sign-in"); +} diff --git a/apps/web/components/ai/chat-panel.tsx b/apps/web/components/ai/chat-panel.tsx new file mode 100644 index 0000000..5bfd8e7 --- /dev/null +++ b/apps/web/components/ai/chat-panel.tsx @@ -0,0 +1,322 @@ +"use client"; + +import * as React from "react"; +import type { inferRouterOutputs } from "@trpc/server"; +import { Loader2, Send, Sparkles, X } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { api } from "@/lib/trpc"; +import type { aiRouter } from "@/server/routers/ai"; +import { usePanelStore } from "@/lib/stores/panel-store"; +import { useWorkspaceStore } from "@/lib/stores/workspace-store"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { AIMessage } from "@/components/ai/message"; + +type ChatRole = "user" | "assistant"; + +type ChatLine = { + id: string; + role: ChatRole; + content: string; + createdAt: Date; +}; + +type ObjectSummary = { title: string; type: string }; +type AiOutputs = inferRouterOutputs; + +/** Cast until `aiRouter` is merged into `appRouter` in `server/root.ts` */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const aiTrpc = (api as any).ai as { + suggestActions: { + useQuery: ( + input: { objectId?: string; objectType?: string }, + opts?: { enabled?: boolean }, + ) => { data: { actions: string[] } | undefined }; + }; + chat: { + useMutation: (opts: { + onMutate?: () => void; + onSuccess?: (data: AiOutputs["chat"]) => void; + onError?: (err: { message: string }) => void; + }) => { + mutate: (input: { + messages: { role: "user" | "assistant"; content: string }[]; + context?: { workspaceId?: string; objectId?: string }; + }) => void; + isPending: boolean; + isError: boolean; + error: { message: string } | null; + }; + }; +}; + +const MODEL_LABEL = "GPT-4o"; +const MAX_CHARS = 8000; + +const WELCOME_CHIPS = [ + "Create a project plan", + "Summarize this document", + "Generate task descriptions", +]; + +function TypingDots() { + return ( +
+ {[0, 1, 2].map((i) => ( + + ))} +
+ ); +} + +export function AIChatPanel() { + const close = usePanelStore((s) => s.close); + const objectId = usePanelStore((s) => s.objectId); + const workspace = useWorkspaceStore((s) => s.currentWorkspace); + + const [input, setInput] = React.useState(""); + const [messages, setMessages] = React.useState([]); + const [sendError, setSendError] = React.useState(null); + const bottomRef = React.useRef(null); + const textareaRef = React.useRef(null); + + const objectQuery = api.objects.getById.useQuery( + { id: objectId! }, + { enabled: !!objectId }, + ); + + const objectSummary = objectQuery.data as ObjectSummary | undefined; + + const suggestQuery = aiTrpc.suggestActions.useQuery( + { + objectId: objectId ?? undefined, + objectType: objectSummary?.type, + }, + { enabled: true }, + ); + + const chatMutation = aiTrpc.chat.useMutation({ + onMutate: () => setSendError(null), + onSuccess: (data) => { + setMessages((prev) => [ + ...prev, + { + id: crypto.randomUUID(), + role: "assistant", + content: data.text, + createdAt: new Date(), + }, + ]); + }, + onError: (err) => setSendError(err.message ?? "Request failed"), + }); + + const isLoading = chatMutation.isPending; + + React.useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages, isLoading]); + + const send = React.useCallback(() => { + const trimmed = input.trim(); + if (!trimmed || isLoading) return; + + const userMsg: ChatLine = { + id: crypto.randomUUID(), + role: "user", + content: trimmed, + createdAt: new Date(), + }; + + const nextMessages = [...messages, userMsg]; + setMessages(nextMessages); + setInput(""); + + const payload = nextMessages.map((m) => ({ + role: m.role, + content: m.content, + })); + + chatMutation.mutate({ + messages: payload, + context: { + workspaceId: workspace?.id, + objectId: objectId ?? undefined, + }, + }); + }, [input, isLoading, messages, chatMutation, workspace?.id, objectId]); + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + send(); + } + }; + + const insertChip = (text: string) => { + setInput((prev) => (prev ? `${prev}\n${text}` : text)); + textareaRef.current?.focus(); + }; + + const contextLabel = objectSummary?.title + ? objectSummary.title + : workspace?.name + ? workspace.name + : "No context"; + + const suggestions = suggestQuery.data?.actions ?? []; + + return ( +
+
+
+
+ +

AI Assistant

+
+
+ + {MODEL_LABEL} + + + {objectId ? "Object: " : "Workspace: "} + {contextLabel} + +
+
+ +
+ + +
+ {messages.length === 0 && !isLoading ? ( +
+
+

How can I help?

+

+ Ask about planning, tasks, or this workspace — or try a suggestion below. +

+
+
+

Try asking

+
+ {WELCOME_CHIPS.map((chip) => ( + + ))} +
+
+
+ ) : null} + + {messages.map((m) => ( + + ))} + + {sendError ? ( + + ) : null} + + {isLoading ? ( +
+
+ +
+
+ + Assistant is thinking +
+
+ ) : null} + +
+
+ + +
+ {suggestions.length > 0 ? ( +
+ {suggestions.slice(0, 6).map((action) => ( + + ))} +
+ ) : null} + +
+
+