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
This commit is contained in:
Randall Stillwell 2026-03-26 22:39:16 -05:00
commit a508ece6e7
183 changed files with 28573 additions and 0 deletions

40
.env.example Normal file
View file

@ -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

39
.gitignore vendored Normal file
View file

@ -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/

View file

@ -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"
}
}

View file

@ -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<HocuspocusRedis | null> {
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<string, unknown>), 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);
});

View file

@ -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;
}

View file

@ -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"]
}

View file

@ -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"
}
}

View file

@ -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";

View file

@ -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;

View file

@ -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");

View file

@ -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";

View file

@ -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),
},
],
};
},
);
}

View file

@ -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),
},
],
};
},
);
}

View file

@ -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<string, number>();
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),
},
],
};
},
);
}

View file

@ -0,0 +1,2 @@
/** Re-export so tsup bundles schema with the MCP server. */
export * from "../../../packages/database/src/schema/index.ts";

View file

@ -0,0 +1 @@
export { objectTypes, viewTypes } from "../../../packages/shared/src/types/index.ts";

View file

@ -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);
}
},
);
}

View file

@ -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";

View file

@ -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);
}
},
);
}

View file

@ -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);
}
},
);
}

View file

@ -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);
}
},
);
}

View file

@ -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);
}

View file

@ -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);
}
},
);
}

View file

@ -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"]
}

View file

@ -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 (
<div className="flex h-full flex-col">
<div className="shrink-0 border-b border-border px-4 pt-2">
<ViewToolbar totalCount={total} />
</div>
<div className="flex-1 overflow-hidden">{children}</div>
</div>
);
}

View file

@ -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 <BoardView config={config} />;
case "table":
return <TableView config={config} />;
case "embed":
return <EmbedView config={config} />;
case "list":
default:
return <ListView config={config} />;
}
}

View file

@ -0,0 +1,19 @@
"use client";
import { BlockEditor } from "@/components/editor";
export default function DocsPage() {
return (
<div className="mx-auto max-w-4xl px-8 py-10">
<h1 className="mb-6 text-3xl font-bold">Documents</h1>
<div className="rounded-lg border border-border bg-card p-1">
<BlockEditor
placeholder="Start typing, or use '/' for commands..."
onChange={(html) => {
// Will persist via tRPC in later phase
}}
/>
</div>
</div>
);
}

View file

@ -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 (
<WorkspaceSync workspaceSlug={workspaceSlug}>{children}</WorkspaceSync>
);
}

View file

@ -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 (
<div className="flex min-h-full flex-col bg-background">
<header className="relative overflow-hidden border-b border-border px-6 py-10 sm:px-10">
<div
className={cn(
"absolute inset-0 bg-gradient-primary opacity-90",
"dark:opacity-100",
)}
aria-hidden
/>
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,_rgba(255,255,255,0.12),_transparent_55%)]" />
<div className="relative flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2 text-primary-foreground/90">
<LayoutDashboard className="size-5" />
<span className="text-xs font-semibold uppercase tracking-widest">
Home
</span>
</div>
<h1 className="text-3xl font-bold tracking-tight text-primary-foreground sm:text-4xl">
{name}
</h1>
<p className="max-w-2xl text-sm text-primary-foreground/85 sm:text-base">
Your command center for tasks, docs, and boards. Pick up where you
left off or open the assistant to plan the day.
</p>
<div className="flex flex-wrap gap-2 pt-2">
<Button
type="button"
size="sm"
variant="secondary"
className="gap-2 bg-primary-foreground/15 text-primary-foreground hover:bg-primary-foreground/25"
onClick={() => openPanel("ai-chat")}
>
<Sparkles className="size-4" />
Open AI assistant
</Button>
<Button
type="button"
size="sm"
variant="secondary"
className="gap-2 bg-primary-foreground/15 text-primary-foreground hover:bg-primary-foreground/25"
onClick={() => openPanel("object-detail", "demo-object")}
>
Sample side panel
<ArrowRight className="size-4" />
</Button>
</div>
</div>
</header>
<div className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-8 px-6 py-8 sm:px-10">
<section>
<h2 className="text-sm font-semibold text-foreground">Quick stats</h2>
<p className="mt-1 text-sm text-muted-foreground">
Placeholder metrics until your data layer is connected.
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-3">
{stats.map((s) => (
<div
key={s.label}
className="rounded-lg border border-border bg-card p-4 shadow-sm"
>
<p className="text-xs font-medium text-muted-foreground">
{s.label}
</p>
<p className="mt-2 text-2xl font-semibold tabular-nums text-foreground">
{s.value}
</p>
<p className="mt-1 text-xs text-muted-foreground">{s.delta}</p>
</div>
))}
</div>
</section>
<Separator />
<section>
<div className="flex items-center justify-between gap-2">
<div>
<h2 className="text-sm font-semibold text-foreground">
Recent activity
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Latest updates across this workspace (sample rows).
</p>
</div>
<Badge variant="secondary" className="shrink-0">
Beta
</Badge>
</div>
<ul className="mt-4 divide-y divide-border rounded-lg border border-border bg-card">
{recent.map((item) => (
<li
key={item.title}
className="flex items-start gap-3 px-4 py-3 first:rounded-t-lg last:rounded-b-lg"
>
<span className="mt-0.5 text-muted-foreground">
{item.status === "done" ? (
<CheckCircle2 className="size-4 text-teal" />
) : (
<CircleDashed className="size-4" />
)}
</span>
<div className="min-w-0 flex-1">
<p className="font-medium text-foreground">{item.title}</p>
<p className="text-xs text-muted-foreground">{item.meta}</p>
</div>
<Badge
variant={item.status === "done" ? "secondary" : "outline"}
className="shrink-0 capitalize"
>
{item.status === "done" ? "Done" : "In progress"}
</Badge>
</li>
))}
</ul>
</section>
</div>
</div>
);
}

View file

@ -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: () => <div className="flex h-full items-center justify-center text-muted-foreground">Loading whiteboard...</div> },
);
export default function WhiteboardsPage() {
return (
<div className="flex h-full flex-col">
<div className="shrink-0 border-b border-border px-6 py-3">
<h1 className="text-lg font-semibold">Whiteboard</h1>
</div>
<div className="flex-1">
<WhiteboardCanvas className="h-full" />
</div>
</div>
);
}

View file

@ -0,0 +1,11 @@
import type { ReactNode } from "react";
import { AppShell } from "@/components/layout/app-shell";
export default function AppLayout({
children,
}: {
children: ReactNode;
}) {
return <AppShell>{children}</AppShell>;
}

View file

@ -0,0 +1,19 @@
import type { ReactNode } from "react";
export default function AuthLayout({ children }: { children: ReactNode }) {
return (
<div className="relative min-h-screen overflow-hidden bg-gradient-to-br from-[hsl(var(--primary)/0.12)] via-background to-[hsl(var(--teal)/0.08)]">
<div
className="pointer-events-none absolute inset-0 opacity-[0.35]"
aria-hidden
style={{
backgroundImage:
"radial-gradient(circle at 20% 20%, hsl(var(--primary) / 0.25) 0%, transparent 45%), radial-gradient(circle at 80% 10%, hsl(var(--teal) / 0.2) 0%, transparent 40%), radial-gradient(circle at 50% 90%, hsl(var(--primary) / 0.15) 0%, transparent 50%)",
}}
/>
<div className="relative flex min-h-screen items-center justify-center p-4 sm:p-6">
<div className="w-full max-w-md">{children}</div>
</div>
</div>
);
}

View file

@ -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<string | null>(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 (
<div
className={cn(
"rounded-2xl border border-border/60 bg-card/80 p-8 shadow-xl shadow-[hsl(var(--primary)/0.08)] backdrop-blur-md",
"ring-1 ring-[hsl(var(--primary)/0.12)]",
)}
>
<div className="mb-8 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-[hsl(var(--primary))] to-[hsl(var(--teal))] text-primary-foreground shadow-lg">
<Sparkles className="h-6 w-6" aria-hidden />
</div>
<h1 className="text-2xl font-semibold tracking-tight text-foreground">Welcome to Tasks</h1>
<p className="mt-2 text-sm text-muted-foreground">Sign in to organize your work with clarity.</p>
</div>
<form onSubmit={onSubmit} className="space-y-5">
{error ? (
<div
role="alert"
className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
>
{error}
</div>
) : null}
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium text-foreground">
Email
</label>
<div className="relative">
<Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => 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"
/>
</div>
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium text-foreground">
Password
</label>
<div className="relative">
<Lock className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => 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="••••••••"
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className={cn(
"flex w-full items-center justify-center gap-2 rounded-lg py-2.5 text-sm font-medium text-primary-foreground transition",
"bg-[hsl(var(--primary))] hover:bg-[hsl(var(--primary)/0.92)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[hsl(var(--primary))]",
"disabled:pointer-events-none disabled:opacity-60",
)}
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Sign in
</button>
</form>
<div className="relative my-8">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-xs uppercase tracking-wide">
<span className="bg-card/90 px-2 text-muted-foreground">Or continue with</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => void signIn("github", { callbackUrl })}
className={cn(
"flex items-center justify-center gap-2 rounded-lg border border-border bg-background py-2.5 text-sm font-medium transition",
"hover:border-[hsl(var(--primary)/0.4)] hover:bg-muted/50",
)}
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
GitHub
</button>
<button
type="button"
onClick={() => void signIn("google", { callbackUrl })}
className={cn(
"flex items-center justify-center gap-2 rounded-lg border border-border bg-background py-2.5 text-sm font-medium transition",
"hover:border-[hsl(var(--teal)/0.45)] hover:bg-muted/50",
)}
>
<svg className="h-4 w-4" viewBox="0 0 24 24" aria-hidden>
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Google
</button>
</div>
<p className="mt-8 text-center text-sm text-muted-foreground">
New to Tasks?{" "}
<Link
href="/sign-up"
className="font-medium text-[hsl(var(--primary))] underline-offset-4 hover:underline"
>
Create an account
</Link>
</p>
</div>
);
}
export default function SignInPage() {
return (
<Suspense
fallback={
<div className="flex min-h-[320px] items-center justify-center rounded-2xl border border-border/60 bg-card/80 p-8">
<Loader2 className="h-8 w-8 animate-spin text-[hsl(var(--primary))]" aria-label="Loading" />
</div>
}
>
<SignInForm />
</Suspense>
);
}

View file

@ -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<string | null>(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 (
<div
className={cn(
"rounded-2xl border border-border/60 bg-card/80 p-8 shadow-xl shadow-[hsl(var(--teal)/0.06)] backdrop-blur-md",
"ring-1 ring-[hsl(var(--teal)/0.15)]",
)}
>
<div className="mb-8 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-[hsl(var(--teal))] to-[hsl(var(--primary))] text-primary-foreground shadow-lg">
<Sparkles className="h-6 w-6" aria-hidden />
</div>
<h1 className="text-2xl font-semibold tracking-tight text-foreground">Join Tasks</h1>
<p className="mt-2 text-sm text-muted-foreground">Create your workspace registration is a preview for now.</p>
</div>
<form onSubmit={onSubmit} className="space-y-5">
{message ? (
<div
role="status"
className="rounded-lg border border-[hsl(var(--teal)/0.35)] bg-[hsl(var(--teal)/0.08)] px-3 py-2 text-sm text-foreground"
>
{message}
</div>
) : null}
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-medium text-foreground">
Name
</label>
<div className="relative">
<User className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
id="name"
name="name"
type="text"
autoComplete="name"
required
value={name}
onChange={(e) => 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"
/>
</div>
</div>
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium text-foreground">
Email
</label>
<div className="relative">
<Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => 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"
/>
</div>
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium text-foreground">
Password
</label>
<div className="relative">
<Lock className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
minLength={8}
value={password}
onChange={(e) => 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"
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className={cn(
"flex w-full items-center justify-center gap-2 rounded-lg py-2.5 text-sm font-medium text-primary-foreground transition",
"bg-gradient-to-r from-[hsl(var(--primary))] to-[hsl(var(--teal))] hover:opacity-[0.96]",
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[hsl(var(--primary))]",
"disabled:pointer-events-none disabled:opacity-60",
)}
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Create account
</button>
</form>
<p className="mt-8 text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link
href="/sign-in"
className="font-medium text-[hsl(var(--primary))] underline-offset-4 hover:underline"
>
Sign in
</Link>
</p>
</div>
);
}

View file

@ -0,0 +1,5 @@
import { handlers } from "@/lib/auth";
export const runtime = "nodejs";
export const { GET, POST } = handlers;

View file

@ -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 };

33
apps/web/app/layout.tsx Normal file
View file

@ -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 (
<html lang="en" suppressHydrationWarning>
<body className={`${inter.variable} font-sans antialiased`}>
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
>
<TRPCProvider>{children}</TRPCProvider>
</ThemeProvider>
</body>
</html>
);
}

5
apps/web/app/page.tsx Normal file
View file

@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function HomePage() {
redirect("/sign-in");
}

View file

@ -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<typeof aiRouter>;
/** 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 (
<div className="flex items-center gap-1.5 px-1 py-2" aria-hidden>
{[0, 1, 2].map((i) => (
<span
key={i}
className="inline-block h-2 w-2 animate-bounce rounded-full bg-muted-foreground/70"
style={{ animationDelay: `${i * 0.15}s` }}
/>
))}
</div>
);
}
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<ChatLine[]>([]);
const [sendError, setSendError] = React.useState<string | null>(null);
const bottomRef = React.useRef<HTMLDivElement>(null);
const textareaRef = React.useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
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 (
<div className="flex h-full min-h-0 flex-col bg-background">
<header className="flex shrink-0 items-start justify-between gap-2 border-b border-border/80 px-4 py-3">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
<Sparkles className="size-5 shrink-0 text-primary" />
<h2 className="truncate text-base font-semibold tracking-tight">AI Assistant</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="font-normal">
{MODEL_LABEL}
</Badge>
<span
className="truncate text-xs text-muted-foreground"
title={contextLabel}
>
{objectId ? "Object: " : "Workspace: "}
<span className="text-foreground/90">{contextLabel}</span>
</span>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => close()}
aria-label="Close panel"
>
<X className="size-4" />
</Button>
</header>
<ScrollArea className="min-h-0 flex-1 px-3">
<div className="pb-4 pt-2">
{messages.length === 0 && !isLoading ? (
<div className="space-y-4 px-1">
<div className="rounded-2xl border border-dashed border-primary/25 bg-primary/5 px-4 py-6 text-center dark:bg-primary/10">
<p className="text-sm font-medium text-foreground">How can I help?</p>
<p className="mt-1 text-xs text-muted-foreground">
Ask about planning, tasks, or this workspace or try a suggestion below.
</p>
</div>
<div className="flex flex-col gap-2">
<p className="text-xs font-medium text-muted-foreground">Try asking</p>
<div className="flex flex-wrap gap-2">
{WELCOME_CHIPS.map((chip) => (
<button
key={chip}
type="button"
onClick={() => insertChip(chip)}
className="rounded-full border border-border bg-card px-3 py-1.5 text-left text-xs transition-colors hover:bg-accent"
>
{chip}
</button>
))}
</div>
</div>
</div>
) : null}
{messages.map((m) => (
<AIMessage
key={m.id}
role={m.role}
content={m.content}
timestamp={m.createdAt}
/>
))}
{sendError ? (
<AIMessage role="system" content={sendError} />
) : null}
{isLoading ? (
<div className="flex items-start gap-3 px-1 py-2">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-border/60 bg-teal-500/15 text-teal-600 dark:text-teal-400">
<Sparkles className="size-4" />
</div>
<div className="rounded-2xl rounded-tl-md border border-border/80 bg-card px-4 py-3">
<TypingDots />
<span className="sr-only">Assistant is thinking</span>
</div>
</div>
) : null}
<div ref={bottomRef} />
</div>
</ScrollArea>
<div className="shrink-0 border-t border-border/80 bg-muted/20 px-3 pb-3 pt-2">
{suggestions.length > 0 ? (
<div className="mb-2 flex flex-wrap gap-1.5">
{suggestions.slice(0, 6).map((action) => (
<button
key={action}
type="button"
onClick={() => insertChip(action)}
className={cn(
"rounded-full border border-primary/20 bg-background/80 px-2.5 py-1 text-xs text-foreground",
"transition-colors hover:border-primary/40 hover:bg-primary/5",
)}
>
{action}
</button>
))}
</div>
) : null}
<div className="flex gap-2">
<div className="relative min-w-0 flex-1">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value.slice(0, MAX_CHARS))}
onKeyDown={onKeyDown}
placeholder="Ask AI anything..."
disabled={isLoading}
rows={3}
className={cn(
"w-full resize-none rounded-xl border border-input bg-background px-3 py-2.5 text-sm",
"placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
/>
<div className="pointer-events-none absolute bottom-2 right-2 text-[10px] text-muted-foreground">
{input.length}/{MAX_CHARS}
</div>
</div>
<Button
type="button"
size="icon"
className="h-auto min-h-[5.5rem] shrink-0 rounded-xl bg-primary"
onClick={send}
disabled={isLoading || !input.trim()}
aria-label="Send message"
>
{isLoading ? (
<Loader2 className="size-5 animate-spin" />
) : (
<Send className="size-5" />
)}
</Button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,565 @@
"use client";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { generateText } from "ai";
import {
ArrowRight,
CheckSquare,
Command,
FileText,
FolderKanban,
LayoutGrid,
Loader2,
Search,
Settings,
Sparkles,
SquarePen,
} from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import {
createOpenAIClient,
GENERAL_SYSTEM_PROMPT,
selectOpenAIModel,
} from "../../../../packages/ai/src";
const RECENT_KEY = "tasks-command-palette-recent-v1";
type PaletteMode = "default" | "ai" | "nav";
export type ResultKind = "object" | "action" | "page" | "ai";
export type PaletteResult = {
id: string;
kind: ResultKind;
title: string;
subtitle?: string;
icon: React.ComponentType<{ className?: string }>;
href?: string;
onSelect?: () => void;
group: string;
};
function loadRecent(): string[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(RECENT_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string") : [];
} catch {
return [];
}
}
function saveRecent(ids: string[]) {
try {
window.localStorage.setItem(RECENT_KEY, JSON.stringify(ids.slice(0, 12)));
} catch {
/* ignore */
}
}
function fuzzyScore(text: string, query: string): number {
const t = text.toLowerCase();
const q = query.toLowerCase().trim();
if (!q) return 1;
let qi = 0;
let bonus = 0;
for (let i = 0; i < t.length && qi < q.length; i++) {
if (t[i] === q[qi]) {
bonus += i === 0 ? 2 : 1;
qi++;
}
}
if (qi < q.length) return 0;
return bonus + 1 / t.length;
}
function parseQuery(raw: string): { mode: PaletteMode; body: string } {
const s = raw.trimStart();
if (s.startsWith(">")) return { mode: "ai", body: s.slice(1).trim() };
if (s.startsWith("/")) return { mode: "nav", body: s.slice(1).trim() };
return { mode: "default", body: raw.trim() };
}
export function CommandPalette() {
const router = useRouter();
const params = useParams<{ workspaceSlug?: string }>();
const workspaceSlug = params?.workspaceSlug ?? "workspace";
const base = `/${workspaceSlug}`;
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const [active, setActive] = React.useState(0);
const [recentIds, setRecentIds] = React.useState<string[]>([]);
const [aiLoading, setAiLoading] = React.useState(false);
const [aiReply, setAiReply] = React.useState<string | null>(null);
const [aiError, setAiError] = React.useState<string | null>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
setRecentIds(loadRecent());
}, [open]);
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const isK = e.key === "k" || e.key === "K";
if (!isK || !(e.metaKey || e.ctrlKey)) return;
const t = e.target as HTMLElement | null;
if (t?.closest?.("[data-command-palette-ignore-shortcut]")) return;
e.preventDefault();
setOpen((o) => !o);
};
document.addEventListener("keydown", onKey, true);
return () => document.removeEventListener("keydown", onKey, true);
}, []);
React.useEffect(() => {
if (!open) return;
const id = window.requestAnimationFrame(() => inputRef.current?.focus());
return () => window.cancelAnimationFrame(id);
}, [open]);
React.useEffect(() => {
if (!open) {
setAiReply(null);
setAiError(null);
setAiLoading(false);
}
}, [open]);
const { mode, body } = parseQuery(query);
const catalog = React.useMemo((): PaletteResult[] => {
const objects: PaletteResult[] = [
{
id: "obj-1",
kind: "object",
title: "Sprint planning",
subtitle: "Task · Due Friday",
icon: CheckSquare,
href: `${base}/lists/sprint`,
group: "Objects",
},
{
id: "obj-2",
kind: "object",
title: "Q1 roadmap",
subtitle: "Document · Edited 3d ago",
icon: FileText,
href: `${base}/docs/roadmap`,
group: "Objects",
},
{
id: "obj-3",
kind: "object",
title: "Product launch",
subtitle: "Project · 12 members",
icon: FolderKanban,
href: `${base}/launch`,
group: "Objects",
},
];
const actions: PaletteResult[] = [
{
id: "act-create-task",
kind: "action",
title: "Create task",
subtitle: "Add a new task to the current workspace",
icon: SquarePen,
group: "Actions",
onSelect: () => router.push(`${base}?create=task`),
},
{
id: "act-create-doc",
kind: "action",
title: "Create document",
subtitle: "Start a new doc from the template gallery",
icon: FileText,
group: "Actions",
onSelect: () => router.push(`${base}/docs?new=1`),
},
{
id: "act-settings",
kind: "action",
title: "Open settings",
subtitle: "Workspace preferences and integrations",
icon: Settings,
group: "Actions",
onSelect: () => router.push(`${base}/settings`),
},
];
const pages: PaletteResult[] = [
{
id: "page-home",
kind: "page",
title: "Workspace home",
subtitle: "Dashboard",
icon: LayoutGrid,
href: base,
group: "Pages",
},
{
id: "page-docs",
kind: "page",
title: "Documents",
subtitle: "All docs in this workspace",
icon: FileText,
href: `${base}/docs`,
group: "Pages",
},
{
id: "page-boards",
kind: "page",
title: "Whiteboards",
subtitle: "Visual boards",
icon: FolderKanban,
href: `${base}/whiteboards`,
group: "Pages",
},
];
return [...objects, ...actions, ...pages];
}, [base, router]);
const recentResults = React.useMemo(() => {
if (recentIds.length === 0) return [];
const map = new Map(catalog.map((c) => [c.id, c]));
return recentIds
.map((id) => map.get(id))
.filter((x): x is PaletteResult => Boolean(x));
}, [catalog, recentIds]);
const flatRows = React.useMemo(() => {
if (mode === "ai") {
const row: PaletteResult = {
id: "ai-run",
kind: "ai",
title: body ? `Ask AI: ${body}` : "Ask AI (type after >)",
subtitle: body
? "Press Enter to run"
: "Example: > summarize my week",
icon: Sparkles,
group: "AI",
};
return [row];
}
if (mode === "nav") {
const pages = catalog.filter((c) => c.kind === "page");
if (!body) return pages;
return pages
.map((p) => ({
p,
s: Math.max(
fuzzyScore(p.title, body),
fuzzyScore(p.subtitle ?? "", body),
),
}))
.filter((x) => x.s > 0)
.sort((a, b) => b.s - a.s)
.map((x) => x.p);
}
const q = body;
if (!q) {
if (recentResults.length > 0) {
const seen = new Set(recentResults.map((r) => r.id));
const rest = catalog.filter((c) => !seen.has(c.id));
return [
...recentResults.map((r) => ({ ...r, group: "Recent" })),
...rest,
];
}
return catalog;
}
const filtered = catalog
.map((c) => ({
c,
s: Math.max(
fuzzyScore(c.title, q),
fuzzyScore(c.subtitle ?? "", q),
fuzzyScore(c.group, q),
),
}))
.filter((x) => x.s > 0)
.sort((a, b) => b.s - a.s)
.map((x) => x.c);
const ask: PaletteResult = {
id: "ask-ai",
kind: "ai",
title: `Ask AI: ${q}`,
subtitle: "Answer in the palette",
icon: Sparkles,
group: "AI",
};
return [...filtered, ask];
}, [body, catalog, mode, recentResults]);
React.useEffect(() => {
setActive(0);
}, [query, flatRows.length, mode]);
const pushRecent = React.useCallback((id: string) => {
setRecentIds((prev) => {
const next = [id, ...prev.filter((x) => x !== id)];
saveRecent(next);
return next;
});
}, []);
const runAi = React.useCallback(async (text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
setAiLoading(true);
setAiError(null);
setAiReply(null);
try {
const client = createOpenAIClient();
if (!client.ok) {
setAiError(
"Add OPENAI_API_KEY or NEXT_PUBLIC_OPENAI_API_KEY to use AI in the command palette.",
);
return;
}
const model = selectOpenAIModel(client.provider, "gpt-4o-mini");
const { text: out } = await generateText({
model,
system: GENERAL_SYSTEM_PROMPT,
prompt: trimmed,
});
setAiReply(out);
} catch (e) {
setAiError((e as Error).message ?? "AI request failed.");
} finally {
setAiLoading(false);
}
}, []);
const execute = React.useCallback(
(row: PaletteResult) => {
if (row.kind === "ai" && row.id === "ask-ai") {
void runAi(body);
pushRecent(row.id);
return;
}
if (row.kind === "ai" && row.id === "ai-run") {
void runAi(body);
return;
}
if (row.href) {
router.push(row.href);
pushRecent(row.id);
setOpen(false);
setQuery("");
return;
}
if (row.onSelect) {
row.onSelect();
pushRecent(row.id);
setOpen(false);
setQuery("");
}
},
[body, pushRecent, router, runAi],
);
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((i) => (flatRows.length ? (i + 1) % flatRows.length : 0));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((i) =>
flatRows.length ? (i - 1 + flatRows.length) % flatRows.length : 0,
);
} else if (e.key === "Enter") {
e.preventDefault();
const row = flatRows[active];
if (row) execute(row);
}
};
return (
<DialogPrimitive.Root open={open} onOpenChange={setOpen}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-[200] bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
className={cn(
"fixed left-1/2 top-[12vh] z-[201] w-[min(720px,calc(100vw-2rem))] -translate-x-1/2 rounded-2xl border border-border bg-popover p-0 shadow-2xl outline-none",
"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",
)}
onKeyDown={onKeyDown}
onOpenAutoFocus={(ev) => ev.preventDefault()}
>
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<Search className="size-5 shrink-0 text-muted-foreground" />
<Input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={
mode === "ai"
? "AI command…"
: mode === "nav"
? "Jump to page…"
: "Search or run a command…"
}
className="h-11 border-0 bg-transparent px-0 text-base shadow-none focus-visible:ring-0"
/>
<Badge variant="outline" className="hidden shrink-0 gap-1 sm:inline-flex">
<Command className="size-3" />
K
</Badge>
</div>
<div className="px-4 pb-2 pt-1 text-[11px] text-muted-foreground">
<span className="font-medium text-foreground/80">Modes:</span>{" "}
<kbd className="rounded border border-border bg-muted px-1 py-0.5 font-mono text-[10px]">
&gt;
</kbd>{" "}
AI ·{" "}
<kbd className="rounded border border-border bg-muted px-1 py-0.5 font-mono text-[10px]">
/
</kbd>{" "}
pages
</div>
<ScrollArea className="max-h-[min(420px,60vh)]">
<div className="px-2 pb-3 pt-1">
{flatRows.length === 0 ? (
<div className="px-3 py-10 text-center text-sm text-muted-foreground">
No matches. Try a shorter query or switch mode with{" "}
<kbd className="rounded border px-1 font-mono text-xs">&gt;</kbd>{" "}
or{" "}
<kbd className="rounded border px-1 font-mono text-xs">/</kbd>.
</div>
) : (
flatRows.map((row, idx) => {
const showHeader =
idx === 0 ||
flatRows[idx - 1]!.group !== row.group;
const Icon = row.icon;
const isActive = idx === active;
return (
<div key={`${row.group}-${row.id}-${idx}`}>
{showHeader ? (
<>
{idx > 0 ? (
<Separator className="my-2 opacity-50" />
) : null}
<div className="px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{row.group}
</div>
</>
) : null}
<button
type="button"
className={cn(
"mb-0.5 flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition-colors",
isActive
? "bg-primary/12 text-foreground"
: "hover:bg-muted/80",
)}
onClick={() => execute(row)}
onMouseEnter={() => setActive(idx)}
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-muted/50">
<Icon className="size-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block font-medium leading-tight">
{row.title}
</span>
{row.subtitle ? (
<span className="text-xs text-muted-foreground">
{row.subtitle}
</span>
) : null}
</span>
{"href" in row && row.href ? (
<ArrowRight className="size-4 shrink-0 text-muted-foreground opacity-60" />
) : null}
</button>
</div>
);
})
)}
</div>
</ScrollArea>
{(aiLoading || aiReply || aiError) && (
<div className="border-t border-border px-4 py-3">
{aiLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin text-primary" />
Thinking
</div>
) : null}
{aiError ? (
<p className="text-sm text-destructive">{aiError}</p>
) : null}
{aiReply ? (
<div className="max-h-40 overflow-y-auto rounded-lg border border-border bg-muted/30 p-3 text-sm leading-relaxed text-foreground">
{aiReply}
</div>
) : null}
{aiReply || aiError ? (
<Button
type="button"
variant="ghost"
size="sm"
className="mt-2 h-8"
onClick={() => {
setAiReply(null);
setAiError(null);
}}
>
Clear
</Button>
) : null}
</div>
)}
<div className="flex items-center justify-between border-t border-border px-4 py-2 text-[11px] text-muted-foreground">
<span>
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-mono">
</kbd>{" "}
navigate ·{" "}
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-mono">
</kbd>{" "}
run ·{" "}
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-mono">
esc
</kbd>{" "}
close
</span>
</div>
<DialogPrimitive.Title className="sr-only">
Command palette
</DialogPrimitive.Title>
<DialogPrimitive.Description className="sr-only">
Search workspace objects, run actions, or ask AI.
</DialogPrimitive.Description>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}

View file

@ -0,0 +1,3 @@
export { AIChatPanel } from "./chat-panel";
export { AIMessage } from "./message";
export type { AIMessageProps } from "./message";

View file

@ -0,0 +1,197 @@
"use client";
import * as React from "react";
import { Sparkles, User } from "lucide-react";
import { cn } from "@/lib/utils";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
export type AIMessageProps = {
role: "user" | "assistant" | "system";
content: string;
timestamp?: Date;
};
/** Minimal markdown: **bold**, *italic*, `code`, lists */
function renderAssistantMarkdown(content: string): React.ReactNode {
const lines = content.split("\n");
const blocks: React.ReactNode[] = [];
let listBuf: { ordered: boolean; items: string[] } | null = null;
let blockKey = 0;
const parseInline = (text: string, keyPrefix: string): React.ReactNode[] => {
const nodes: React.ReactNode[] = [];
let remaining = text;
let i = 0;
while (remaining.length > 0) {
const tick = remaining.match(/^`([^`]+)`/);
if (tick) {
nodes.push(
<code
key={`${keyPrefix}-c-${i++}`}
className="rounded bg-muted/80 px-1.5 py-0.5 font-mono text-[0.85em] text-foreground"
>
{tick[1]}
</code>,
);
remaining = remaining.slice(tick[0].length);
continue;
}
const bold = remaining.match(/^\*\*([^*]+)\*\*/);
if (bold) {
nodes.push(
<strong key={`${keyPrefix}-b-${i++}`} className="font-semibold text-foreground">
{bold[1]}
</strong>,
);
remaining = remaining.slice(bold[0].length);
continue;
}
const italic = remaining.match(/^\*([^*]+)\*/);
if (italic) {
nodes.push(
<em key={`${keyPrefix}-i-${i++}`} className="italic">
{italic[1]}
</em>,
);
remaining = remaining.slice(italic[0].length);
continue;
}
const nextSpecial = remaining.search(/[`\*]/);
if (nextSpecial === -1) {
nodes.push(remaining);
break;
}
if (nextSpecial > 0) {
nodes.push(remaining.slice(0, nextSpecial));
remaining = remaining.slice(nextSpecial);
continue;
}
nodes.push(remaining[0]);
remaining = remaining.slice(1);
}
return nodes;
};
const flushList = () => {
if (!listBuf || listBuf.items.length === 0) return;
const { ordered, items } = listBuf;
listBuf = null;
const lis = items.map((line, i) => (
<li key={i} className="ml-1 list-inside leading-relaxed marker:text-muted-foreground">
{parseInline(line, `li-${i}`)}
</li>
));
if (ordered) {
blocks.push(
<ol key={`ol-${blockKey++}`} className="list-decimal space-y-1 pl-4">
{lis}
</ol>,
);
} else {
blocks.push(
<ul key={`ul-${blockKey++}`} className="list-disc space-y-1 pl-4">
{lis}
</ul>,
);
}
};
for (const line of lines) {
const ul = line.match(/^\s*[-*]\s+(.*)$/);
const ol = line.match(/^\s*\d+\.\s+(.*)$/);
if (ul) {
if (listBuf?.ordered) flushList();
if (!listBuf) listBuf = { ordered: false, items: [] };
listBuf.items.push(ul[1] ?? "");
continue;
}
if (ol) {
if (listBuf && !listBuf.ordered) flushList();
if (!listBuf) listBuf = { ordered: true, items: [] };
listBuf.items.push(ol[1] ?? "");
continue;
}
flushList();
if (line.trim() === "") {
blocks.push(<div key={`sp-${blockKey++}`} className="h-2" />);
} else {
blocks.push(
<p key={`p-${blockKey++}`} className="leading-relaxed">
{parseInline(line, `p-${blockKey}`)}
</p>,
);
}
}
flushList();
return <div className="space-y-2 text-sm">{blocks}</div>;
}
export function AIMessage({ role, content, timestamp }: AIMessageProps) {
if (role === "system") {
return (
<div className="flex justify-center px-2 py-1">
<p className="max-w-[90%] text-center text-xs text-muted-foreground">{content}</p>
</div>
);
}
const isUser = role === "user";
return (
<div
className={cn(
"flex gap-3 px-1 py-2",
isUser ? "flex-row-reverse" : "flex-row",
)}
>
<Avatar className="mt-0.5 h-8 w-8 shrink-0 border border-border/60">
<AvatarFallback
className={cn(
"text-xs",
isUser
? "bg-primary/15 text-primary"
: "bg-teal-500/15 text-teal-600 dark:text-teal-400",
)}
>
{isUser ? <User className="size-4" /> : <Sparkles className="size-4" />}
</AvatarFallback>
</Avatar>
<div
className={cn(
"flex min-w-0 max-w-[min(100%,28rem)] flex-col gap-1",
isUser ? "items-end" : "items-start",
)}
>
<div
className={cn(
"rounded-2xl px-4 py-2.5 shadow-sm",
isUser
? "rounded-tr-md bg-primary text-primary-foreground"
: "rounded-tl-md border border-border/80 bg-card text-card-foreground",
)}
>
{isUser ? (
<p className="whitespace-pre-wrap text-sm leading-relaxed">{content}</p>
) : (
<div className="prose prose-sm dark:prose-invert max-w-none prose-p:my-1 prose-headings:my-2">
{renderAssistantMarkdown(content)}
</div>
)}
</div>
{timestamp ? (
<time
dateTime={timestamp.toISOString()}
className="text-[10px] text-muted-foreground"
>
{timestamp.toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
})}
</time>
) : null}
</div>
</div>
);
}

View file

@ -0,0 +1,442 @@
"use client";
import * as React from "react";
import { useEditor, EditorContent, type Editor } from "@tiptap/react";
import { BubbleMenu } from "@tiptap/react/menus";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import Image from "@tiptap/extension-image";
import TaskList from "@tiptap/extension-task-list";
import TaskItem from "@tiptap/extension-task-item";
import Highlight from "@tiptap/extension-highlight";
import Typography from "@tiptap/extension-typography";
import TextAlign from "@tiptap/extension-text-align";
import HorizontalRule from "@tiptap/extension-horizontal-rule";
import CodeBlock from "@tiptap/extension-code-block";
import { Table, TableRow, TableCell, TableHeader } from "@tiptap/extension-table";
import { Collaboration } from "@tiptap/extension-collaboration";
import CollaborationCursor from "@tiptap/extension-collaboration-cursor";
import { WebSocketStatus } from "@hocuspocus/provider";
import {
Bold,
Italic,
Underline as UnderlineIcon,
Strikethrough,
Code,
Link2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
useCollaboration,
useAwarenessPeers,
colorFromUserName,
reconnectCollaboration,
type CollaborationConnection,
} from "@/lib/yjs-provider";
import {
PresenceAvatars,
ConnectionStatus,
type ConnectionUiStatus,
} from "@/components/ui/presence";
import { SlashCommand } from "./slash-menu";
import { EditorToolbar } from "./toolbar";
import { BlockDragHandle } from "./drag-handle";
import { Callout } from "./extensions/callout";
import { Toggle } from "./extensions/toggle";
import { Mention } from "./extensions/mention";
import { Embed } from "./extensions/embed";
import { Divider } from "./extensions/divider";
import { BlockEditor, type BlockEditorProps } from "./editor";
function bubbleCh(editor: Editor) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return editor.chain().focus() as any;
}
const editorContentStyles = cn(
"editor-prose min-h-[280px] w-full max-w-none px-14 py-10 text-[15px] leading-7 text-foreground antialiased",
"focus:outline-none [&_.ProseMirror]:outline-none [&_.ProseMirror]:min-h-[240px]",
"[&_.ProseMirror_p.is-empty]:relative [&_.ProseMirror_p.is-empty::before]:pointer-events-none [&_.ProseMirror_p.is-empty::before]:float-left [&_.ProseMirror_p.is-empty::before]:h-0 [&_.ProseMirror_p.is-empty::before]:text-muted-foreground [&_.ProseMirror_p.is-empty::before]:content-[attr(data-placeholder)]",
"[&_.ProseMirror_p]:my-1 [&_.ProseMirror_p]:leading-7",
"[&_.ProseMirror_h1]:mb-3 [&_.ProseMirror_h1]:mt-8 [&_.ProseMirror_h1]:text-3xl [&_.ProseMirror_h1]:font-bold [&_.ProseMirror_h1]:tracking-tight [&_.ProseMirror_h1]:text-foreground",
"[&_.ProseMirror_h2]:mb-2 [&_.ProseMirror_h2]:mt-6 [&_.ProseMirror_h2]:text-2xl [&_.ProseMirror_h2]:font-semibold [&_.ProseMirror_h2]:tracking-tight",
"[&_.ProseMirror_h3]:mb-2 [&_.ProseMirror_h3]:mt-5 [&_.ProseMirror_h3]:text-xl [&_.ProseMirror_h3]:font-semibold",
"[&_.ProseMirror_ul]:my-2 [&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ul]:pl-6",
"[&_.ProseMirror_ol]:my-2 [&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ol]:pl-6",
"[&_.ProseMirror_li]:my-0.5 [&_.ProseMirror_li_p]:my-0",
"[&_.ProseMirror_blockquote]:my-3 [&_.ProseMirror_blockquote]:border-l-4 [&_.ProseMirror_blockquote]:border-teal [&_.ProseMirror_blockquote]:bg-muted/40 [&_.ProseMirror_blockquote]:py-1 [&_.ProseMirror_blockquote]:pl-4 [&_.ProseMirror_blockquote]:pr-2 [&_.ProseMirror_blockquote]:italic [&_.ProseMirror_blockquote]:text-muted-foreground",
"[&_.ProseMirror_pre]:my-4 [&_.ProseMirror_pre]:overflow-x-auto [&_.ProseMirror_pre]:rounded-lg [&_.ProseMirror_pre]:border [&_.ProseMirror_pre]:border-border [&_.ProseMirror_pre]:bg-muted/80 [&_.ProseMirror_pre]:p-4 [&_.ProseMirror_pre]:font-mono [&_.ProseMirror_pre]:text-sm",
"[&_.ProseMirror_code]:rounded-md [&_.ProseMirror_code]:bg-muted [&_.ProseMirror_code]:px-1.5 [&_.ProseMirror_code]:py-0.5 [&_.ProseMirror_code]:font-mono [&_.ProseMirror_code]:text-[0.9em] [&_.ProseMirror_code]:text-foreground",
"[&_.ProseMirror_pre_code]:bg-transparent [&_.ProseMirror_pre_code]:p-0",
"[&_.ProseMirror_hr]:my-6 [&_.ProseMirror_hr]:border-border",
"[&_.ProseMirror_img]:my-4 [&_.ProseMirror_img]:max-h-[480px] [&_.ProseMirror_img]:w-auto [&_.ProseMirror_img]:max-w-full [&_.ProseMirror_img]:rounded-lg [&_.ProseMirror_img]:border [&_.ProseMirror_img]:border-border",
"[&_.ProseMirror_table]:my-4 [&_.ProseMirror_table]:w-full [&_.ProseMirror_table]:border-collapse [&_.ProseMirror_table]:overflow-hidden [&_.ProseMirror_table]:rounded-md [&_.ProseMirror_table]:border [&_.ProseMirror_table]:border-border",
"[&_.ProseMirror_td]:border [&_.ProseMirror_td]:border-border [&_.ProseMirror_td]:bg-background [&_.ProseMirror_td]:px-3 [&_.ProseMirror_td]:py-2",
"[&_.ProseMirror_th]:border [&_.ProseMirror_th]:border-border [&_.ProseMirror_th]:bg-muted [&_.ProseMirror_th]:px-3 [&_.ProseMirror_th]:py-2 [&_.ProseMirror_th]:text-left [&_.ProseMirror_th]:font-medium",
"[&_.ProseMirror_.ProseMirror-selectednode]:ring-2 [&_.ProseMirror_.ProseMirror-selectednode]:ring-primary/40",
"dark:[&_.ProseMirror_blockquote]:bg-muted/20",
);
function mapWsToUi(ws: WebSocketStatus): ConnectionUiStatus {
if (ws === WebSocketStatus.Connected) return "connected";
if (ws === WebSocketStatus.Connecting) return "connecting";
return "disconnected";
}
export type CollaborativeBlockEditorProps = BlockEditorProps & {
documentId: string;
userName?: string;
userColor?: string;
};
function CollaborativeEditorShell({
collab,
displayName,
cursorColor,
documentName,
placeholder,
onChange,
editable,
className,
}: {
collab: CollaborationConnection & { doc: NonNullable<CollaborationConnection["doc"]>; provider: NonNullable<CollaborationConnection["provider"]> };
displayName: string;
cursorColor: string;
documentName: string;
placeholder?: string;
} & Pick<BlockEditorProps, "onChange" | "editable" | "className">) {
const containerRef = React.useRef<HTMLDivElement>(null);
const { doc, provider } = collab;
const extensions = React.useMemo(
() => [
StarterKit.configure({
undoRedo: false,
heading: { levels: [1, 2, 3] },
codeBlock: false,
horizontalRule: false,
link: {
openOnClick: false,
HTMLAttributes: {
class:
"text-primary underline underline-offset-2 decoration-primary/50",
},
},
}),
Collaboration.configure({
document: doc,
field: "default",
provider,
}),
CollaborationCursor.configure({
provider,
user: {
name: displayName,
color: cursorColor,
},
}),
Placeholder.configure({
placeholder:
placeholder ??
'Write something, or type "/" for commands…',
}),
CodeBlock.configure({
HTMLAttributes: {
class: "font-mono text-sm",
},
}),
HorizontalRule,
Image.configure({
HTMLAttributes: {
class: "rounded-lg border border-border",
},
}),
TaskList,
TaskItem.configure({
nested: true,
HTMLAttributes: {
class: "flex gap-2",
},
}),
Highlight.configure({
multicolor: true,
}),
Typography,
TextAlign.configure({
types: ["heading", "paragraph"],
}),
Table.configure({
resizable: false,
}),
TableRow,
TableHeader,
TableCell,
SlashCommand,
Callout,
Toggle,
Mention,
Embed,
Divider,
],
[cursorColor, displayName, doc, placeholder, provider],
);
const editor = useEditor(
{
immediatelyRender: false,
extensions,
// Yjs is the source of truth; avoid seeding HTML that could fight the fragment.
content: "",
editable: editable ?? true,
editorProps: {
attributes: {
class: "focus:outline-none",
},
},
onUpdate: ({ editor: ed }) => {
onChange?.(ed.getHTML());
},
},
[extensions, editable],
);
React.useEffect(() => {
if (!editor) return;
editor.commands.updateUser({
name: displayName,
color: cursorColor,
});
}, [cursorColor, displayName, editor]);
React.useEffect(() => {
if (!editor) return;
editor.setEditable(editable ?? true);
}, [editable, editor]);
const localClientId = doc.clientID;
const peers = useAwarenessPeers(collab.awareness, localClientId);
const connectionLabel = mapWsToUi(collab.wsStatus);
return (
<TooltipProvider delayDuration={200}>
<div
className={cn(
"group/editor overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-sm",
className,
)}
>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border/80 bg-muted/30 px-3 py-2">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-3">
<ConnectionStatus
status={connectionLabel}
onRetry={() => reconnectCollaboration(documentName)}
/>
<PresenceAvatars users={peers} />
</div>
</div>
<EditorToolbar editor={editor} />
<div ref={containerRef} className="relative bg-background dark:bg-background">
<BlockDragHandle editor={editor} containerRef={containerRef} />
<EditorContent editor={editor} className={editorContentStyles} />
{editor ? (
<BubbleMenu
editor={editor}
options={{
placement: "top",
}}
className="flex items-center gap-0.5 rounded-lg border border-border bg-popover p-1 shadow-lg"
>
<BubbleBtn
label="Bold"
shortcut="⌘B"
active={editor.isActive("bold")}
onClick={() => bubbleCh(editor).toggleBold().run()}
>
<Bold className="size-4" />
</BubbleBtn>
<BubbleBtn
label="Italic"
shortcut="⌘I"
active={editor.isActive("italic")}
onClick={() => bubbleCh(editor).toggleItalic().run()}
>
<Italic className="size-4" />
</BubbleBtn>
<BubbleBtn
label="Underline"
shortcut="⌘U"
active={editor.isActive("underline")}
onClick={() => bubbleCh(editor).toggleUnderline().run()}
>
<UnderlineIcon className="size-4" />
</BubbleBtn>
<BubbleBtn
label="Strikethrough"
shortcut="⌘⇧S"
active={editor.isActive("strike")}
onClick={() => bubbleCh(editor).toggleStrike().run()}
>
<Strikethrough className="size-4" />
</BubbleBtn>
<BubbleBtn
label="Code"
shortcut="⌘E"
active={editor.isActive("code")}
onClick={() => bubbleCh(editor).toggleCode().run()}
>
<Code className="size-4" />
</BubbleBtn>
<BubbleBtn
label="Link"
shortcut="⌘K"
active={editor.isActive("link")}
onClick={() => {
const prev = editor.getAttributes("link").href as
| string
| undefined;
const href = window.prompt("URL", prev ?? "https://");
if (href === null) return;
if (href === "") {
bubbleCh(editor).unsetLink().run();
return;
}
bubbleCh(editor).setLink({ href }).run();
}}
>
<Link2 className="size-4" />
</BubbleBtn>
</BubbleMenu>
) : null}
</div>
</div>
<style>{`
.collaboration-cursor__label {
border-radius: 6px 6px 6px 0;
color: #fff;
font-size: 11px;
font-weight: 600;
padding: 3px 8px;
position: absolute;
top: -1.25em;
left: -1px;
white-space: nowrap;
pointer-events: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
}
.collaboration-cursor__caret {
border-left: 2px solid;
border-right: none;
margin-left: -1px;
margin-right: -1px;
position: relative;
word-break: normal;
}
`}</style>
</TooltipProvider>
);
}
function BubbleBtn({
label,
shortcut,
active,
onClick,
children,
}: {
label: string;
shortcut: string;
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"size-8",
active && "bg-primary/15 text-primary hover:bg-primary/20",
)}
onClick={onClick}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{label}
<span className="ml-2 text-xs opacity-70">{shortcut}</span>
</TooltipContent>
</Tooltip>
);
}
/**
* Block editor wired to Yjs + Hocuspocus with live cursors and presence.
* Falls back to {@link BlockEditor} if the collaboration session cannot be established.
*/
export function CollaborativeBlockEditor({
documentId,
userName,
userColor,
...rest
}: CollaborativeBlockEditorProps) {
const documentName = `object:${documentId}`;
const collab = useCollaboration(documentName);
const displayName = userName?.trim() || "Anonymous";
const cursorColor = userColor ?? colorFromUserName(displayName);
if (collab.error) {
return <BlockEditor {...rest} />;
}
if (!collab.doc || !collab.provider) {
return (
<div
className={cn(
"overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-sm",
rest.className,
)}
>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border/80 bg-muted/30 px-3 py-2">
<ConnectionStatus status="connecting" />
</div>
<div className="flex min-h-[280px] items-center justify-center bg-background px-6 text-sm text-muted-foreground">
Preparing collaboration
</div>
</div>
);
}
return (
<CollaborativeEditorShell
collab={
collab as CollaborationConnection & {
doc: NonNullable<CollaborationConnection["doc"]>;
provider: NonNullable<CollaborationConnection["provider"]>;
}
}
displayName={displayName}
cursorColor={cursorColor}
documentName={documentName}
placeholder={rest.placeholder}
onChange={rest.onChange}
editable={rest.editable}
className={rest.className}
/>
);
}

View file

@ -0,0 +1,101 @@
"use client";
import * as React from "react";
import type { Editor } from "@tiptap/react";
import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
type BlockDragHandleProps = {
editor: Editor | null;
containerRef: React.RefObject<HTMLElement | null>;
};
/**
* Visual drag handle on the left of the hovered top-level block.
* Drag-to-reorder is not wired (would require custom NodeViews).
*/
export function BlockDragHandle({ editor, containerRef }: BlockDragHandleProps) {
const [pos, setPos] = React.useState<{
top: number;
left: number;
} | null>(null);
React.useEffect(() => {
if (!editor) return;
const root = editor.view.dom;
const onMove = (e: MouseEvent) => {
const coords = editor.view.posAtCoords({
left: e.clientX,
top: e.clientY,
});
if (!coords) {
setPos(null);
return;
}
const $pos = editor.state.doc.resolve(coords.pos);
for (let d = $pos.depth; d > 0; d--) {
const node = $pos.node(d);
const parent = $pos.node(d - 1);
if (parent.type.name === "doc" && node.isBlock) {
const start = $pos.before(d);
const el = editor.view.nodeDOM(start);
if (el instanceof HTMLElement) {
const cr = el.getBoundingClientRect();
const wrap = containerRef.current?.getBoundingClientRect();
if (wrap) {
setPos({
top: cr.top - wrap.top + cr.height / 2 - 14,
left: -2,
});
} else {
setPos({
top: cr.top + cr.height / 2 - 14,
left: cr.left - 28,
});
}
}
return;
}
}
setPos(null);
};
const onLeave = () => setPos(null);
root.addEventListener("mousemove", onMove);
root.addEventListener("mouseleave", onLeave);
return () => {
root.removeEventListener("mousemove", onMove);
root.removeEventListener("mouseleave", onLeave);
};
}, [editor, containerRef]);
if (!editor || !pos) return null;
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
draggable
onDragStart={(e) => {
e.preventDefault();
}}
className={cn(
"pointer-events-auto absolute z-10 flex size-7 items-center justify-center rounded-md",
"text-muted-foreground transition-colors hover:bg-accent hover:text-primary",
)}
style={{ top: pos.top, left: pos.left }}
aria-label="Drag block (visual only)"
>
<span className="select-none text-base leading-none tracking-tight" aria-hidden>
</span>
</button>
</TooltipTrigger>
<TooltipContent side="left">Drag to reorder (coming soon)</TooltipContent>
</Tooltip>
);
}

View file

@ -0,0 +1,298 @@
"use client";
import * as React from "react";
import { useEditor, EditorContent, type Editor } from "@tiptap/react";
import { BubbleMenu } from "@tiptap/react/menus";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import Image from "@tiptap/extension-image";
import TaskList from "@tiptap/extension-task-list";
import TaskItem from "@tiptap/extension-task-item";
import Highlight from "@tiptap/extension-highlight";
import Link from "@tiptap/extension-link";
import Typography from "@tiptap/extension-typography";
import TextAlign from "@tiptap/extension-text-align";
import HorizontalRule from "@tiptap/extension-horizontal-rule";
import CodeBlock from "@tiptap/extension-code-block";
import { Table, TableRow, TableCell, TableHeader } from "@tiptap/extension-table";
import {
Bold,
Italic,
Underline as UnderlineIcon,
Strikethrough,
Code,
Link2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { SlashCommand } from "./slash-menu";
import { EditorToolbar } from "./toolbar";
import { BlockDragHandle } from "./drag-handle";
import { Callout } from "./extensions/callout";
import { Toggle } from "./extensions/toggle";
import { Mention } from "./extensions/mention";
import { Embed } from "./extensions/embed";
import { Divider } from "./extensions/divider";
import { AiBlock } from "./extensions/ai-block";
function bubbleCh(editor: Editor) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return editor.chain().focus() as any;
}
const editorContentStyles = cn(
"editor-prose min-h-[280px] w-full max-w-none px-14 py-10 text-[15px] leading-7 text-foreground antialiased",
"focus:outline-none [&_.ProseMirror]:outline-none [&_.ProseMirror]:min-h-[240px]",
"[&_.ProseMirror_p.is-empty]:relative [&_.ProseMirror_p.is-empty::before]:pointer-events-none [&_.ProseMirror_p.is-empty::before]:float-left [&_.ProseMirror_p.is-empty::before]:h-0 [&_.ProseMirror_p.is-empty::before]:text-muted-foreground [&_.ProseMirror_p.is-empty::before]:content-[attr(data-placeholder)]",
"[&_.ProseMirror_p]:my-1 [&_.ProseMirror_p]:leading-7",
"[&_.ProseMirror_h1]:mb-3 [&_.ProseMirror_h1]:mt-8 [&_.ProseMirror_h1]:text-3xl [&_.ProseMirror_h1]:font-bold [&_.ProseMirror_h1]:tracking-tight [&_.ProseMirror_h1]:text-foreground",
"[&_.ProseMirror_h2]:mb-2 [&_.ProseMirror_h2]:mt-6 [&_.ProseMirror_h2]:text-2xl [&_.ProseMirror_h2]:font-semibold [&_.ProseMirror_h2]:tracking-tight",
"[&_.ProseMirror_h3]:mb-2 [&_.ProseMirror_h3]:mt-5 [&_.ProseMirror_h3]:text-xl [&_.ProseMirror_h3]:font-semibold",
"[&_.ProseMirror_ul]:my-2 [&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ul]:pl-6",
"[&_.ProseMirror_ol]:my-2 [&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ol]:pl-6",
"[&_.ProseMirror_li]:my-0.5 [&_.ProseMirror_li_p]:my-0",
"[&_.ProseMirror_blockquote]:my-3 [&_.ProseMirror_blockquote]:border-l-4 [&_.ProseMirror_blockquote]:border-teal [&_.ProseMirror_blockquote]:bg-muted/40 [&_.ProseMirror_blockquote]:py-1 [&_.ProseMirror_blockquote]:pl-4 [&_.ProseMirror_blockquote]:pr-2 [&_.ProseMirror_blockquote]:italic [&_.ProseMirror_blockquote]:text-muted-foreground",
"[&_.ProseMirror_pre]:my-4 [&_.ProseMirror_pre]:overflow-x-auto [&_.ProseMirror_pre]:rounded-lg [&_.ProseMirror_pre]:border [&_.ProseMirror_pre]:border-border [&_.ProseMirror_pre]:bg-muted/80 [&_.ProseMirror_pre]:p-4 [&_.ProseMirror_pre]:font-mono [&_.ProseMirror_pre]:text-sm",
"[&_.ProseMirror_code]:rounded-md [&_.ProseMirror_code]:bg-muted [&_.ProseMirror_code]:px-1.5 [&_.ProseMirror_code]:py-0.5 [&_.ProseMirror_code]:font-mono [&_.ProseMirror_code]:text-[0.9em] [&_.ProseMirror_code]:text-foreground",
"[&_.ProseMirror_pre_code]:bg-transparent [&_.ProseMirror_pre_code]:p-0",
"[&_.ProseMirror_hr]:my-6 [&_.ProseMirror_hr]:border-border",
"[&_.ProseMirror_img]:my-4 [&_.ProseMirror_img]:max-h-[480px] [&_.ProseMirror_img]:w-auto [&_.ProseMirror_img]:max-w-full [&_.ProseMirror_img]:rounded-lg [&_.ProseMirror_img]:border [&_.ProseMirror_img]:border-border",
"[&_.ProseMirror_table]:my-4 [&_.ProseMirror_table]:w-full [&_.ProseMirror_table]:border-collapse [&_.ProseMirror_table]:overflow-hidden [&_.ProseMirror_table]:rounded-md [&_.ProseMirror_table]:border [&_.ProseMirror_table]:border-border",
"[&_.ProseMirror_td]:border [&_.ProseMirror_td]:border-border [&_.ProseMirror_td]:bg-background [&_.ProseMirror_td]:px-3 [&_.ProseMirror_td]:py-2",
"[&_.ProseMirror_th]:border [&_.ProseMirror_th]:border-border [&_.ProseMirror_th]:bg-muted [&_.ProseMirror_th]:px-3 [&_.ProseMirror_th]:py-2 [&_.ProseMirror_th]:text-left [&_.ProseMirror_th]:font-medium",
"[&_.ProseMirror_.ProseMirror-selectednode]:ring-2 [&_.ProseMirror_.ProseMirror-selectednode]:ring-primary/40",
"dark:[&_.ProseMirror_blockquote]:bg-muted/20",
);
export type BlockEditorProps = {
content?: string;
onChange?: (html: string) => void;
editable?: boolean;
className?: string;
placeholder?: string;
};
export function BlockEditor({
content,
onChange,
editable = true,
className,
placeholder,
}: BlockEditorProps) {
const containerRef = React.useRef<HTMLDivElement>(null);
const extensions = React.useMemo(
() => [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
codeBlock: false,
horizontalRule: false,
link: {
openOnClick: false,
HTMLAttributes: {
class:
"text-primary underline underline-offset-2 decoration-primary/50",
},
},
}),
Placeholder.configure({
placeholder:
placeholder ??
'Write something, or type "/" for commands…',
}),
CodeBlock.configure({
HTMLAttributes: {
class: "font-mono text-sm",
},
}),
HorizontalRule,
Image.configure({
HTMLAttributes: {
class: "rounded-lg border border-border",
},
}),
TaskList,
TaskItem.configure({
nested: true,
HTMLAttributes: {
class: "flex gap-2",
},
}),
Highlight.configure({
multicolor: true,
}),
Typography,
TextAlign.configure({
types: ["heading", "paragraph"],
}),
Table.configure({
resizable: false,
}),
TableRow,
TableHeader,
TableCell,
SlashCommand,
Callout,
Toggle,
Mention,
Embed,
Divider,
AiBlock,
],
[placeholder],
);
const editor = useEditor(
{
immediatelyRender: false,
extensions,
content: content ?? "<p></p>",
editable,
editorProps: {
attributes: {
class: "focus:outline-none",
},
},
onUpdate: ({ editor: ed }) => {
onChange?.(ed.getHTML());
},
},
[extensions, editable],
);
React.useEffect(() => {
if (!editor) return;
editor.setEditable(editable);
}, [editable, editor]);
return (
<TooltipProvider delayDuration={200}>
<div
className={cn(
"group/editor overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-sm",
className,
)}
>
<EditorToolbar editor={editor} />
<div ref={containerRef} className="relative bg-background dark:bg-background">
<BlockDragHandle editor={editor} containerRef={containerRef} />
<EditorContent editor={editor} className={editorContentStyles} />
{editor ? (
<BubbleMenu
editor={editor}
options={{
placement: "top",
}}
className="flex items-center gap-0.5 rounded-lg border border-border bg-popover p-1 shadow-lg"
>
<BubbleBtn
label="Bold"
shortcut="⌘B"
active={editor.isActive("bold")}
onClick={() => bubbleCh(editor).toggleBold().run()}
>
<Bold className="size-4" />
</BubbleBtn>
<BubbleBtn
label="Italic"
shortcut="⌘I"
active={editor.isActive("italic")}
onClick={() => bubbleCh(editor).toggleItalic().run()}
>
<Italic className="size-4" />
</BubbleBtn>
<BubbleBtn
label="Underline"
shortcut="⌘U"
active={editor.isActive("underline")}
onClick={() => bubbleCh(editor).toggleUnderline().run()}
>
<UnderlineIcon className="size-4" />
</BubbleBtn>
<BubbleBtn
label="Strikethrough"
shortcut="⌘⇧S"
active={editor.isActive("strike")}
onClick={() => bubbleCh(editor).toggleStrike().run()}
>
<Strikethrough className="size-4" />
</BubbleBtn>
<BubbleBtn
label="Code"
shortcut="⌘E"
active={editor.isActive("code")}
onClick={() => bubbleCh(editor).toggleCode().run()}
>
<Code className="size-4" />
</BubbleBtn>
<BubbleBtn
label="Link"
shortcut="⌘K"
active={editor.isActive("link")}
onClick={() => {
const prev = editor.getAttributes("link").href as
| string
| undefined;
const href = window.prompt("URL", prev ?? "https://");
if (href === null) return;
if (href === "") {
bubbleCh(editor).unsetLink().run();
return;
}
bubbleCh(editor).setLink({ href }).run();
}}
>
<Link2 className="size-4" />
</BubbleBtn>
</BubbleMenu>
) : null}
</div>
</div>
</TooltipProvider>
);
}
function BubbleBtn({
label,
shortcut,
active,
onClick,
children,
}: {
label: string;
shortcut: string;
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"size-8",
active && "bg-primary/15 text-primary hover:bg-primary/20",
)}
onClick={onClick}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{label}
<span className="ml-2 text-xs opacity-70">{shortcut}</span>
</TooltipContent>
</Tooltip>
);
}

View file

@ -0,0 +1,462 @@
"use client";
import { Node, mergeAttributes, nodeInputRule } from "@tiptap/react";
import {
NodeViewWrapper,
ReactNodeViewRenderer,
type NodeViewProps,
} from "@tiptap/react";
import { streamText } from "ai";
import {
Check,
Languages,
Loader2,
Minimize2,
RefreshCw,
Sparkles,
TextQuote,
Trash2,
Wand2,
X,
} from "lucide-react";
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import {
createOpenAIClient,
EDITOR_SYSTEM_PROMPT,
expand,
rewrite,
selectOpenAIModel,
summarize,
translate,
} from "../../../../../packages/ai/src";
type AiStatus = "idle" | "loading" | "done" | "error";
function escapeHtml(s: string) {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function plainTextToHtml(text: string) {
const trimmed = text.trim();
if (!trimmed) return "<p></p>";
return trimmed
.split(/\n\n+/)
.map((block) => `<p>${escapeHtml(block).replace(/\n/g, "<br/>")}</p>`)
.join("");
}
async function runEditorGeneration(prompt: string, signal: AbortSignal) {
const client = createOpenAIClient();
if (!client.ok) {
throw new Error(
"OpenAI API key is not configured. Set OPENAI_API_KEY or NEXT_PUBLIC_OPENAI_API_KEY.",
);
}
const model = selectOpenAIModel(client.provider, "gpt-4o-mini");
const result = streamText({
model,
abortSignal: signal,
system: EDITOR_SYSTEM_PROMPT,
prompt: prompt.trim(),
});
let out = "";
for await (const chunk of result.textStream) {
out += chunk;
}
return out;
}
async function runActionPair(
systemPrompt: string,
userPrompt: string,
signal: AbortSignal,
) {
const client = createOpenAIClient();
if (!client.ok) {
throw new Error(
"OpenAI API key is not configured. Set OPENAI_API_KEY or NEXT_PUBLIC_OPENAI_API_KEY.",
);
}
const model = selectOpenAIModel(client.provider, "gpt-4o-mini");
const result = streamText({
model,
abortSignal: signal,
system: systemPrompt,
prompt: userPrompt,
});
let out = "";
for await (const chunk of result.textStream) {
out += chunk;
}
return out;
}
function LoadingDots() {
return (
<span className="inline-flex items-center gap-0.5 px-1" aria-hidden>
{[0, 1, 2].map((i) => (
<span
key={i}
className="inline-block size-1.5 animate-pulse rounded-full bg-primary/70"
style={{ animationDelay: `${i * 120}ms` }}
/>
))}
</span>
);
}
function AiBlockView(props: NodeViewProps) {
const { node, editor, getPos, updateAttributes, deleteNode } = props;
const status = (node.attrs.status as AiStatus) ?? "idle";
const prompt = (node.attrs.prompt as string) ?? "";
const resultHtml = (node.attrs.resultHtml as string) ?? "";
const errorMessage = (node.attrs.errorMessage as string) ?? "";
const inputRef = React.useRef<HTMLInputElement>(null);
const abortRef = React.useRef<AbortController | null>(null);
React.useEffect(() => {
if (status === "idle" && !resultHtml) {
inputRef.current?.focus();
}
}, [status, resultHtml]);
const [selTick, setSelTick] = React.useState(0);
React.useEffect(() => {
const bump = () => setSelTick((n) => n + 1);
editor.on("selectionUpdate", bump);
editor.on("transaction", bump);
return () => {
editor.off("selectionUpdate", bump);
editor.off("transaction", bump);
};
}, [editor]);
const selectionText = React.useMemo(() => {
const { from, to } = editor.state.selection;
if (from === to) return "";
return editor.state.doc.textBetween(from, to, "\n");
// eslint-disable-next-line react-hooks/exhaustive-deps -- sync selection text when editor updates
}, [editor, selTick]);
const stopGeneration = React.useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
}, []);
const runPrompt = React.useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
stopGeneration();
const ac = new AbortController();
abortRef.current = ac;
updateAttributes({
prompt: trimmed,
status: "loading",
resultHtml: "",
errorMessage: "",
});
try {
const out = await runEditorGeneration(trimmed, ac.signal);
updateAttributes({
status: "done",
resultHtml: plainTextToHtml(out),
errorMessage: "",
});
} catch (e) {
if ((e as Error).name === "AbortError") return;
updateAttributes({
status: "error",
errorMessage: (e as Error).message ?? "Something went wrong.",
});
} finally {
abortRef.current = null;
}
},
[stopGeneration, updateAttributes],
);
const runQuick = React.useCallback(
async (kind: "summarize" | "expand" | "simplify" | "translate") => {
const base = selectionText.trim() || prompt.trim();
if (!base) {
updateAttributes({
status: "error",
errorMessage: "Select text in the editor or enter a prompt first.",
});
return;
}
stopGeneration();
const ac = new AbortController();
abortRef.current = ac;
updateAttributes({
status: "loading",
resultHtml: "",
errorMessage: "",
});
try {
let pair;
if (kind === "summarize") pair = summarize(base);
else if (kind === "expand") pair = expand(base);
else if (kind === "simplify") pair = rewrite(base, "concise");
else pair = translate(base, "Spanish");
const out = await runActionPair(
pair.systemPrompt,
pair.userPrompt,
ac.signal,
);
updateAttributes({
status: "done",
prompt: prompt || `[${kind}]`,
resultHtml: plainTextToHtml(out),
errorMessage: "",
});
} catch (e) {
if ((e as Error).name === "AbortError") return;
updateAttributes({
status: "error",
errorMessage: (e as Error).message ?? "Something went wrong.",
});
} finally {
abortRef.current = null;
}
},
[prompt, selectionText, stopGeneration, updateAttributes],
);
const accept = React.useCallback(() => {
const pos = getPos();
if (typeof pos !== "number") return;
const html = resultHtml || "<p></p>";
editor
.chain()
.focus()
.deleteRange({ from: pos, to: pos + node.nodeSize })
.insertContentAt(pos, html)
.run();
}, [editor, getPos, node.nodeSize, resultHtml]);
const discard = React.useCallback(() => {
deleteNode();
}, [deleteNode]);
const regenerate = React.useCallback(() => {
if (prompt.trim()) void runPrompt(prompt);
}, [prompt, runPrompt]);
const onKeyDownInput = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
void runPrompt(inputRef.current?.value ?? "");
}
if (e.key === "Escape") {
e.preventDefault();
discard();
}
};
return (
<NodeViewWrapper
className={cn(
"ai-block my-4 rounded-xl border border-border/80 bg-gradient-to-br from-muted/40 via-background to-muted/30 p-4 shadow-sm ring-1 ring-border/50",
props.selected && "ring-2 ring-primary/35",
)}
data-type="ai-block"
>
<div className="mb-3 flex items-center gap-2">
<span className="flex size-8 items-center justify-center rounded-lg border border-border bg-background shadow-sm">
<Sparkles className="size-4 text-primary" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold tracking-tight">
AI assistant
</span>
<Badge variant="muted" className="text-[10px] uppercase">
/ai
</Badge>
</div>
<p className="text-xs text-muted-foreground">
Ask in natural language, then accept or regenerate.
</p>
</div>
</div>
{status !== "loading" && !(status === "done" && resultHtml) ? (
<div className="space-y-2">
<Input
ref={inputRef}
className="h-11 border-dashed bg-background/80 text-[15px]"
placeholder='e.g. "Write a short PRD for mobile offline mode"'
defaultValue={prompt}
key={prompt}
onKeyDown={onKeyDownInput}
/>
<div className="flex flex-wrap gap-1.5">
<QuickBtn
icon={TextQuote}
label="Summarize"
onClick={() => void runQuick("summarize")}
/>
<QuickBtn
icon={Wand2}
label="Expand"
onClick={() => void runQuick("expand")}
/>
<QuickBtn
icon={Minimize2}
label="Simplify"
onClick={() => void runQuick("simplify")}
/>
<QuickBtn
icon={Languages}
label="Translate"
onClick={() => void runQuick("translate")}
/>
</div>
<p className="text-[11px] text-muted-foreground">
Enter to run · Esc to remove block · Quick actions use selected text
or your prompt
</p>
</div>
) : null}
{status === "loading" ? (
<div className="flex min-h-[52px] items-center gap-2 rounded-lg border border-dashed border-border bg-muted/30 px-3 py-2 text-sm text-muted-foreground">
<Loader2 className="size-4 shrink-0 animate-spin text-primary" />
<span>Generating</span>
<LoadingDots />
</div>
) : null}
{status === "error" && errorMessage ? (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
) : null}
{status === "done" && resultHtml ? (
<div className="space-y-3">
<div
className="editor-prose max-h-[min(360px,50vh)] overflow-y-auto rounded-lg border border-border bg-card px-3 py-2.5 text-[15px] leading-relaxed [&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: resultHtml }}
/>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" onClick={accept}>
<Check className="mr-1.5 size-3.5" />
Accept
</Button>
<Button type="button" size="sm" variant="secondary" onClick={regenerate}>
<RefreshCw className="mr-1.5 size-3.5" />
Regenerate
</Button>
<Button type="button" size="sm" variant="ghost" onClick={discard}>
<Trash2 className="mr-1.5 size-3.5" />
Discard
</Button>
</div>
</div>
) : null}
{status === "idle" ||
status === "loading" ||
status === "error" ? (
<button
type="button"
className="mt-2 flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground"
contentEditable={false}
onClick={discard}
>
<X className="size-3" />
Cancel
</button>
) : null}
</NodeViewWrapper>
);
}
function QuickBtn({
icon: Icon,
label,
onClick,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
}) {
return (
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs"
onClick={onClick}
>
<Icon className="size-3.5 opacity-80" />
{label}
</Button>
);
}
export const AiBlock = Node.create({
name: "aiBlock",
group: "block",
atom: true,
draggable: true,
addAttributes() {
return {
prompt: { default: "" },
status: { default: "idle" },
resultHtml: { default: "" },
errorMessage: { default: "" },
};
},
parseHTML() {
return [{ tag: 'div[data-type="ai-block"]' }];
},
renderHTML({
HTMLAttributes,
}: {
HTMLAttributes: Record<string, unknown>;
}) {
return [
"div",
mergeAttributes(HTMLAttributes, { "data-type": "ai-block" }),
];
},
addNodeView() {
return ReactNodeViewRenderer(AiBlockView);
},
addInputRules() {
return [
nodeInputRule({
find: /(^|\s)\/ai$/,
type: this.type,
getAttributes: () => ({
prompt: "",
status: "idle",
resultHtml: "",
errorMessage: "",
}),
}),
];
},
});

View file

@ -0,0 +1,167 @@
"use client";
import { Node, mergeAttributes } from "@tiptap/react";
import {
NodeViewContent,
NodeViewWrapper,
ReactNodeViewRenderer,
type NodeViewProps,
} from "@tiptap/react";
import {
AlertTriangle,
CheckCircle2,
Info,
XCircle,
} from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
export type CalloutType = "info" | "warning" | "success" | "error";
const TYPE_ICON: Record<
CalloutType,
React.ComponentType<{ className?: string }>
> = {
info: Info,
warning: AlertTriangle,
success: CheckCircle2,
error: XCircle,
};
const TYPE_STYLES: Record<
CalloutType,
{ box: string; icon: string }
> = {
info: {
box: "border border-blue-500/25 bg-blue-500/10 dark:bg-blue-500/15 dark:border-blue-400/30",
icon: "text-blue-600 dark:text-blue-400",
},
warning: {
box: "border border-amber-500/30 bg-amber-500/10 dark:bg-amber-500/15 dark:border-amber-400/35",
icon: "text-amber-700 dark:text-amber-400",
},
success: {
box: "border border-emerald-500/25 bg-emerald-500/10 dark:bg-emerald-500/15 dark:border-emerald-400/30",
icon: "text-emerald-700 dark:text-emerald-400",
},
error: {
box: "border border-red-500/25 bg-red-500/10 dark:bg-red-500/15 dark:border-red-400/30",
icon: "text-red-600 dark:text-red-400",
},
};
const DEFAULT_EMOJI: Record<CalloutType, string> = {
info: "💡",
warning: "⚠️",
success: "✅",
error: "⛔",
};
function CalloutView(props: NodeViewProps) {
const { node, updateAttributes, selected } = props;
const type = (node.attrs.type as CalloutType) ?? "info";
const emoji =
typeof node.attrs.emoji === "string" && node.attrs.emoji.length > 0
? node.attrs.emoji
: DEFAULT_EMOJI[type];
const Icon = TYPE_ICON[type];
const styles = TYPE_STYLES[type];
return (
<NodeViewWrapper
className={cn(
"callout-block my-3 flex gap-3 rounded-lg px-3 py-2.5",
styles.box,
selected && "ring-2 ring-primary/40 ring-offset-2 ring-offset-background",
)}
data-type="callout"
data-callout-type={type}
>
<div className="flex shrink-0 flex-col items-center gap-1 pt-0.5">
<span
className="cursor-pointer select-none text-lg leading-none"
contentEditable={false}
onClick={() => {
const order: CalloutType[] = [
"info",
"warning",
"success",
"error",
];
const next = order[(order.indexOf(type) + 1) % order.length];
updateAttributes({
type: next,
emoji: DEFAULT_EMOJI[next],
});
}}
title="Cycle callout type"
>
{emoji}
</span>
<Icon className={cn("size-4", styles.icon)} aria-hidden />
</div>
<div className="min-w-0 flex-1 [&_.ProseMirror_p]:my-1 [&_.ProseMirror_p:first-child]:mt-0 [&_.ProseMirror_p:last-child]:mb-0">
<NodeViewContent className="callout-content outline-none" />
</div>
</NodeViewWrapper>
);
}
export const Callout = Node.create({
name: "callout",
group: "block",
content: "block+",
defining: true,
addAttributes() {
return {
type: {
default: "info",
parseHTML: (el) =>
(el.getAttribute("data-callout-type") as CalloutType | null) ??
"info",
renderHTML: (attrs) => ({
"data-callout-type": attrs.type ?? "info",
}),
},
emoji: {
default: "",
parseHTML: (el) => el.getAttribute("data-emoji") ?? "",
renderHTML: (attrs) =>
attrs.emoji ? { "data-emoji": attrs.emoji } : {},
},
};
},
parseHTML() {
return [
{
tag: 'div[data-type="callout"]',
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(HTMLAttributes, { "data-type": "callout" }),
0,
];
},
addNodeView() {
return ReactNodeViewRenderer(CalloutView);
},
addKeyboardShortcuts() {
return {
"Mod-Shift-Alt-c": () =>
this.editor.commands.insertContent({
type: this.name,
attrs: { type: "info", emoji: DEFAULT_EMOJI.info },
content: [{ type: "paragraph" }],
}),
};
},
});

View file

@ -0,0 +1,145 @@
"use client";
import { Node, mergeAttributes } from "@tiptap/react";
import { ReactNodeViewRenderer } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { Star } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
import { NodeViewWrapper } from "@tiptap/react";
export type DividerStyle = "solid" | "dashed" | "dotted" | "dots" | "star";
function lineClass(style: DividerStyle) {
switch (style) {
case "solid":
return "h-px bg-border";
case "dashed":
return "h-0 w-full border-t-2 border-dashed border-border";
case "dotted":
return "h-0 w-full border-t-2 border-dotted border-border";
case "dots":
return "hidden";
case "star":
return "h-px w-full bg-border/70";
default:
return "h-px bg-border";
}
}
function DividerView(props: NodeViewProps) {
const { node, updateAttributes, selected } = props;
const style = (node.attrs.style as DividerStyle) ?? "solid";
return (
<NodeViewWrapper
className={cn(
"divider-block group relative my-6 flex min-h-[28px] items-center justify-center py-1",
selected &&
"rounded ring-2 ring-primary/40 ring-offset-2 ring-offset-background",
)}
data-type="divider"
data-divider-style={style}
contentEditable={false}
>
<div
className={cn(
"pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2",
lineClass(style),
)}
aria-hidden
/>
{style === "dots" ? (
<div className="relative flex items-center justify-center gap-2 px-8">
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/45" />
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/45" />
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/45" />
</div>
) : null}
{style === "star" ? (
<span
className="relative z-[1] inline-flex size-8 items-center justify-center rounded-full border border-border bg-background text-primary shadow-sm dark:bg-card"
contentEditable={false}
>
<Star className="size-4 fill-primary/15 text-primary" aria-hidden />
</span>
) : style !== "dots" ? (
<span className="sr-only">Divider</span>
) : null}
<select
aria-label="Divider style"
className={cn(
"absolute right-0 top-1/2 z-10 -translate-y-1/2 cursor-pointer rounded border border-border bg-background px-1 py-0.5 text-[10px] text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100",
"focus:opacity-100",
)}
value={style}
onChange={(e) =>
updateAttributes({ style: e.target.value as DividerStyle })
}
onClick={(e) => e.stopPropagation()}
>
<option value="solid">Solid</option>
<option value="dashed">Dashed</option>
<option value="dotted">Dotted</option>
<option value="dots">Dots</option>
<option value="star">Star</option>
</select>
</NodeViewWrapper>
);
}
export const Divider = Node.create({
name: "divider",
group: "block",
atom: true,
draggable: true,
selectable: true,
addAttributes() {
return {
style: {
default: "solid" as DividerStyle,
parseHTML: (el) =>
(el.getAttribute("data-divider-style") as DividerStyle | null) ??
"solid",
renderHTML: (attrs) => ({
"data-divider-style": attrs.style ?? "solid",
}),
},
};
},
parseHTML() {
return [
{
tag: 'div[data-type="divider"]',
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(HTMLAttributes, {
"data-type": "divider",
role: "separator",
}),
];
},
addNodeView() {
return ReactNodeViewRenderer(DividerView);
},
addKeyboardShortcuts() {
return {
"Mod-Shift-Alt-d": () =>
this.editor.commands.insertContent({
type: this.name,
attrs: { style: "solid" },
}),
};
},
});

View file

@ -0,0 +1,217 @@
"use client";
import { Node, mergeAttributes } from "@tiptap/react";
import {
NodeViewWrapper,
ReactNodeViewRenderer,
type NodeViewProps,
} from "@tiptap/react";
import { ExternalLink, Link2 } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
function mockEmbedMeta(url: string): {
title: string;
description: string;
image: string;
} {
try {
const u = new URL(url);
const host = u.hostname.replace(/^www\./, "");
return {
title: host || "Link preview",
description: `Preview for ${url}`,
image: `https://picsum.photos/seed/${encodeURIComponent(host)}/400/200`,
};
} catch {
return {
title: "Link preview",
description: url,
image: "",
};
}
}
function EmbedView(props: NodeViewProps) {
const { node, updateAttributes, selected } = props;
const url = String(node.attrs.url ?? "");
const title = String(node.attrs.title ?? "");
const description = String(node.attrs.description ?? "");
const image = String(node.attrs.image ?? "");
const [draft, setDraft] = React.useState(url);
React.useEffect(() => {
setDraft(url);
}, [url]);
const applyUrl = React.useCallback(() => {
const next = draft.trim();
if (!next) return;
let normalized = next;
if (!/^https?:\/\//i.test(normalized)) {
normalized = `https://${normalized}`;
}
const meta = mockEmbedMeta(normalized);
updateAttributes({
url: normalized,
title: meta.title,
description: meta.description,
image: meta.image,
});
}, [draft, updateAttributes]);
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
applyUrl();
}
};
if (!url) {
return (
<NodeViewWrapper
className={cn(
"embed-block my-4 rounded-lg border border-dashed border-border bg-muted/30 p-4 dark:bg-muted/15",
selected && "ring-2 ring-primary/40 ring-offset-2 ring-offset-background",
)}
data-type="embed"
>
<div className="flex items-center gap-2 text-muted-foreground">
<Link2 className="size-4 shrink-0" aria-hidden />
<input
className="min-w-0 flex-1 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground outline-none ring-primary/30 focus:ring-2"
placeholder="Paste a URL and press Enter…"
type="url"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={applyUrl}
onKeyDown={onKeyDown}
/>
</div>
</NodeViewWrapper>
);
}
return (
<NodeViewWrapper
className={cn(
"embed-block my-4 overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-sm",
selected && "ring-2 ring-primary/40 ring-offset-2 ring-offset-background",
)}
data-type="embed"
data-url={url}
>
{image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
alt=""
className="h-40 w-full object-cover"
src={image}
/>
) : null}
<div className="flex flex-col gap-1 p-4">
<a
className="inline-flex items-center gap-1.5 text-base font-semibold text-primary hover:underline"
href={url}
rel="noopener noreferrer"
target="_blank"
>
{title || url}
<ExternalLink className="size-3.5 opacity-70" aria-hidden />
</a>
{description ? (
<p className="text-sm leading-relaxed text-muted-foreground">
{description}
</p>
) : null}
<p className="truncate text-xs text-muted-foreground/80">{url}</p>
</div>
</NodeViewWrapper>
);
}
export const Embed = Node.create({
name: "embed",
group: "block",
atom: true,
draggable: true,
selectable: true,
addAttributes() {
return {
url: {
default: "",
parseHTML: (el) => el.getAttribute("data-url") ?? "",
renderHTML: (attrs) =>
attrs.url ? { "data-url": attrs.url } : {},
},
title: {
default: "",
parseHTML: (el) => el.querySelector("a")?.textContent?.trim() ?? "",
renderHTML: () => ({}),
},
description: {
default: "",
parseHTML: (el) =>
el.querySelector("p")?.textContent?.trim() ?? "",
renderHTML: () => ({}),
},
image: {
default: "",
parseHTML: () => "",
renderHTML: () => ({}),
},
};
},
parseHTML() {
return [
{
tag: 'div[data-type="embed"]',
},
];
},
renderHTML({ node, HTMLAttributes }) {
const url = String(node.attrs.url ?? "");
const title = String(node.attrs.title ?? url);
const description = String(node.attrs.description ?? "");
return [
"div",
mergeAttributes(HTMLAttributes, {
"data-type": "embed",
...(url ? { "data-url": url } : {}),
}),
[
"a",
{
href: url || "#",
rel: "noopener noreferrer",
target: "_blank",
},
title,
],
["p", {}, description],
];
},
addNodeView() {
return ReactNodeViewRenderer(EmbedView);
},
addKeyboardShortcuts() {
return {
"Mod-Shift-Alt-e": () =>
this.editor.commands.insertContent({
type: this.name,
attrs: {
url: "",
title: "",
description: "",
image: "",
},
}),
};
},
});

View file

@ -0,0 +1,5 @@
export { Callout } from "./callout";
export { Toggle } from "./toggle";
export { Mention } from "./mention";
export { Embed } from "./embed";
export { Divider } from "./divider";

View file

@ -0,0 +1,275 @@
"use client";
import { Node, mergeAttributes } from "@tiptap/react";
import { PluginKey } from "@tiptap/pm/state";
import Suggestion, { type SuggestionProps } from "@tiptap/suggestion";
import { ReactRenderer } from "@tiptap/react";
import * as React from "react";
import { cn } from "@/lib/utils";
export type MentionEntityType = "user" | "object";
export type MentionItem = {
id: string;
label: string;
type: MentionEntityType;
};
const MOCK_ITEMS: MentionItem[] = [
{ id: "u1", label: "Alice Chen", type: "user" },
{ id: "u2", label: "Jordan Smith", type: "user" },
{ id: "u3", label: "Sam Rivera", type: "user" },
{ id: "o1", label: "Q1 Roadmap", type: "object" },
{ id: "o2", label: "Design System", type: "object" },
{ id: "o3", label: "Customer Onboarding", type: "object" },
];
export const mentionSuggestionPluginKey = new PluginKey("mentionSuggestion");
function filterItems(query: string): MentionItem[] {
const q = query.trim().toLowerCase();
if (!q) return MOCK_ITEMS.slice(0, 8);
return MOCK_ITEMS.filter((item) =>
item.label.toLowerCase().includes(q),
).slice(0, 8);
}
function positionList(el: HTMLElement, props: SuggestionProps<MentionItem>) {
const rect = props.clientRect?.();
if (!rect) return;
el.style.position = "fixed";
el.style.left = `${rect.left}px`;
el.style.top = `${rect.bottom + 8}px`;
el.style.zIndex = "50";
el.style.minWidth = `${Math.max(rect.width, 220)}px`;
}
type ListProps = {
items: MentionItem[];
command: (item: MentionItem) => void;
selectedIndex: number;
};
function MentionList({ items, command, selectedIndex }: ListProps) {
const ref = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const el = ref.current?.children[selectedIndex] as HTMLElement | undefined;
el?.scrollIntoView({ block: "nearest" });
}, [selectedIndex]);
if (items.length === 0) {
return (
<div className="rounded-lg border border-border bg-popover px-3 py-2 text-sm text-muted-foreground shadow-lg">
No matches
</div>
);
}
return (
<div
ref={ref}
className="max-h-[min(280px,50vh)] overflow-auto rounded-lg border border-border bg-popover p-1 shadow-lg"
role="listbox"
>
{items.map((item, index) => (
<button
key={`${item.type}-${item.id}`}
className={cn(
"flex w-full items-center rounded-md px-2 py-1.5 text-left text-sm outline-none transition-colors",
item.type === "user"
? "text-foreground"
: "text-foreground",
index === selectedIndex && "bg-accent",
)}
type="button"
role="option"
aria-selected={index === selectedIndex}
onClick={() => command(item)}
>
<span
className={cn(
"mr-2 rounded-md px-1.5 py-0.5 text-xs font-medium",
item.type === "user"
? "bg-primary/15 text-primary dark:bg-primary/25"
: "bg-teal/15 text-teal dark:bg-teal/25",
)}
>
{item.type === "user" ? "User" : "Object"}
</span>
<span className="truncate">@{item.label}</span>
</button>
))}
</div>
);
}
export const Mention = Node.create({
name: "mention",
group: "inline",
inline: true,
atom: true,
selectable: true,
addAttributes() {
return {
id: {
default: null as string | null,
parseHTML: (el) => el.getAttribute("data-id"),
renderHTML: (attrs) => (attrs.id ? { "data-id": attrs.id } : {}),
},
label: {
default: "",
parseHTML: (el) =>
el.textContent?.replace(/^@/, "").trim() ?? "",
renderHTML: () => ({}),
},
type: {
default: "user" as MentionEntityType,
parseHTML: (el) =>
(el.getAttribute("data-mention-type") as MentionEntityType | null) ??
"user",
renderHTML: (attrs) => ({
"data-mention-type": attrs.type ?? "user",
}),
},
};
},
parseHTML() {
return [
{
tag: 'span[data-type="mention"]',
},
];
},
renderHTML({ node, HTMLAttributes }) {
const label = String(node.attrs.label ?? "");
const isUser = node.attrs.type === "user";
return [
"span",
mergeAttributes(HTMLAttributes, {
"data-type": "mention",
"data-id": node.attrs.id ?? "",
"data-mention-type": node.attrs.type ?? "user",
class: cn(
"mention-chip rounded-md px-1.5 py-0.5 text-sm font-medium",
isUser
? "bg-primary/15 text-primary dark:bg-primary/25"
: "bg-teal/15 text-teal dark:bg-teal/25",
),
}),
`@${label}`,
];
},
renderText({ node }) {
return `@${node.attrs.label ?? ""}`;
},
addProseMirrorPlugins() {
const extensionName = this.name;
let renderer: ReactRenderer | null = null;
let lastProps: SuggestionProps<MentionItem> | null = null;
let selectedIndex = 0;
const updateSelected = (next: number) => {
if (!lastProps || !renderer) return;
const len = lastProps.items.length;
if (len === 0) return;
selectedIndex = ((next % len) + len) % len;
renderer.updateProps({
items: lastProps.items,
command: lastProps.command,
selectedIndex,
});
};
return [
Suggestion<MentionItem, MentionItem>({
editor: this.editor,
pluginKey: mentionSuggestionPluginKey,
char: "@",
allowSpaces: false,
items: ({ query }) => filterItems(query),
command: ({ editor, range, props }) => {
editor
.chain()
.focus()
.insertContentAt(range, [
{
type: extensionName,
attrs: {
id: props.id,
label: props.label,
type: props.type,
},
},
{ type: "text", text: " " },
])
.run();
},
render: () => ({
onStart: (props) => {
lastProps = props;
selectedIndex = 0;
renderer = new ReactRenderer(MentionList, {
editor: props.editor,
props: {
items: props.items,
command: props.command,
selectedIndex: 0,
},
});
renderer.element.style.pointerEvents = "auto";
document.body.appendChild(renderer.element);
positionList(renderer.element, props);
},
onUpdate: (props) => {
lastProps = props;
selectedIndex = Math.min(
selectedIndex,
Math.max(0, props.items.length - 1),
);
renderer?.updateProps({
items: props.items,
command: props.command,
selectedIndex,
});
if (renderer) positionList(renderer.element, props);
},
onExit: () => {
renderer?.destroy();
renderer = null;
lastProps = null;
selectedIndex = 0;
},
onKeyDown: ({ event }) => {
if (event.key === "ArrowDown") {
event.preventDefault();
updateSelected(selectedIndex + 1);
return true;
}
if (event.key === "ArrowUp") {
event.preventDefault();
updateSelected(selectedIndex - 1);
return true;
}
if (event.key === "Enter") {
event.preventDefault();
const item = lastProps?.items[selectedIndex];
if (item && lastProps) {
lastProps.command(item);
}
return true;
}
return false;
},
}),
}),
];
},
});

View file

@ -0,0 +1,134 @@
"use client";
import { Node, mergeAttributes } from "@tiptap/react";
import {
NodeViewContent,
NodeViewWrapper,
ReactNodeViewRenderer,
type NodeViewProps,
} from "@tiptap/react";
import { ChevronDown, ChevronRight } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
function ToggleView(props: NodeViewProps) {
const { node, updateAttributes, selected } = props;
const open = Boolean(node.attrs.open);
const title = typeof node.attrs.title === "string" ? node.attrs.title : "";
const onTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
updateAttributes({ title: e.target.value });
};
return (
<NodeViewWrapper
as="details"
className={cn(
"toggle-block my-2 rounded-lg border border-border bg-card/60 dark:bg-card/40",
selected && "ring-2 ring-primary/40 ring-offset-2 ring-offset-background",
)}
data-type="toggle"
open={open}
onToggle={(e: React.SyntheticEvent<HTMLDetailsElement>) => {
updateAttributes({ open: e.currentTarget.open });
}}
>
<summary
className="flex cursor-pointer list-none items-center gap-2 px-2 py-1.5 text-[15px] font-medium text-foreground marker:hidden [&::-webkit-details-marker]:hidden"
>
<span
className="flex size-6 shrink-0 items-center justify-center text-muted-foreground"
contentEditable={false}
>
{open ? (
<ChevronDown className="size-4" aria-hidden />
) : (
<ChevronRight className="size-4" aria-hidden />
)}
</span>
<input
className="min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
placeholder="Toggle"
type="text"
value={title}
onChange={onTitleChange}
onClick={(e) => e.stopPropagation()}
/>
</summary>
<div
className="toggle-body border-t border-border px-2 py-2 pl-9 [&_.ProseMirror_p]:my-1"
data-toggle-body="true"
>
<NodeViewContent />
</div>
</NodeViewWrapper>
);
}
export const Toggle = Node.create({
name: "toggle",
group: "block",
content: "block+",
defining: true,
addAttributes() {
return {
open: {
default: true,
parseHTML: (el) =>
el instanceof HTMLElement && el.hasAttribute("open"),
renderHTML: (attrs) => (attrs.open ? { open: "" } : {}),
},
title: {
default: "",
parseHTML: (el) => {
if (!(el instanceof HTMLElement)) return "";
const s = el.querySelector(":scope > summary");
return s?.textContent?.trim() ?? "";
},
renderHTML: (attrs) => ({}),
},
};
},
parseHTML() {
return [
{
tag: 'details[data-type="toggle"]',
contentElement: 'div[data-toggle-body="true"]',
},
];
},
renderHTML({ node, HTMLAttributes }) {
return [
"details",
mergeAttributes(HTMLAttributes, {
"data-type": "toggle",
...(node.attrs.open ? { open: "" } : {}),
}),
[
"summary",
{ class: "toggle-summary" },
node.attrs.title ? String(node.attrs.title) : "\u00a0",
],
["div", { "data-toggle-body": "true", class: "toggle-body" }, 0],
];
},
addNodeView() {
return ReactNodeViewRenderer(ToggleView);
},
addKeyboardShortcuts() {
return {
"Mod-Shift-Alt-t": () =>
this.editor.commands.insertContent({
type: this.name,
attrs: { open: true, title: "" },
content: [{ type: "paragraph" }],
}),
};
},
});

View file

@ -0,0 +1,3 @@
export { BlockEditor, type BlockEditorProps } from "./editor";
export { EditorToolbar } from "./toolbar";
export { SlashMenu } from "./slash-menu";

View file

@ -0,0 +1,413 @@
"use client";
import {
Extension,
type Editor,
type Range,
ReactRenderer,
} from "@tiptap/react";
import Suggestion, { type SuggestionKeyDownProps } from "@tiptap/suggestion";
import { PluginKey } from "@tiptap/pm/state";
import * as React from "react";
import {
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
ListTodo,
ImageIcon,
Code2,
Table2,
Minus,
MessageSquareQuote,
ChevronRight,
Sparkles,
Type,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
export const slashCommandPluginKey = new PluginKey("slashCommand");
export type SlashItem = {
title: string;
description: string;
section: string;
icon: React.ComponentType<{ className?: string }>;
command: (opts: { editor: Editor; range: Range }) => void;
};
/** TipTap chain typing does not merge all extension commands on the base Editor type. */
function afterSlashDelete(editor: Editor, range: Range) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return editor.chain().focus().deleteRange(range) as any;
}
function getSlashItems(): SlashItem[] {
return [
{
title: "Paragraph",
description: "Plain text block",
section: "Text",
icon: Type,
command: ({ editor, range }) => {
afterSlashDelete(editor, range).setParagraph().run();
},
},
{
title: "Heading 1",
description: "Large section title",
section: "Text",
icon: Heading1,
command: ({ editor, range }) => {
afterSlashDelete(editor, range).setHeading({ level: 1 }).run();
},
},
{
title: "Heading 2",
description: "Medium section title",
section: "Text",
icon: Heading2,
command: ({ editor, range }) => {
afterSlashDelete(editor, range).setHeading({ level: 2 }).run();
},
},
{
title: "Heading 3",
description: "Small section title",
section: "Text",
icon: Heading3,
command: ({ editor, range }) => {
afterSlashDelete(editor, range).setHeading({ level: 3 }).run();
},
},
{
title: "Bullet List",
description: "Unordered list",
section: "Lists",
icon: List,
command: ({ editor, range }) => {
afterSlashDelete(editor, range).toggleBulletList().run();
},
},
{
title: "Numbered List",
description: "Ordered list",
section: "Lists",
icon: ListOrdered,
command: ({ editor, range }) => {
afterSlashDelete(editor, range).toggleOrderedList().run();
},
},
{
title: "Task List",
description: "Checklist with tasks",
section: "Lists",
icon: ListTodo,
command: ({ editor, range }) => {
afterSlashDelete(editor, range).toggleTaskList().run();
},
},
{
title: "Image",
description: "Embed an image by URL",
section: "Media",
icon: ImageIcon,
command: ({ editor, range }) => {
const url = window.prompt("Image URL");
if (!url) return;
afterSlashDelete(editor, range).setImage({ src: url }).run();
},
},
{
title: "Code Block",
description: "Syntax-highlighted code",
section: "Media",
icon: Code2,
command: ({ editor, range }) => {
afterSlashDelete(editor, range).toggleCodeBlock().run();
},
},
{
title: "Table",
description: "3×3 table with header",
section: "Media",
icon: Table2,
command: ({ editor, range }) => {
afterSlashDelete(editor, range)
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run();
},
},
{
title: "Horizontal Rule",
description: "Divider line",
section: "Media",
icon: Minus,
command: ({ editor, range }) => {
afterSlashDelete(editor, range).setHorizontalRule().run();
},
},
{
title: "Callout",
description: "Highlighted callout block",
section: "Advanced",
icon: Sparkles,
command: ({ editor, range }) => {
afterSlashDelete(editor, range)
.insertContent(
'<blockquote class="notion-callout border-l-4 border-teal pl-4 py-2 my-2 rounded-r-md bg-muted/50"><p>Callout</p></blockquote>',
)
.run();
},
},
{
title: "Toggle",
description: "Collapsible section (placeholder)",
section: "Advanced",
icon: ChevronRight,
command: ({ editor, range }) => {
afterSlashDelete(editor, range)
.insertContent({
type: "heading",
attrs: { level: 3 },
content: [{ type: "text", text: "Toggle heading" }],
})
.run();
},
},
{
title: "Quote",
description: "Blockquote citation",
section: "Advanced",
icon: MessageSquareQuote,
command: ({ editor, range }) => {
afterSlashDelete(editor, range).toggleBlockquote().run();
},
},
];
}
function filterItems(query: string): SlashItem[] {
const q = query.trim().toLowerCase();
const all = getSlashItems();
if (!q) return all;
return all.filter(
(item) =>
item.title.toLowerCase().includes(q) ||
item.description.toLowerCase().includes(q) ||
item.section.toLowerCase().includes(q),
);
}
export type SlashMenuProps = {
items: SlashItem[];
command: (item: SlashItem) => void;
editor: Editor;
};
export type SlashMenuHandle = {
onKeyDown: (props: SuggestionKeyDownProps) => boolean;
};
export const SlashMenu = React.forwardRef<SlashMenuHandle, SlashMenuProps>(
function SlashMenu({ items, command }, ref) {
const [selected, setSelected] = React.useState(0);
React.useEffect(() => {
setSelected(0);
}, [items]);
const grouped = React.useMemo(() => {
const map = new Map<string, SlashItem[]>();
for (const item of items) {
const list = map.get(item.section) ?? [];
list.push(item);
map.set(item.section, list);
}
return map;
}, [items]);
React.useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (items.length === 0) return false;
if (event.key === "ArrowDown") {
event.preventDefault();
setSelected((i) => (i + 1) % Math.max(items.length, 1));
return true;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setSelected((i) =>
i === 0 ? Math.max(items.length - 1, 0) : i - 1,
);
return true;
}
if (event.key === "Enter") {
event.preventDefault();
const item = items[selected];
if (item) command(item);
return true;
}
return false;
},
}));
if (items.length === 0) {
return (
<div className="animate-in fade-in-0 zoom-in-95 duration-150 z-[100] w-72 rounded-lg border border-border bg-popover p-3 text-sm text-muted-foreground shadow-lg">
No matching commands
</div>
);
}
let flatIndex = 0;
return (
<div
className={cn(
"animate-in fade-in-0 zoom-in-95 duration-150 z-[100] w-80 overflow-hidden rounded-lg border border-border bg-popover shadow-lg",
)}
>
<ScrollArea className="max-h-[min(320px,50vh)]">
<div className="p-1.5">
{Array.from(grouped.entries()).map(
([section, sectionItems], sectionIndex, sections) => (
<div key={section}>
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{section}
</div>
<div className="space-y-0.5">
{sectionItems.map((item) => {
const idx = flatIndex++;
const Icon = item.icon;
const isActive = idx === selected;
return (
<Button
key={`${section}-${item.title}`}
type="button"
variant="ghost"
className={cn(
"h-auto w-full justify-start gap-3 px-2 py-2 text-left font-normal",
isActive &&
"bg-primary/10 text-primary dark:bg-primary/20",
)}
onClick={() => command(item)}
onMouseEnter={() => setSelected(idx)}
>
<span
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-muted/60",
isActive && "border-primary/40 bg-primary/5",
)}
>
<Icon className="size-4 text-foreground" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium leading-tight">
{item.title}
</span>
<span className="text-xs text-muted-foreground">
{item.description}
</span>
</span>
</Button>
);
})}
</div>
{sectionIndex < sections.length - 1 ? (
<Separator className="my-2 opacity-50" />
) : null}
</div>
),
)}
</div>
</ScrollArea>
</div>
);
},
);
SlashMenu.displayName = "SlashMenu";
export const SlashCommand = Extension.create({
name: "slashCommand",
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
pluginKey: slashCommandPluginKey,
char: "/",
allowSpaces: true,
startOfLine: false,
command: ({ editor: ed, range, props }) => {
props.command({ editor: ed, range });
},
items: ({ query }) => filterItems(query),
render: () => {
let component: ReactRenderer | null = null;
return {
onStart: (props) => {
component = new ReactRenderer(SlashMenu, {
props: {
...props,
command: (item: SlashItem) => {
item.command({
editor: props.editor,
range: props.range,
});
},
},
editor: props.editor,
});
component.element.style.position = "absolute";
component.element.style.zIndex = "100";
document.body.appendChild(component.element);
updatePosition(props);
},
onUpdate: (props) => {
component?.updateProps({
...props,
command: (item: SlashItem) => {
item.command({
editor: props.editor,
range: props.range,
});
},
});
updatePosition(props);
},
onExit: () => {
component?.destroy();
component = null;
},
onKeyDown: (keyProps) => {
const ref = component?.ref as SlashMenuHandle | null;
if (ref?.onKeyDown) {
return ref.onKeyDown(keyProps);
}
return false;
},
};
function updatePosition(p: {
clientRect?: (() => DOMRect | null) | null;
}) {
if (!component) return;
const rect = p.clientRect?.();
if (!rect) return;
const el = component.element;
el.style.left = `${rect.left}px`;
el.style.top = `${rect.bottom + 6}px`;
}
},
}),
];
},
});

View file

@ -0,0 +1,344 @@
"use client";
import type { ReactNode } from "react";
import type { Editor } from "@tiptap/react";
import {
Bold,
Italic,
Underline,
Strikethrough,
Code,
Highlighter,
ChevronDown,
List,
ListOrdered,
ListTodo,
AlignLeft,
AlignCenter,
AlignRight,
ImageIcon,
Minus,
Table2,
Undo2,
Redo2,
Heading1,
Heading2,
Heading3,
Pilcrow,
SquareCode,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
function ch(editor: Editor) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return editor.chain().focus() as any;
}
type EditorToolbarProps = {
editor: Editor | null;
className?: string;
};
export function EditorToolbar({ editor, className }: EditorToolbarProps) {
if (!editor) return null;
const blockLabel = editor.isActive("heading", { level: 1 })
? "Heading 1"
: editor.isActive("heading", { level: 2 })
? "Heading 2"
: editor.isActive("heading", { level: 3 })
? "Heading 3"
: "Paragraph";
return (
<TooltipProvider delayDuration={200}>
<div
className={cn(
"flex flex-wrap items-center gap-0.5 border-b border-border bg-muted/30 px-2 py-1.5",
className,
)}
>
<div className="flex items-center gap-0.5">
<ToolbarIcon
label="Bold"
shortcut="⌘B"
active={editor.isActive("bold")}
onClick={() => ch(editor).toggleBold().run()}
>
<Bold className="size-4" />
</ToolbarIcon>
<ToolbarIcon
label="Italic"
shortcut="⌘I"
active={editor.isActive("italic")}
onClick={() => ch(editor).toggleItalic().run()}
>
<Italic className="size-4" />
</ToolbarIcon>
<ToolbarIcon
label="Underline"
shortcut="⌘U"
active={editor.isActive("underline")}
onClick={() => ch(editor).toggleUnderline().run()}
>
<Underline className="size-4" />
</ToolbarIcon>
<ToolbarIcon
label="Strikethrough"
shortcut="⌘⇧S"
active={editor.isActive("strike")}
onClick={() => ch(editor).toggleStrike().run()}
>
<Strikethrough className="size-4" />
</ToolbarIcon>
<ToolbarIcon
label="Code"
shortcut="⌘E"
active={editor.isActive("code")}
onClick={() => ch(editor).toggleCode().run()}
>
<Code className="size-4" />
</ToolbarIcon>
<ToolbarIcon
label="Highlight"
shortcut="⌘⇧H"
active={editor.isActive("highlight")}
onClick={() => ch(editor).toggleHighlight().run()}
>
<Highlighter className="size-4" />
</ToolbarIcon>
</div>
<Separator orientation="vertical" className="mx-1 h-7" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
"h-8 gap-1 px-2 text-xs font-normal text-muted-foreground",
editor.isActive("heading") && "text-primary",
)}
>
{blockLabel}
<ChevronDown className="size-3.5 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuItem
onClick={() => ch(editor).setParagraph().run()}
className="gap-2"
>
<Pilcrow className="size-4" />
Paragraph
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => ch(editor).setHeading({ level: 1 }).run()}
className="gap-2"
>
<Heading1 className="size-4" />
Heading 1
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => ch(editor).setHeading({ level: 2 }).run()}
className="gap-2"
>
<Heading2 className="size-4" />
Heading 2
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => ch(editor).setHeading({ level: 3 }).run()}
className="gap-2"
>
<Heading3 className="size-4" />
Heading 3
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Separator orientation="vertical" className="mx-1 h-7" />
<div className="flex items-center gap-0.5">
<ToolbarIcon
label="Bullet list"
shortcut="⌘⇧8"
active={editor.isActive("bulletList")}
onClick={() => ch(editor).toggleBulletList().run()}
>
<List className="size-4" />
</ToolbarIcon>
<ToolbarIcon
label="Ordered list"
shortcut="⌘⇧7"
active={editor.isActive("orderedList")}
onClick={() => ch(editor).toggleOrderedList().run()}
>
<ListOrdered className="size-4" />
</ToolbarIcon>
<ToolbarIcon
label="Task list"
shortcut="⌘⇧9"
active={editor.isActive("taskList")}
onClick={() => ch(editor).toggleTaskList().run()}
>
<ListTodo className="size-4" />
</ToolbarIcon>
</div>
<Separator orientation="vertical" className="mx-1 h-7" />
<div className="flex items-center gap-0.5">
<ToolbarIcon
label="Align left"
shortcut=""
active={
editor.isActive({ textAlign: "left" }) ||
(!editor.isActive({ textAlign: "center" }) &&
!editor.isActive({ textAlign: "right" }))
}
onClick={() => ch(editor).setTextAlign("left").run()}
>
<AlignLeft className="size-4" />
</ToolbarIcon>
<ToolbarIcon
label="Align center"
shortcut=""
active={editor.isActive({ textAlign: "center" })}
onClick={() => ch(editor).setTextAlign("center").run()}
>
<AlignCenter className="size-4" />
</ToolbarIcon>
<ToolbarIcon
label="Align right"
shortcut=""
active={editor.isActive({ textAlign: "right" })}
onClick={() => ch(editor).setTextAlign("right").run()}
>
<AlignRight className="size-4" />
</ToolbarIcon>
</div>
<Separator orientation="vertical" className="mx-1 h-7" />
<div className="flex items-center gap-0.5">
<ToolbarIcon
label="Insert image"
shortcut=""
active={false}
onClick={() => {
const src = window.prompt("Image URL");
if (src) ch(editor).setImage({ src }).run();
}}
>
<ImageIcon className="size-4" />
</ToolbarIcon>
<ToolbarIcon
label="Horizontal rule"
shortcut=""
active={editor.isActive("horizontalRule")}
onClick={() => ch(editor).setHorizontalRule().run()}
>
<Minus className="size-4" />
</ToolbarIcon>
<ToolbarIcon
label="Insert table"
shortcut=""
active={editor.isActive("table")}
onClick={() =>
ch(editor)
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run()
}
>
<Table2 className="size-4" />
</ToolbarIcon>
<ToolbarIcon
label="Code block"
shortcut="⌘⌥C"
active={editor.isActive("codeBlock")}
onClick={() => ch(editor).toggleCodeBlock().run()}
>
<SquareCode className="size-4" />
</ToolbarIcon>
</div>
<Separator orientation="vertical" className="mx-1 h-7" />
<div className="flex items-center gap-0.5">
<ToolbarIcon
label="Undo"
shortcut="⌘Z"
active={false}
onClick={() => ch(editor).undo().run()}
>
<Undo2 className="size-4" />
</ToolbarIcon>
<ToolbarIcon
label="Redo"
shortcut="⌘⇧Z"
active={false}
onClick={() => ch(editor).redo().run()}
>
<Redo2 className="size-4" />
</ToolbarIcon>
</div>
</div>
</TooltipProvider>
);
}
function ToolbarIcon({
label,
shortcut,
active,
onClick,
children,
}: {
label: string;
shortcut: string;
active: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"size-8 shrink-0",
active && "bg-primary/15 text-primary hover:bg-primary/20 hover:text-primary",
)}
onClick={onClick}
aria-pressed={active}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<span>{label}</span>
{shortcut ? (
<span className="ml-2 text-xs opacity-70">{shortcut}</span>
) : null}
</TooltipContent>
</Tooltip>
);
}

View file

@ -0,0 +1,41 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { TooltipProvider } from "@/components/ui/tooltip";
import { RightPanel } from "@/components/panels/right-panel";
import { Sidebar } from "@/components/sidebar/sidebar";
import { CommandPalette } from "@/components/ai/command-palette";
import { SearchDialog } from "@/components/search";
export function AppShell({ children }: { children: ReactNode }) {
const [searchOpen, setSearchOpen] = useState(false);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const isSlash = e.key === "/" || e.code === "Slash";
if (!isSlash || !(e.metaKey || e.ctrlKey)) return;
const t = e.target as HTMLElement | null;
if (t?.closest?.("[data-search-dialog-ignore-shortcut]")) return;
e.preventDefault();
setSearchOpen(true);
};
document.addEventListener("keydown", onKey, true);
return () => document.removeEventListener("keydown", onKey, true);
}, []);
return (
<TooltipProvider delayDuration={300}>
<div className="flex h-[100dvh] w-full overflow-hidden bg-background">
<Sidebar onOpenSearch={() => setSearchOpen(true)} />
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<main className="flex-1 overflow-auto">{children}</main>
</div>
<RightPanel />
</div>
<CommandPalette />
<SearchDialog open={searchOpen} onOpenChange={setSearchOpen} />
</TooltipProvider>
);
}

View file

@ -0,0 +1,35 @@
"use client";
import type { ReactNode } from "react";
import { useEffect } from "react";
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
function formatNameFromSlug(slug: string) {
return slug
.split("-")
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
export function WorkspaceSync({
workspaceSlug,
children,
}: {
workspaceSlug: string;
children: ReactNode;
}) {
const setWorkspace = useWorkspaceStore((s) => s.setWorkspace);
useEffect(() => {
setWorkspace({
id: workspaceSlug,
slug: workspaceSlug,
name: formatNameFromSlug(workspaceSlug),
});
return () => setWorkspace(null);
}, [workspaceSlug, setWorkspace]);
return <>{children}</>;
}

View file

@ -0,0 +1,137 @@
"use client";
import * as React from "react";
import * as Popover from "@radix-ui/react-popover";
import { Check, Search } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar";
import { Input } from "@/components/ui/input";
export interface WorkspaceUser {
id: string;
name: string;
avatarUrl?: string | null;
}
/** Default mock list; override via `users` prop when wiring real data */
export const WORKSPACE_USERS: WorkspaceUser[] = [
{ id: "u1", name: "Alice", avatarUrl: null },
{ id: "u2", name: "Bob", avatarUrl: null },
{ id: "u3", name: "Charlie", avatarUrl: null },
{ id: "u4", name: "Diana", avatarUrl: null },
];
function initials(name: string) {
return name
.split(/\s+/)
.map((p) => p[0])
.join("")
.slice(0, 2)
.toUpperCase();
}
export interface AssigneePickerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
assignedIds: string[];
onToggle: (userId: string) => void;
users?: WorkspaceUser[];
children: React.ReactNode;
side?: "top" | "right" | "bottom" | "left";
align?: "start" | "center" | "end";
}
export function AssigneePicker({
open,
onOpenChange,
assignedIds,
onToggle,
users = WORKSPACE_USERS,
children,
side = "bottom",
align = "start",
}: AssigneePickerProps) {
const [q, setQ] = React.useState("");
const filtered = React.useMemo(() => {
const s = q.trim().toLowerCase();
if (!s) return users;
return users.filter((u) => u.name.toLowerCase().includes(s));
}, [q, users]);
React.useEffect(() => {
if (!open) setQ("");
}, [open]);
return (
<Popover.Root open={open} onOpenChange={onOpenChange}>
<Popover.Trigger asChild>{children}</Popover.Trigger>
<Popover.Portal>
<Popover.Content
side={side}
align={align}
sideOffset={6}
className={cn(
"z-50 w-[min(100vw-2rem,280px)] rounded-lg border border-border bg-popover p-0 shadow-lg outline-none",
"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",
)}
>
<div className="border-b border-border p-2">
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search people…"
className="h-8 border-0 bg-muted/50 pl-8 text-xs focus-visible:ring-1"
/>
</div>
</div>
<div className="max-h-56 overflow-y-auto p-1">
{filtered.length === 0 ? (
<p className="px-2 py-4 text-center text-xs text-muted-foreground">
No matches
</p>
) : (
filtered.map((user) => {
const assigned = assignedIds.includes(user.id);
return (
<button
key={user.id}
type="button"
onClick={() => onToggle(user.id)}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors",
"hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
assigned && "bg-primary/5",
)}
>
<Avatar className="size-7 border border-border">
<AvatarImage src={user.avatarUrl ?? undefined} alt="" />
<AvatarFallback className="text-[10px] font-medium">
{initials(user.name)}
</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1 truncate font-medium text-foreground">
{user.name}
</span>
{assigned ? (
<Check className="size-4 shrink-0 text-primary" aria-hidden />
) : (
<span className="size-4 shrink-0" />
)}
</button>
);
})
)}
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}

View file

@ -0,0 +1,2 @@
export * from "./panel-header";
export * from "./right-panel";

View file

@ -0,0 +1,706 @@
"use client";
import * as React from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@radix-ui/react-tabs";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Check,
CheckSquare,
ChevronDown,
ExternalLink,
FileText,
Folder,
Link2,
Plus,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { api } from "@/lib/trpc";
import {
usePanelStore,
type PanelDetailTab,
} from "@/lib/stores/panel-store";
import { AssigneePicker, WORKSPACE_USERS } from "@/components/panels/assignee-picker";
import {
PropertyEditor,
type PropertyFieldType,
} from "@/components/panels/property-editor";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
const OBJECT_DETAIL_KEY = "object-detail";
type StatusValue = "open" | "in_progress" | "done" | "closed";
const STATUS_OPTIONS: {
value: StatusValue;
label: string;
dot: string;
}[] = [
{ value: "open", label: "Open", dot: "bg-slate-400" },
{ value: "in_progress", label: "In progress", dot: "bg-amber-500" },
{ value: "done", label: "Done", dot: "bg-emerald-500" },
{ value: "closed", label: "Closed", dot: "bg-zinc-400" },
];
const MOCK_OBJECT: ObjectDetailData = {
id: "demo",
title: "Design Landing Page",
type: "task",
status: "in_progress",
description: "Create wireframes and align with brand guidelines before handoff.",
assignees: [
{ user: { id: "u1", name: "Alice", avatarUrl: null } },
],
propertyValues: [
{
id: "pv1",
propertyDefinition: {
name: "Priority",
fieldType: "select",
config: {
options: [
{ label: "High", value: "High" },
{ label: "Medium", value: "Medium" },
{ label: "Low", value: "Low" },
],
},
},
value: "High",
},
{
id: "pv2",
propertyDefinition: {
name: "Due Date",
fieldType: "date",
config: {},
},
value: "2025-04-01",
},
],
children: [
{ id: "child-1", title: "Review copy", type: "task" },
],
relations: [
{ id: "rel-1", title: "Brand guidelines PDF", type: "document" },
{ id: "rel-2", title: "Q2 marketing plan", type: "task" },
],
};
export interface ObjectDetailData {
id: string;
title: string;
type: string;
status: string;
description: string | null;
assignees: {
user: { id?: string; name: string; avatarUrl?: string | null };
}[];
propertyValues: {
id?: string;
propertyDefinition: {
name: string;
fieldType: string;
config?: Record<string, unknown> | null;
};
value: unknown;
}[];
children?: { id: string; title: string; type: string }[];
relations?: { id: string; title: string; type: string }[];
}
function normalizeFieldType(raw: string): PropertyFieldType {
const t = raw.toLowerCase().replace(/[\s-]+/g, "_") as PropertyFieldType;
const allowed: PropertyFieldType[] = [
"text",
"richtext",
"number",
"date",
"select",
"multiselect",
"person",
"relation",
"url",
"file",
"checkbox",
];
return allowed.includes(t) ? t : "text";
}
function typeLabel(type: string) {
const t = type.toLowerCase();
if (t === "task") return "Task";
if (t === "document") return "Document";
return type.charAt(0).toUpperCase() + type.slice(1);
}
function TypeIcon({ type }: { type: string }) {
const t = type.toLowerCase();
if (t === "document") return <FileText className="size-4 text-teal-600 dark:text-teal-400" />;
if (t === "task") return <CheckSquare className="size-4 text-violet-600 dark:text-violet-400" />;
return <Folder className="size-4 text-muted-foreground" />;
}
function initials(name: string) {
return name
.split(/\s+/)
.map((p) => p[0])
.join("")
.slice(0, 2)
.toUpperCase();
}
function useObjectDetailQuery(objectId: string | null) {
const utils = api.useUtils();
return useQuery({
queryKey: [OBJECT_DETAIL_KEY, objectId],
queryFn: async (): Promise<ObjectDetailData | null> => {
if (!objectId) return null;
const fetcher = (
utils as unknown as {
objects?: { getById?: { fetch: (args: { id: string }) => Promise<ObjectDetailData> } };
}
).objects?.getById?.fetch;
if (typeof fetcher === "function") {
return fetcher({ id: objectId });
}
await new Promise((r) => setTimeout(r, 220));
return { ...MOCK_OBJECT, id: objectId };
},
enabled: Boolean(objectId),
});
}
function DetailSkeleton() {
return (
<div className="flex animate-pulse flex-col gap-3 p-3">
<div className="flex gap-2">
<div className="size-8 rounded-md bg-muted" />
<div className="flex flex-1 flex-col gap-2">
<div className="h-3 w-20 rounded bg-muted" />
<div className="h-6 w-full rounded bg-muted" />
</div>
</div>
<div className="h-9 w-full rounded-md bg-muted" />
<div className="h-24 w-full rounded-md bg-muted" />
<div className="h-32 w-full rounded-md bg-muted" />
</div>
);
}
export function ObjectDetail() {
const objectId = usePanelStore((s) => s.objectId);
const activeTab = usePanelStore((s) => s.activeTab);
const setActiveTab = usePanelStore((s) => s.setActiveTab);
const closePanel = usePanelStore((s) => s.close);
const openPanel = usePanelStore((s) => s.open);
const queryClient = useQueryClient();
const utils = api.useUtils();
const { data, isPending, isError, error } = useObjectDetailQuery(objectId);
const [titleDraft, setTitleDraft] = React.useState("");
const [editingTitle, setEditingTitle] = React.useState(false);
const [descriptionDraft, setDescriptionDraft] = React.useState("");
const [assigneeOpen, setAssigneeOpen] = React.useState(false);
React.useEffect(() => {
if (data?.title != null) setTitleDraft(data.title);
}, [data?.title]);
React.useEffect(() => {
if (data?.description != null) setDescriptionDraft(data.description ?? "");
}, [data?.description]);
const mergeObjectCache = React.useCallback(
(id: string, patch: Partial<ObjectDetailData>) => {
queryClient.setQueryData<ObjectDetailData | null>(
[OBJECT_DETAIL_KEY, id],
(old) => (old ? { ...old, ...patch } : old),
);
},
[queryClient],
);
const updateObject = useMutation({
mutationFn: async (patch: Partial<ObjectDetailData> & { id: string }) => {
const u = utils as unknown as {
objects?: { update?: { mutate: (args: unknown) => Promise<unknown> } };
};
if (typeof u.objects?.update?.mutate === "function") {
return u.objects.update.mutate(patch);
}
},
onMutate: async (patch) => {
mergeObjectCache(patch.id, patch);
},
});
const setPropertyValue = useMutation({
mutationFn: async (args: {
objectId: string;
propertyValueId?: string;
propertyDefinitionId?: string;
value: unknown;
}) => {
const u = utils as unknown as {
properties?: { setValue?: { mutate: (a: unknown) => Promise<unknown> } };
};
if (typeof u.properties?.setValue?.mutate === "function") {
return u.properties.setValue.mutate(args);
}
},
});
const handlePropertyChange = (
index: number,
next: unknown,
row: ObjectDetailData["propertyValues"][number],
) => {
if (!data) return;
const nextRows = [...data.propertyValues];
nextRows[index] = { ...row, value: next };
mergeObjectCache(data.id, { propertyValues: nextRows });
setPropertyValue.mutate({
objectId: data.id,
propertyValueId: row.id,
value: next,
});
};
const assignedIds = React.useMemo(() => {
if (!data) return [];
return data.assignees
.map((a) => a.user.id)
.filter((id): id is string => Boolean(id));
}, [data]);
const toggleAssignee = (userId: string) => {
if (!data) return;
const user = WORKSPACE_USERS.find((u) => u.id === userId);
if (!user) return;
const has = assignedIds.includes(userId);
let nextAssignees: ObjectDetailData["assignees"];
if (has) {
nextAssignees = data.assignees.filter((a) => a.user.id !== userId);
} else {
nextAssignees = [
...data.assignees,
{ user: { id: user.id, name: user.name, avatarUrl: user.avatarUrl } },
];
}
mergeObjectCache(data.id, { assignees: nextAssignees });
const u = utils as unknown as {
objects?: { assign?: { mutate: (a: unknown) => Promise<unknown> } };
};
if (typeof u.objects?.assign?.mutate === "function") {
u.objects.assign.mutate({
objectId: data.id,
userId,
assign: !has,
});
}
};
const commitTitle = () => {
if (!data || titleDraft.trim() === data.title) {
setEditingTitle(false);
return;
}
updateObject.mutate({ id: data.id, title: titleDraft.trim() });
setEditingTitle(false);
};
const commitDescription = () => {
if (!data) return;
if (descriptionDraft === (data.description ?? "")) return;
updateObject.mutate({ id: data.id, description: descriptionDraft });
};
const setStatus = (status: StatusValue) => {
if (!data) return;
updateObject.mutate({ id: data.id, status });
};
const onTabChange = (v: string) => {
setActiveTab(v as PanelDetailTab);
};
if (!objectId) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
<p>No object selected.</p>
<Button variant="outline" size="sm" onClick={() => closePanel()}>
Close panel
</Button>
</div>
);
}
if (isPending) {
return <DetailSkeleton />;
}
if (isError || !data) {
return (
<div className="p-4 text-xs text-destructive">
{isError
? error instanceof Error
? error.message
: "Failed to load object."
: "Nothing to display."}
</div>
);
}
const status =
STATUS_OPTIONS.find((s) => s.value === data.status)?.value ??
(data.status as StatusValue);
const statusMeta =
STATUS_OPTIONS.find((s) => s.value === status) ?? STATUS_OPTIONS[0];
return (
<div className="flex h-full min-h-0 flex-1 flex-col bg-card">
<header className="shrink-0 border-b border-border px-3 pb-2 pt-3">
<div className="flex items-start gap-2">
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-muted/40">
<TypeIcon type={data.type} />
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<Badge
variant="secondary"
className="h-5 px-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground"
>
{typeLabel(data.type)}
</Badge>
</div>
{editingTitle ? (
<Input
autoFocus
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commitTitle();
}
if (e.key === "Escape") {
setTitleDraft(data.title);
setEditingTitle(false);
}
}}
className="h-8 text-sm font-semibold"
/>
) : (
<button
type="button"
onClick={() => setEditingTitle(true)}
className="w-full rounded px-0.5 text-left text-sm font-semibold leading-snug text-foreground hover:bg-muted/60"
>
{data.title}
</button>
)}
</div>
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0 text-muted-foreground hover:text-foreground"
onClick={() => closePanel()}
aria-label="Close panel"
>
<X className="size-4" />
</Button>
</div>
<div className="mt-2 flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 border-border px-2 text-xs font-normal"
>
<span
className={cn("size-2 shrink-0 rounded-full", statusMeta.dot)}
/>
{statusMeta.label}
<ChevronDown className="size-3.5 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44">
{STATUS_OPTIONS.map((opt) => (
<DropdownMenuItem
key={opt.value}
className="gap-2 text-xs"
onClick={() => setStatus(opt.value)}
>
<span className={cn("size-2 rounded-full", opt.dot)} />
{opt.label}
{opt.value === status ? (
<Check className="ml-auto size-3.5 text-primary" />
) : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<Tabs
value={activeTab}
onValueChange={onTabChange}
className="flex min-h-0 flex-1 flex-col"
>
<TabsList className="flex shrink-0 gap-0 border-b border-border px-1">
{(
[
["details", "Details"],
["activity", "Activity"],
["comments", "Comments"],
] as const
).map(([value, label]) => (
<TabsTrigger
key={value}
value={value}
className={cn(
"relative flex-1 px-2 py-2 text-center text-[11px] font-semibold uppercase tracking-wide text-muted-foreground transition-colors",
"hover:text-foreground",
"data-[state=active]:text-foreground",
"data-[state=active]:after:absolute data-[state=active]:after:inset-x-2 data-[state=active]:after:bottom-0 data-[state=active]:after:h-0.5 data-[state=active]:after:rounded-full data-[state=active]:after:bg-primary",
)}
>
{label}
</TabsTrigger>
))}
</TabsList>
<TabsContent
value="details"
className="flex min-h-0 flex-1 flex-col overflow-hidden animate-in fade-in-0 duration-200 ease-out data-[state=inactive]:hidden"
>
<ScrollArea className="min-h-0 flex-1">
<div className="flex flex-col gap-0 px-3 pb-6 pt-2">
<section className="py-2">
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Assignees
</h3>
<div className="flex flex-wrap items-center gap-1.5">
{data.assignees.map((a, i) => (
<Avatar
key={`${a.user.id ?? a.user.name}-${i}`}
className="size-7 border border-border"
title={a.user.name}
>
<AvatarImage src={a.user.avatarUrl ?? undefined} alt="" />
<AvatarFallback className="text-[10px]">
{initials(a.user.name)}
</AvatarFallback>
</Avatar>
))}
<AssigneePicker
open={assigneeOpen}
onOpenChange={setAssigneeOpen}
assignedIds={assignedIds}
onToggle={toggleAssignee}
>
<Button
variant="outline"
size="sm"
className="h-7 gap-1 px-2 text-[11px] font-normal"
>
<Plus className="size-3.5" />
Add assignee
</Button>
</AssigneePicker>
</div>
</section>
<Separator className="bg-border/80" />
<section className="py-2">
<div className="mb-2 flex items-center justify-between gap-2">
<h3 className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Properties
</h3>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[10px] font-medium text-primary"
type="button"
>
<Plus className="mr-1 size-3" />
Add property
</Button>
</div>
<div className="rounded-md border border-border/80 bg-muted/20">
{data.propertyValues.length === 0 ? (
<p className="px-3 py-4 text-center text-[11px] text-muted-foreground">
No custom properties yet.
</p>
) : (
data.propertyValues.map((row, index) => (
<div
key={row.id ?? `${row.propertyDefinition.name}-${index}`}
className="border-b border-border/60 px-2 last:border-b-0"
>
<PropertyEditor
definition={{
name: row.propertyDefinition.name,
fieldType: normalizeFieldType(
row.propertyDefinition.fieldType,
),
config: row.propertyDefinition.config as
| { options?: { label: string; value: string }[] }
| undefined,
}}
value={row.value}
onChange={(v) => handlePropertyChange(index, v, row)}
/>
</div>
))
)}
</div>
</section>
<Separator className="bg-border/80" />
<section className="py-2">
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Description
</h3>
<textarea
value={descriptionDraft}
onChange={(e) => setDescriptionDraft(e.target.value)}
onBlur={commitDescription}
rows={4}
placeholder="Add a description…"
className="w-full resize-y rounded-md border border-input bg-background px-2 py-1.5 text-xs leading-relaxed ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
/>
</section>
{data.children && data.children.length > 0 ? (
<>
<Separator className="bg-border/80" />
<section className="py-2">
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Subtasks
</h3>
<ul className="space-y-1">
{data.children.map((c) => (
<li key={c.id}>
<button
type="button"
onClick={() => openPanel("object-detail", c.id)}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted/70"
>
<CheckSquare className="size-3.5 shrink-0 text-violet-500" />
<span className="min-w-0 flex-1 truncate font-medium">
{c.title}
</span>
<Badge
variant="outline"
className="h-5 shrink-0 px-1.5 text-[9px] font-normal uppercase"
>
{typeLabel(c.type)}
</Badge>
</button>
</li>
))}
</ul>
</section>
</>
) : null}
<Separator className="bg-border/80" />
<section className="py-2">
<div className="mb-2 flex items-center justify-between gap-2">
<h3 className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Relations
</h3>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[10px] font-medium text-primary"
type="button"
>
<Link2 className="mr-1 size-3" />
Add relation
</Button>
</div>
{data.relations && data.relations.length > 0 ? (
<ul className="space-y-1">
{data.relations.map((r) => (
<li key={r.id}>
<button
type="button"
onClick={() => openPanel("object-detail", r.id)}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted/70"
>
<ExternalLink className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">
{r.title}
</span>
<Badge
variant="secondary"
className="h-5 shrink-0 px-1.5 text-[9px] font-normal uppercase"
>
{typeLabel(r.type)}
</Badge>
</button>
</li>
))}
</ul>
) : (
<p className="py-2 text-[11px] text-muted-foreground">
No relations yet.
</p>
)}
</section>
</div>
</ScrollArea>
</TabsContent>
<TabsContent
value="activity"
className="min-h-0 flex-1 animate-in fade-in-0 duration-200 ease-out data-[state=inactive]:hidden"
>
<div className="p-4 text-xs text-muted-foreground">
<p className="rounded-md border border-dashed border-border bg-muted/20 px-3 py-6 text-center">
Activity feed will appear here (audit log, updates, mentions).
</p>
</div>
</TabsContent>
<TabsContent
value="comments"
className="min-h-0 flex-1 animate-in fade-in-0 duration-200 ease-out data-[state=inactive]:hidden"
>
<div className="p-4 text-xs text-muted-foreground">
<p className="rounded-md border border-dashed border-border bg-muted/20 px-3 py-6 text-center">
Comments thread coming soon.
</p>
</div>
</TabsContent>
</Tabs>
</div>
);
}

View file

@ -0,0 +1,48 @@
"use client";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface PanelHeaderProps {
title: string;
description?: string;
onClose: () => void;
className?: string;
}
export function PanelHeader({
title,
description,
onClose,
className,
}: PanelHeaderProps) {
return (
<div
className={cn(
"flex shrink-0 items-start justify-between gap-3 border-b border-border px-4 py-3",
className,
)}
>
<div className="min-w-0 flex-1">
<h2 className="truncate text-sm font-semibold leading-tight text-foreground">
{title}
</h2>
{description ? (
<p className="mt-0.5 text-xs text-muted-foreground">{description}</p>
) : null}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0"
onClick={onClose}
aria-label="Close panel"
>
<X className="size-4" />
</Button>
</div>
);
}

View file

@ -0,0 +1,322 @@
"use client";
import * as React from "react";
import {
ExternalLink,
FileIcon,
Link2,
User,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
export type PropertyFieldType =
| "text"
| "richtext"
| "number"
| "date"
| "select"
| "multiselect"
| "person"
| "relation"
| "url"
| "file"
| "checkbox";
export interface PropertyDefinitionInput {
name: string;
fieldType: PropertyFieldType;
config?: {
options?: { label: string; value: string }[];
[key: string]: unknown;
} | null;
}
export interface PropertyEditorProps {
definition: PropertyDefinitionInput;
value: unknown;
onChange: (value: unknown) => void;
className?: string;
}
function initials(name: string) {
return name
.split(/\s+/)
.map((p) => p[0])
.join("")
.slice(0, 2)
.toUpperCase();
}
export function PropertyEditor({
definition,
value,
onChange,
className,
}: PropertyEditorProps) {
const { name, fieldType, config } = definition;
const options = config?.options ?? [];
const row = (control: React.ReactNode) => (
<div
className={cn(
"grid grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)] items-center gap-2 py-1.5",
className,
)}
>
<label className="truncate text-xs font-medium text-muted-foreground">
{name}
</label>
<div className="min-w-0">{control}</div>
</div>
);
switch (fieldType) {
case "text":
return row(
<Input
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
className="h-8 text-xs"
/>,
);
case "richtext":
return row(
<textarea
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
rows={3}
className="flex min-h-[72px] w-full resize-y rounded-md border border-input bg-background px-2 py-1.5 text-xs ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
placeholder="Rich text (TipTap later)"
/>,
);
case "number": {
const n =
value === null || value === undefined || value === ""
? ""
: Number(value);
return row(
<Input
type="number"
value={Number.isFinite(n as number) ? String(n) : ""}
onChange={(e) => {
const v = e.target.value;
onChange(v === "" ? null : Number(v));
}}
className="h-8 text-xs"
/>,
);
}
case "date":
return row(
<Input
type="date"
value={String(value ?? "").slice(0, 10)}
onChange={(e) => onChange(e.target.value || null)}
className="h-8 text-xs"
/>,
);
case "select": {
const current = String(value ?? "");
const label =
options.find((o) => o.value === current)?.label ??
(current || "—");
return row(
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 w-full justify-between px-2 text-xs font-normal"
>
<span className="truncate">{label}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[var(--radix-dropdown-menu-trigger-width)]">
{options.map((opt) => (
<DropdownMenuItem
key={opt.value}
className="text-xs"
onClick={() => onChange(opt.value)}
>
{opt.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>,
);
}
case "multiselect": {
const arr = Array.isArray(value)
? (value as string[])
: value
? [String(value)]
: [];
const toggle = (v: string) => {
if (arr.includes(v)) onChange(arr.filter((x) => x !== v));
else onChange([...arr, v]);
};
return row(
<div className="flex flex-wrap gap-1">
{arr.map((v) => (
<Badge
key={v}
variant="secondary"
className="gap-0.5 pr-1 text-[10px] font-normal"
>
{options.find((o) => o.value === v)?.label ?? v}
<button
type="button"
className="ml-0.5 rounded p-0.5 hover:bg-muted"
onClick={() => toggle(v)}
aria-label={`Remove ${v}`}
>
<X className="size-3" />
</button>
</Badge>
))}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 px-2 text-[10px]">
+ Add
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-48 overflow-y-auto">
{options.map((opt) => (
<DropdownMenuItem
key={opt.value}
className="text-xs"
onClick={() => toggle(opt.value)}
>
{opt.label}
{arr.includes(opt.value) ? " ✓" : ""}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>,
);
}
case "person": {
const raw = value as
| { user?: { name?: string; avatarUrl?: string | null } }
| { name?: string; avatarUrl?: string | null }
| null
| undefined;
const person =
raw && typeof raw === "object" && "user" in raw && raw.user
? raw.user
: (raw as { name?: string; avatarUrl?: string | null } | undefined);
const displayName = person?.name ?? "Unassigned";
return row(
<div className="flex items-center gap-2">
<Avatar className="size-7 border border-border">
<AvatarImage src={person?.avatarUrl ?? undefined} alt="" />
<AvatarFallback className="text-[10px] font-medium">
{displayName === "Unassigned" ? (
<User className="size-3.5 opacity-70" />
) : (
initials(displayName)
)}
</AvatarFallback>
</Avatar>
<span className="truncate text-xs">{displayName}</span>
</div>,
);
}
case "relation": {
const rel = value as
| { id?: string; title?: string }
| string
| null
| undefined;
const title =
typeof rel === "object" && rel && "title" in rel
? String(rel.title)
: typeof rel === "string"
? rel
: "—";
return row(
<Button
variant="link"
className="h-auto min-h-0 justify-start p-0 text-xs text-primary"
type="button"
>
<Link2 className="mr-1 size-3.5 shrink-0" />
<span className="truncate">{title}</span>
</Button>,
);
}
case "url":
return row(
<div className="relative">
<ExternalLink className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
type="url"
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
className="h-8 pl-8 text-xs"
placeholder="https://"
/>
</div>,
);
case "file":
return row(
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<FileIcon className="size-3.5 shrink-0" />
<span className="truncate">{String(value ?? "No file")}</span>
</div>,
);
case "checkbox":
return row(
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={Boolean(value)}
onChange={(e) => onChange(e.target.checked)}
className={cn(
"size-4 rounded border border-input bg-background",
"text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
)}
/>
<span className="text-xs text-muted-foreground">
{Boolean(value) ? "Yes" : "No"}
</span>
</label>,
);
default:
return row(
<Input
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
className="h-8 text-xs"
/>,
);
}
}

View file

@ -0,0 +1,28 @@
"use client";
import { cn } from "@/lib/utils";
import { usePanelStore } from "@/lib/stores/panel-store";
import { ObjectDetail } from "./object-detail";
import { AIChatPanel } from "@/components/ai";
export function RightPanel() {
const isOpen = usePanelStore((s) => s.isOpen);
const content = usePanelStore((s) => s.content);
return (
<div
className={cn(
"flex h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out",
isOpen ? "w-[360px]" : "w-0",
)}
aria-hidden={!isOpen}
>
{isOpen && content ? (
<div className="flex h-full w-[360px] flex-col border-l border-border bg-card text-card-foreground">
{content === "object-detail" && <ObjectDetail />}
{content === "ai-chat" && <AIChatPanel />}
</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,11 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import type { ComponentProps } from "react";
export function ThemeProvider({
children,
...props
}: ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View file

@ -0,0 +1,27 @@
"use client";
import { useState, type ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import superjson from "superjson";
import { api, getBaseUrl } from "@/lib/trpc";
export function TRPCProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
api.createClient({
links: [
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
}),
],
}),
);
return (
<api.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</api.Provider>
);
}

View file

@ -0,0 +1,3 @@
export { SearchDialog } from "./search-dialog";
export { SearchResultRow as SearchResult, highlightText } from "./search-result";
export type { SearchResultItem } from "./search-result";

View file

@ -0,0 +1,470 @@
"use client";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { CornerDownLeft, Search, X } from "lucide-react";
import { useParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { usePanelStore } from "@/lib/stores/panel-store";
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
import {
SearchResultRow,
highlightText,
type SearchResultItem,
} from "./search-result";
const DEBOUNCE_MS = 300;
const MOCK_RESULTS: SearchResultItem[] = [
{
id: "10000000-0000-4000-8000-000000000101",
type: "task",
title: "Design system audit",
status: "in_progress",
parentId: "10000000-0000-4000-8000-000000000002",
workspaceId: null,
descriptionSnippet:
"…align tokens with the new brand palette before the next release.",
parentBreadcrumb: "Project Alpha > Sprint 1",
},
{
id: "10000000-0000-4000-8000-000000000102",
type: "document",
title: "Q1 roadmap",
status: "open",
parentId: "10000000-0000-4000-8000-000000000001",
workspaceId: null,
descriptionSnippet: "Goals, milestones, and risks for the quarter…",
parentBreadcrumb: "Project Alpha",
},
{
id: "10000000-0000-4000-8000-000000000103",
type: "project",
title: "Mobile launch",
status: "open",
parentId: null,
workspaceId: null,
descriptionSnippet: "Cross-functional initiative spanning design and eng…",
parentBreadcrumb: null,
},
{
id: "10000000-0000-4000-8000-000000000104",
type: "whiteboard",
title: "Architecture brainstorm",
status: "open",
parentId: "10000000-0000-4000-8000-000000000001",
workspaceId: null,
descriptionSnippet: "Service diagram and sequence flows for the API layer…",
parentBreadcrumb: "Project Alpha",
},
];
const MOCK_RECENT: SearchResultItem[] = MOCK_RESULTS.slice(0, 3);
const GROUP_ORDER = ["task", "document", "project", "whiteboard", "group", "workspace"] as const;
const GROUP_LABEL: Record<string, string> = {
task: "Tasks",
document: "Documents",
project: "Projects",
whiteboard: "Whiteboards",
group: "Groups",
workspace: "Workspace",
};
function isUuid(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
value,
);
}
function groupKey(type: string): string {
if (type === "group") return "project";
return type;
}
function sortGroupEntries(
map: Map<string, SearchResultItem[]>,
): { key: string; label: string; items: SearchResultItem[] }[] {
const out: { key: string; label: string; items: SearchResultItem[] }[] = [];
for (const k of GROUP_ORDER) {
const items = map.get(k);
if (items?.length) {
out.push({
key: k,
label: GROUP_LABEL[k] ?? k,
items,
});
}
}
for (const [k, items] of map) {
if (!GROUP_ORDER.includes(k as (typeof GROUP_ORDER)[number]) && items.length) {
out.push({
key: k,
label: GROUP_LABEL[k] ?? k,
items,
});
}
}
return out;
}
function groupResults(rows: SearchResultItem[]) {
const map = new Map<string, SearchResultItem[]>();
for (const r of rows) {
const g = groupKey(r.type);
if (!map.has(g)) map.set(g, []);
map.get(g)!.push(r);
}
return sortGroupEntries(map);
}
function useDebouncedValue<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = window.setTimeout(() => setDebounced(value), delay);
return () => window.clearTimeout(t);
}, [value, delay]);
return debounced;
}
function ResultSkeleton() {
return (
<div className="flex h-10 items-center gap-2.5 rounded-lg px-2.5 py-1">
<div className="size-8 shrink-0 animate-pulse rounded-md bg-muted" />
<div className="min-w-0 flex-1 space-y-1.5">
<div className="h-3.5 w-2/3 animate-pulse rounded bg-muted" />
<div className="h-2.5 w-1/2 animate-pulse rounded bg-muted/70" />
</div>
</div>
);
}
export function SearchDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const params = useParams<{ workspaceSlug?: string }>();
const workspace = useWorkspaceStore((s) => s.currentWorkspace);
const openPanel = usePanelStore((s) => s.open);
const workspaceId =
workspace?.id && isUuid(workspace.id) ? workspace.id : undefined;
const [query, setQuery] = useState("");
const debounced = useDebouncedValue(query, DEBOUNCE_MS);
const inputRef = useRef<HTMLInputElement>(null);
const searchEnabled = open && debounced.trim().length > 0;
const searchQuery = api.search.search.useQuery(
{
query: debounced.trim(),
workspaceId,
limit: 20,
},
{
enabled: searchEnabled,
retry: false,
},
);
const recentQuery = api.search.recent.useQuery(
{ workspaceId, limit: 10 },
{
enabled: open,
retry: false,
},
);
const useMockSearch = searchQuery.isError;
const useMockRecent = recentQuery.isError;
const resultRows = useMemo(() => {
if (!searchEnabled) return [];
if (useMockSearch) {
const q = debounced.trim().toLowerCase();
return MOCK_RESULTS.filter(
(m) =>
m.title.toLowerCase().includes(q) ||
(m.descriptionSnippet?.toLowerCase().includes(q) ?? false),
);
}
return searchQuery.data?.results ?? [];
}, [
searchEnabled,
useMockSearch,
debounced,
searchQuery.data?.results,
]);
const recentRows = useMemo(() => {
if (useMockRecent) return MOCK_RECENT;
return recentQuery.data?.results ?? [];
}, [useMockRecent, recentQuery.data?.results]);
const grouped = useMemo(() => groupResults(resultRows), [resultRows]);
const flatList = useMemo(() => {
const list: { group: string; label: string; item: SearchResultItem }[] = [];
for (const g of grouped) {
for (const item of g.items) {
list.push({ group: g.key, label: g.label, item });
}
}
return list;
}, [grouped]);
const emptyQuery = !query.trim();
const showLoading =
searchEnabled &&
(searchQuery.isFetching || (searchQuery.isPending && !useMockSearch));
const keyboardNavItems = useMemo(() => {
if (showLoading) return [];
if (searchEnabled && resultRows.length > 0) {
return flatList.map((f) => f.item);
}
if (emptyQuery && recentRows.length > 0 && !recentQuery.isFetching) {
return recentRows;
}
return [];
}, [
showLoading,
searchEnabled,
resultRows.length,
flatList,
emptyQuery,
recentRows,
recentQuery.isFetching,
]);
const activeIndexById = useMemo(() => {
const m = new Map<string, number>();
keyboardNavItems.forEach((item, i) => m.set(item.id, i));
return m;
}, [keyboardNavItems]);
const [active, setActive] = useState(0);
useEffect(() => {
setActive(0);
}, [query, keyboardNavItems.length, searchEnabled, emptyQuery]);
useEffect(() => {
if (!open) {
setQuery("");
setActive(0);
}
}, [open]);
useEffect(() => {
if (!open) return;
const id = window.requestAnimationFrame(() => inputRef.current?.focus());
return () => window.cancelAnimationFrame(id);
}, [open]);
const selectObject = useCallback(
(obj: SearchResultItem) => {
openPanel("object-detail", obj.id);
onOpenChange(false);
setQuery("");
},
[openPanel, onOpenChange],
);
const onKeyDown = (e: React.KeyboardEvent) => {
const len = keyboardNavItems.length;
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((i) => (len ? (i + 1) % len : 0));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((i) => (len ? (i - 1 + len) % len : 0));
} else if (e.key === "Enter") {
e.preventDefault();
const row = keyboardNavItems[active];
if (row) selectObject(row);
}
};
const showRecentEmpty = emptyQuery && !showLoading;
const showNoResults =
searchEnabled && !showLoading && resultRows.length === 0;
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-[200] bg-background/80 backdrop-blur-sm",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
)}
/>
<DialogPrimitive.Content
data-search-dialog-ignore-shortcut
className={cn(
"fixed left-1/2 top-[8vh] z-[201] w-[min(720px,calc(100vw-1.5rem))] -translate-x-1/2 rounded-xl border border-border bg-popover shadow-2xl outline-none",
"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",
)}
onOpenAutoFocus={(ev) => ev.preventDefault()}
onKeyDown={onKeyDown}
>
<div className="flex items-center gap-2 border-b border-border px-3 py-2.5">
<Search className="size-5 shrink-0 text-muted-foreground" />
<Input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search tasks, docs, projects…"
className="h-10 flex-1 border-0 bg-transparent px-0 text-base shadow-none placeholder:text-muted-foreground/80 focus-visible:ring-0"
autoComplete="off"
aria-label="Search workspace"
/>
{query ? (
<Button
type="button"
size="icon"
variant="ghost"
className="size-8 shrink-0 text-muted-foreground"
onClick={() => setQuery("")}
aria-label="Clear search"
>
<X className="size-4" />
</Button>
) : null}
</div>
<ScrollArea className="max-h-[min(480px,65vh)]">
<div className="px-2 pb-3 pt-1">
{showLoading ? (
<div className="space-y-1 px-1 pt-1">
{Array.from({ length: 6 }).map((_, i) => (
<ResultSkeleton key={i} />
))}
</div>
) : showRecentEmpty ? (
<div className="px-1 pt-1">
<div className="px-2 pb-2 pt-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{recentQuery.isFetching ? "Loading…" : "Recent"}
</div>
{recentQuery.isFetching && !useMockRecent ? (
<div className="space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
<ResultSkeleton key={i} />
))}
</div>
) : (
<div className="space-y-0.5">
{recentRows.map((item) => (
<SearchResultRow
key={item.id}
object={item}
query=""
active={activeIndexById.get(item.id) === active}
onMouseEnter={() =>
setActive(activeIndexById.get(item.id) ?? 0)
}
onClick={() => selectObject(item)}
/>
))}
</div>
)}
<p className="px-2 pt-3 text-[11px] text-muted-foreground">
Tip: press{" "}
<kbd className="rounded border border-border bg-muted px-1 py-0.5 font-mono text-[10px]">
</kbd>
<kbd className="ml-0.5 rounded border border-border bg-muted px-1 py-0.5 font-mono text-[10px]">
/
</kbd>{" "}
anytime to search.
</p>
</div>
) : showNoResults ? (
<div className="px-3 py-10 text-center">
<p className="text-sm font-medium text-foreground">
No results for{" "}
<span className="text-foreground">
{highlightText(debounced.trim(), debounced.trim())}
</span>
</p>
<p className="mt-2 text-sm text-muted-foreground">
Try a shorter keyword, check spelling, or search in another
workspace.
</p>
<ul className="mx-auto mt-4 max-w-sm list-inside list-disc text-left text-xs text-muted-foreground">
<li>Use words from the title or description</li>
<li>Remove filters in the sidebar if any</li>
<li>Browse recent items below when the query is empty</li>
</ul>
</div>
) : (
<div className="space-y-1 px-1 pt-1">
{grouped.map((g) => (
<div key={g.key}>
<div className="px-2 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground first:pt-0">
{g.label}
</div>
<div className="space-y-0.5">
{g.items.map((item) => {
const idx = activeIndexById.get(item.id) ?? -1;
return (
<SearchResultRow
key={item.id}
object={item}
query={debounced.trim()}
active={idx === active}
onMouseEnter={() =>
setActive(idx >= 0 ? idx : 0)
}
onClick={() => selectObject(item)}
/>
);
})}
</div>
</div>
))}
</div>
)}
</div>
</ScrollArea>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border px-3 py-2 text-[11px] text-muted-foreground">
<span className="flex flex-wrap items-center gap-1.5">
<CornerDownLeft className="size-3.5 opacity-70" />
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-mono">
</kbd>
navigate
<span className="opacity-40">·</span>
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-mono">
</kbd>
open
</span>
<span className="opacity-80">
{params?.workspaceSlug ? `/${params.workspaceSlug}` : ""}
</span>
</div>
<DialogPrimitive.Title className="sr-only">Search workspace</DialogPrimitive.Title>
<DialogPrimitive.Description className="sr-only">
Find tasks, documents, projects, and whiteboards. Use arrow keys to
navigate and Enter to open the detail panel.
</DialogPrimitive.Description>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}

View file

@ -0,0 +1,159 @@
"use client";
import type { ReactNode } from "react";
import {
CheckSquare,
FileText,
Folder,
FolderKanban,
LayoutDashboard,
PenLine,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
export type SearchResultItem = {
id: string;
type: string;
title: string;
status: string | null;
parentId: string | null;
workspaceId: string | null;
descriptionSnippet: string | null;
parentBreadcrumb: string | null;
};
const TYPE_STYLES: Record<
string,
{ Icon: typeof CheckSquare; className: string }
> = {
task: {
Icon: CheckSquare,
className:
"text-teal-600 dark:text-teal-400 bg-teal-500/12 border-teal-500/25",
},
document: {
Icon: FileText,
className:
"text-sky-600 dark:text-sky-400 bg-sky-500/12 border-sky-500/25",
},
project: {
Icon: FolderKanban,
className:
"text-amber-600 dark:text-amber-400 bg-amber-500/12 border-amber-500/25",
},
whiteboard: {
Icon: PenLine,
className:
"text-violet-600 dark:text-violet-400 bg-violet-500/12 border-violet-500/25",
},
group: {
Icon: Folder,
className:
"text-orange-600 dark:text-orange-400 bg-orange-500/12 border-orange-500/25",
},
workspace: {
Icon: LayoutDashboard,
className:
"text-emerald-600 dark:text-emerald-400 bg-emerald-500/12 border-emerald-500/25",
},
};
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function highlightText(text: string, query: string): ReactNode {
const q = query.trim();
if (!q) return text;
const re = new RegExp(`(${escapeRegExp(q)})`, "gi");
const parts = text.split(re);
return parts.map((part, i) => {
if (part.toLowerCase() === q.toLowerCase()) {
return (
<mark
key={i}
className="rounded-sm bg-amber-200/90 px-0.5 font-medium text-foreground dark:bg-amber-500/35"
>
{part}
</mark>
);
}
return <span key={i}>{part}</span>;
});
}
function formatStatus(status: string | null): string {
if (!status) return "";
return status.replace(/_/g, " ");
}
export function SearchResultRow({
object,
query,
onClick,
active,
onMouseEnter,
}: {
object: SearchResultItem;
query: string;
onClick: () => void;
active: boolean;
onMouseEnter?: () => void;
}) {
const style = TYPE_STYLES[object.type] ?? {
Icon: Folder,
className:
"text-muted-foreground bg-muted/80 border-border",
};
const Icon = style.Icon;
return (
<button
type="button"
onClick={onClick}
onMouseEnter={onMouseEnter}
className={cn(
"flex h-10 w-full min-w-0 items-center gap-2.5 rounded-lg border border-transparent px-2.5 text-left text-sm transition-colors",
active
? "border-border bg-muted/90 shadow-sm dark:bg-muted/50"
: "hover:bg-muted/60 dark:hover:bg-muted/40",
)}
>
<span
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-md border",
style.className,
)}
>
<Icon className="size-4" />
</span>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium leading-tight text-foreground">
{highlightText(object.title, query)}
</span>
{object.status ? (
<Badge
variant="secondary"
className="h-5 shrink-0 px-1.5 text-[10px] font-medium capitalize"
>
{formatStatus(object.status)}
</Badge>
) : null}
</span>
{object.parentBreadcrumb ? (
<span className="mt-0.5 block truncate text-[11px] text-muted-foreground">
{object.parentBreadcrumb}
</span>
) : null}
{object.descriptionSnippet ? (
<span className="mt-0.5 line-clamp-1 text-[11px] leading-snug text-muted-foreground">
{highlightText(object.descriptionSnippet, query)}
</span>
) : null}
</span>
</button>
);
}

View file

@ -0,0 +1,5 @@
export * from "./sidebar";
export * from "./sidebar-header";
export * from "./sidebar-item";
export * from "./sidebar-nav";
export * from "./sidebar-toggle";

View file

@ -0,0 +1,708 @@
"use client";
import { useCallback, useMemo, type ReactNode } from "react";
import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import {
ChevronRight,
CircleDot,
FileText,
Folder,
FolderOpen,
FolderPlus,
MoreHorizontal,
PenTool,
Plus,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { getBaseUrl } from "@/lib/trpc";
import {
isSidebarNodeExpanded,
isSidebarSectionExpanded,
useSidebarStore,
} from "@/lib/stores/sidebar-store";
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
import { cn } from "@/lib/utils";
/** Matches server `ObjectTreeNode` shape from objects.getTree */
export type TreeNodeData = {
id: string;
title: string;
type: string;
icon: string | null;
parentId: string | null;
childCount: number;
children: TreeNodeData[];
};
export type PartitionedTrees = {
projects: TreeNodeData[];
documents: TreeNodeData[];
whiteboards: TreeNodeData[];
};
function hasDescendantType(node: TreeNodeData, type: string): boolean {
if (node.type === type) return true;
return node.children.some((c) => hasDescendantType(c, type));
}
export function partitionRoots(roots: TreeNodeData[]): PartitionedTrees {
const projects: TreeNodeData[] = [];
const documents: TreeNodeData[] = [];
const whiteboards: TreeNodeData[] = [];
for (const root of roots) {
if (root.type === "project") {
projects.push(root);
continue;
}
if (root.type === "whiteboard") {
whiteboards.push(root);
continue;
}
if (root.type === "document") {
documents.push(root);
continue;
}
if (root.type === "group") {
const hasWb = hasDescendantType(root, "whiteboard");
const hasDoc = hasDescendantType(root, "document");
if (hasWb && !hasDoc) {
whiteboards.push(root);
} else if (hasDoc) {
documents.push(root);
} else {
projects.push(root);
}
continue;
}
projects.push(root);
}
return { projects, documents, whiteboards };
}
const EMPTY_PARTITIONED: PartitionedTrees = {
projects: [],
documents: [],
whiteboards: [],
};
const MOCK_PARTITIONED: PartitionedTrees = {
projects: [
{
id: "10000000-0000-4000-8000-000000000001",
title: "Project Alpha",
type: "project",
icon: null,
parentId: null,
childCount: 2,
children: [
{
id: "10000000-0000-4000-8000-000000000002",
title: "Sprint 1",
type: "group",
icon: null,
parentId: "10000000-0000-4000-8000-000000000001",
childCount: 2,
children: [
{
id: "10000000-0000-4000-8000-000000000003",
title: "Task 1",
type: "task",
icon: null,
parentId: "10000000-0000-4000-8000-000000000002",
childCount: 0,
children: [],
},
{
id: "10000000-0000-4000-8000-000000000004",
title: "Task 2",
type: "task",
icon: null,
parentId: "10000000-0000-4000-8000-000000000002",
childCount: 0,
children: [],
},
],
},
{
id: "10000000-0000-4000-8000-000000000005",
title: "Sprint 2",
type: "group",
icon: null,
parentId: "10000000-0000-4000-8000-000000000001",
childCount: 0,
children: [],
},
],
},
{
id: "10000000-0000-4000-8000-000000000006",
title: "Project Beta",
type: "project",
icon: null,
parentId: null,
childCount: 0,
children: [],
},
],
documents: [
{
id: "20000000-0000-4000-8000-000000000001",
title: "Documents",
type: "group",
icon: null,
parentId: null,
childCount: 2,
children: [
{
id: "20000000-0000-4000-8000-000000000002",
title: "Meeting Notes",
type: "document",
icon: null,
parentId: "20000000-0000-4000-8000-000000000001",
childCount: 0,
children: [],
},
{
id: "20000000-0000-4000-8000-000000000003",
title: "Product Spec",
type: "document",
icon: null,
parentId: "20000000-0000-4000-8000-000000000001",
childCount: 0,
children: [],
},
],
},
],
whiteboards: [
{
id: "30000000-0000-4000-8000-000000000001",
title: "Whiteboards",
type: "group",
icon: null,
parentId: null,
childCount: 1,
children: [
{
id: "30000000-0000-4000-8000-000000000002",
title: "Brainstorm",
type: "whiteboard",
icon: null,
parentId: "30000000-0000-4000-8000-000000000001",
childCount: 0,
children: [],
},
],
},
],
};
function TypeIcon({ type }: { type: string }) {
switch (type) {
case "project":
return <Folder className="size-3.5 shrink-0 text-amber-600/90 dark:text-amber-400/90" />;
case "group":
return <FolderOpen className="size-3.5 shrink-0 text-muted-foreground" />;
case "document":
return <FileText className="size-3.5 shrink-0 text-muted-foreground" />;
case "whiteboard":
return <PenTool className="size-3.5 shrink-0 text-muted-foreground" />;
case "task":
return <CircleDot className="size-3.5 shrink-0 text-muted-foreground" />;
default:
return <Folder className="size-3.5 shrink-0 text-muted-foreground" />;
}
}
async function fetchObjectsTree(workspaceId: string): Promise<{ tree: TreeNodeData[] }> {
const input = encodeURIComponent(JSON.stringify({ json: { workspaceId } }));
const res = await fetch(`${getBaseUrl()}/api/trpc/objects.getTree?input=${input}`, {
credentials: "include",
headers: { Accept: "application/json" },
});
if (!res.ok) {
throw new Error(`getTree failed: ${res.status}`);
}
const payload = (await res.json()) as unknown;
const tree = extractTreeFromTrpcPayload(payload);
if (!tree) {
throw new Error("getTree: unexpected response shape");
}
return { tree };
}
function extractTreeFromTrpcPayload(payload: unknown): TreeNodeData[] | null {
if (Array.isArray(payload)) {
const first = payload[0] as { result?: { data?: { json?: { tree?: TreeNodeData[] } } } };
return first?.result?.data?.json?.tree ?? null;
}
const single = payload as { result?: { data?: { json?: { tree?: TreeNodeData[] } } } };
return single.result?.data?.json?.tree ?? null;
}
function useObjectsTreeQuery(workspaceId: string | undefined) {
return useQuery({
queryKey: ["objects", "getTree", workspaceId],
queryFn: () => fetchObjectsTree(workspaceId!),
enabled: Boolean(workspaceId),
retry: false,
});
}
function countNodes(roots: TreeNodeData[]): number {
let n = 0;
const walk = (nodes: TreeNodeData[]) => {
for (const node of nodes) {
n += 1;
walk(node.children);
}
};
walk(roots);
return n;
}
function CollapsibleBody({
open,
children,
}: {
open: boolean;
children: ReactNode;
}) {
return (
<div
className={cn(
"grid transition-[grid-template-rows] duration-200 ease-out",
open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="overflow-hidden">{children}</div>
</div>
);
}
export function TreeNode({
node,
level,
collapsed,
base,
pathname,
}: {
node: TreeNodeData;
level: number;
collapsed: boolean;
base: string;
pathname: string | null;
}) {
const expandedNodes = useSidebarStore((s) => s.expandedNodes);
const toggleNode = useSidebarStore((s) => s.toggleNode);
const hasChildren = node.children.length > 0;
const expanded = isSidebarNodeExpanded(expandedNodes, node.id);
const href = `${base}/o/${node.id}`;
const active =
pathname === href ||
(pathname?.startsWith(`${base}/o/${node.id}/`) ?? false);
const onToggleExpand = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
toggleNode(node.id);
},
[node.id, toggleNode],
);
if (collapsed) {
const link = (
<Link
href={href}
className={cn(
"flex h-8 w-full items-center justify-center rounded-md text-sidebar-foreground transition-colors duration-150",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
active &&
"bg-sidebar-accent text-sidebar-accent-foreground shadow-[inset_3px_0_0_0_hsl(var(--primary))]",
)}
>
<TypeIcon type={node.type} />
</Link>
);
return (
<div className="w-full">
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>{link}</TooltipTrigger>
<TooltipContent side="right" className="max-w-[240px] font-medium">
{node.title}
</TooltipContent>
</Tooltip>
{hasChildren ? (
<div className="flex flex-col gap-px border-l border-sidebar-border/60 pl-1">
{node.children.map((ch) => (
<TreeNode
key={ch.id}
node={ch}
level={level + 1}
collapsed={collapsed}
base={base}
pathname={pathname}
/>
))}
</div>
) : null}
</div>
);
}
const indentPx = 8 + level * 16;
return (
<div className="select-none">
<div
className="group relative flex min-h-7 items-center gap-0.5 rounded-md pr-1 transition-colors duration-150"
style={{ paddingLeft: indentPx }}
>
<div className="flex min-h-7 min-w-0 flex-1 items-center gap-0.5">
{hasChildren ? (
<button
type="button"
onClick={onToggleExpand}
className="flex size-6 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
aria-expanded={expanded}
aria-label={expanded ? "Collapse" : "Expand"}
>
<ChevronRight
className={cn(
"size-3.5 transition-transform duration-200",
expanded && "rotate-90",
)}
/>
</button>
) : (
<span className="size-6 shrink-0" aria-hidden />
)}
<Link
href={href}
className={cn(
"flex min-h-7 min-w-0 flex-1 items-center gap-2 rounded-sm py-1 pl-0.5 pr-2 text-sm text-sidebar-foreground transition-colors duration-150",
"hover:bg-sidebar-accent/80 hover:text-sidebar-accent-foreground",
active &&
"bg-sidebar-accent text-sidebar-accent-foreground shadow-[inset_3px_0_0_0_hsl(var(--primary))]",
)}
>
<TypeIcon type={node.type} />
<span className="min-w-0 flex-1 truncate font-medium">{node.title}</span>
{node.childCount > 0 ? (
<span className="shrink-0 tabular-nums text-[10px] text-muted-foreground">
{node.childCount}
</span>
) : null}
</Link>
</div>
<div
className={cn(
"pointer-events-none flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity duration-150",
"group-hover:pointer-events-auto group-hover:opacity-100",
)}
>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-sidebar-accent-foreground"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Plus className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Add child</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-sidebar-accent-foreground"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<MoreHorizontal className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem>Rename</DropdownMenuItem>
<DropdownMenuItem>Duplicate</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive">Archive</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{hasChildren ? (
<CollapsibleBody open={expanded}>
<div className="flex flex-col gap-px pb-0.5">
{node.children.map((ch) => (
<TreeNode
key={ch.id}
node={ch}
level={level + 1}
collapsed={collapsed}
base={base}
pathname={pathname}
/>
))}
</div>
</CollapsibleBody>
) : null}
</div>
);
}
function SectionHeader({
title,
sectionKey,
collapsed,
}: {
title: string;
sectionKey: string;
collapsed: boolean;
}) {
const expandedSections = useSidebarStore((s) => s.expandedSections);
const toggleSection = useSidebarStore((s) => s.toggleSection);
const open = isSidebarSectionExpanded(expandedSections, sectionKey);
if (collapsed) {
return (
<div className="flex justify-center py-1">
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 text-muted-foreground hover:bg-sidebar-accent"
onClick={() => toggleSection(sectionKey)}
>
<ChevronRight
className={cn("size-3.5 transition-transform", open && "rotate-90")}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">{title}</TooltipContent>
</Tooltip>
</div>
);
}
return (
<Button
type="button"
variant="ghost"
className="mb-0.5 flex h-7 w-full items-center justify-between rounded-md px-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
onClick={() => toggleSection(sectionKey)}
>
<span>{title}</span>
<ChevronRight
className={cn("size-3.5 shrink-0 transition-transform duration-200", open && "rotate-90")}
/>
</Button>
);
}
export function NavTree({
collapsed,
trees: treesProp,
}: {
collapsed: boolean;
trees?: PartitionedTrees;
}) {
const pathname = usePathname();
const params = useParams();
const workspace = useWorkspaceStore((s) => s.currentWorkspace);
const slugParam =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : "";
const base = workspace?.slug
? `/${workspace.slug}`
: slugParam
? `/${slugParam}`
: "";
const workspaceId = workspace?.id;
const { data, isLoading, isError } = useObjectsTreeQuery(workspaceId);
const partitioned = useMemo(() => {
if (treesProp) return treesProp;
if (!workspaceId) return MOCK_PARTITIONED;
if (isLoading) return EMPTY_PARTITIONED;
if (isError || !data?.tree) return MOCK_PARTITIONED;
return partitionRoots(data.tree);
}, [treesProp, workspaceId, isLoading, isError, data?.tree]);
const totalCount =
countNodes(partitioned.projects) +
countNodes(partitioned.documents) +
countNodes(partitioned.whiteboards);
const liveEmpty =
Boolean(workspaceId) &&
!isLoading &&
!isError &&
data?.tree &&
data.tree.length === 0;
const showLoading = Boolean(workspaceId) && isLoading && !treesProp;
const expandedSections = useSidebarStore((s) => s.expandedSections);
const projectsOpen = isSidebarSectionExpanded(expandedSections, "projects");
const documentsOpen = isSidebarSectionExpanded(expandedSections, "documents");
const whiteboardsOpen = isSidebarSectionExpanded(expandedSections, "whiteboards");
if (showLoading) {
return (
<ScrollArea className="flex-1">
<div className="flex flex-col gap-2 px-3 pb-4 pt-2">
<div className="h-7 animate-pulse rounded-md bg-sidebar-accent/50" />
<div className="h-7 animate-pulse rounded-md bg-sidebar-accent/40" />
<div className="h-7 animate-pulse rounded-md bg-sidebar-accent/30" />
<div className="h-7 animate-pulse rounded-md bg-sidebar-accent/25" />
</div>
</ScrollArea>
);
}
return (
<ScrollArea className="flex-1">
<div className="flex flex-col gap-0.5 px-2 pb-4 pt-1">
{liveEmpty ? (
<div className="rounded-md border border-dashed border-sidebar-border px-3 py-6 text-center text-xs text-muted-foreground">
No projects, documents, or whiteboards yet.
<br />
<span className="text-[10px]">Create a project to get started.</span>
</div>
) : null}
{!liveEmpty && totalCount === 0 ? (
<div className="rounded-md border border-dashed border-sidebar-border px-3 py-6 text-center text-xs text-muted-foreground">
Nothing to show yet.
</div>
) : null}
{!liveEmpty && totalCount > 0 ? (
<>
<div>
<SectionHeader title="Projects" sectionKey="projects" collapsed={collapsed} />
<CollapsibleBody open={projectsOpen || collapsed}>
<div className={cn("flex flex-col gap-px", collapsed && "items-center")}>
{partitioned.projects.map((node) => (
<TreeNode
key={node.id}
node={node}
level={0}
collapsed={collapsed}
base={base}
pathname={pathname}
/>
))}
</div>
{!collapsed ? (
<Button
type="button"
variant="ghost"
className="mt-1 h-7 w-full justify-start gap-2 px-2 text-xs font-medium text-muted-foreground hover:text-sidebar-accent-foreground"
onClick={() => {
/* api.objects.create — wire when AppRouter includes objects */
}}
>
<FolderPlus className="size-3.5" />
New Project
</Button>
) : (
<div className="mt-1 flex justify-center">
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 text-muted-foreground"
onClick={() => {}}
>
<FolderPlus className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">New Project</TooltipContent>
</Tooltip>
</div>
)}
</CollapsibleBody>
</div>
<div>
<SectionHeader title="Documents" sectionKey="documents" collapsed={collapsed} />
<CollapsibleBody open={documentsOpen || collapsed}>
<div className={cn("flex flex-col gap-px", collapsed && "items-center")}>
{partitioned.documents.map((node) => (
<TreeNode
key={node.id}
node={node}
level={0}
collapsed={collapsed}
base={base}
pathname={pathname}
/>
))}
</div>
</CollapsibleBody>
</div>
<div>
<SectionHeader title="Whiteboards" sectionKey="whiteboards" collapsed={collapsed} />
<CollapsibleBody open={whiteboardsOpen || collapsed}>
<div className={cn("flex flex-col gap-px", collapsed && "items-center")}>
{partitioned.whiteboards.map((node) => (
<TreeNode
key={node.id}
node={node}
level={0}
collapsed={collapsed}
base={base}
pathname={pathname}
/>
))}
</div>
</CollapsibleBody>
</div>
</>
) : null}
</div>
</ScrollArea>
);
}

View file

@ -0,0 +1,126 @@
"use client";
import { ChevronDown, LogOut, Settings2, User } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
import { cn } from "@/lib/utils";
export function SidebarHeader({ collapsed }: { collapsed: boolean }) {
const workspace = useWorkspaceStore((s) => s.currentWorkspace);
const title = workspace?.name ?? "Workspace";
const trigger = (
<Button
type="button"
variant="ghost"
className={cn(
"h-auto min-h-10 w-full justify-between gap-1 rounded-md px-2 py-1.5 text-left font-semibold text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
collapsed && "justify-center px-0",
)}
>
{!collapsed ? (
<>
<span className="min-w-0 flex-1 truncate text-sm">{title}</span>
<ChevronDown className="size-4 shrink-0 opacity-60" />
</>
) : (
<span className="flex size-8 items-center justify-center rounded-md bg-sidebar-accent/80 text-xs font-bold text-primary">
{title.slice(0, 2).toUpperCase()}
</span>
)}
</Button>
);
return (
<div
className={cn(
"flex shrink-0 items-center gap-1 border-b border-sidebar-border px-2 py-2",
collapsed && "flex-col px-1",
)}
>
<DropdownMenu>
{collapsed ? (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="right">{title}</TooltipContent>
</Tooltip>
) : (
<DropdownMenuTrigger asChild className="min-w-0 flex-1">
{trigger}
</DropdownMenuTrigger>
)}
<DropdownMenuContent className="w-56" align="start" side="bottom">
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{title}</p>
<p className="text-xs leading-none text-muted-foreground">
{workspace?.slug ? `/${workspace.slug}` : ""}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Settings2 className="size-4" />
Workspace settings
</DropdownMenuItem>
<DropdownMenuItem>
<User className="size-4" />
Profile
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive">
<LogOut className="size-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0 rounded-full text-sidebar-foreground hover:bg-sidebar-accent"
aria-label="Account menu"
>
<Avatar className="size-8 border border-sidebar-border">
<AvatarFallback className="bg-primary/20 text-xs font-semibold text-primary">
ME
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem>
<User className="size-4" />
Account
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive">
<LogOut className="size-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View file

@ -0,0 +1,84 @@
"use client";
import type { MouseEventHandler, ReactNode } from "react";
import Link from "next/link";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export interface SidebarItemProps {
href: string;
icon: ReactNode;
label: string;
active?: boolean;
collapsed?: boolean;
badge?: number;
dotColor?: string;
onClick?: MouseEventHandler<HTMLAnchorElement>;
}
export function SidebarItem({
href,
icon,
label,
active,
collapsed,
badge,
dotColor,
onClick,
}: SidebarItemProps) {
const inner = (
<Link
href={href}
onClick={onClick}
className={cn(
"group flex min-h-8 w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-sidebar-foreground transition-colors duration-150",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
collapsed && "justify-center gap-0 px-0",
active &&
"bg-sidebar-accent text-sidebar-accent-foreground shadow-[inset_3px_0_0_0_hsl(var(--primary))]",
)}
>
<span className="flex shrink-0 items-center justify-center text-muted-foreground group-hover:text-sidebar-accent-foreground [&_svg]:size-[18px]">
{icon}
</span>
{dotColor && !collapsed ? (
<span
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: dotColor }}
aria-hidden
/>
) : null}
<span
className={cn(
"min-w-0 flex-1 truncate text-left font-medium",
collapsed && "sr-only",
)}
>
{label}
</span>
{badge != null && badge > 0 && !collapsed ? (
<span className="rounded-full bg-primary/15 px-1.5 py-0 text-[10px] font-semibold tabular-nums text-primary">
{badge > 99 ? "99+" : badge}
</span>
) : null}
</Link>
);
if (collapsed) {
return (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>{inner}</TooltipTrigger>
<TooltipContent side="right" className="font-medium">
{label}
</TooltipContent>
</Tooltip>
);
}
return inner;
}

View file

@ -0,0 +1,227 @@
"use client";
import { useMemo, useState } from "react";
import {
ChevronDown,
FileText,
FolderKanban,
Home,
LayoutGrid,
Presentation,
Search,
Settings,
Star,
} from "lucide-react";
import { useParams, usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
import { SidebarItem } from "./sidebar-item";
const PROJECT_DOTS = [
"hsl(var(--primary))",
"hsl(var(--teal))",
"hsl(38 92% 50%)",
"hsl(199 89% 48%)",
];
function NavSectionLabel({
children,
collapsed,
}: {
children: React.ReactNode;
collapsed?: boolean;
}) {
if (collapsed) return null;
return (
<div className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground first:pt-1">
{children}
</div>
);
}
export function SidebarNav({
collapsed,
onOpenSearch,
}: {
collapsed: boolean;
onOpenSearch?: () => void;
}) {
const pathname = usePathname();
const params = useParams();
const workspace = useWorkspaceStore((s) => s.currentWorkspace);
const slugParam =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : "";
const base = workspace?.slug
? `/${workspace.slug}`
: slugParam
? `/${slugParam}`
: "";
const [favoritesOpen, setFavoritesOpen] = useState(true);
const [projectsOpen, setProjectsOpen] = useState(true);
const homeActive = pathname === base || pathname === `${base}/`;
const favoriteItems = useMemo(
() => [
{ label: "Q1 Launch", href: `${base}/favorites/q1` },
{ label: "Design system", href: `${base}/favorites/design` },
],
[base],
);
const projectItems = useMemo(
() => [
{ label: "Product roadmap", slug: "product-roadmap" },
{ label: "Marketing", slug: "marketing" },
{ label: "Engineering", slug: "engineering" },
{ label: "Operations", slug: "operations" },
],
[base],
);
return (
<ScrollArea className="flex-1">
<nav className="flex flex-col gap-0.5 px-2 pb-4 pt-1">
<SidebarItem
href={onOpenSearch ? "#" : `${base}/search`}
icon={<Search />}
label="Search"
collapsed={collapsed}
active={pathname.startsWith(`${base}/search`)}
onClick={
onOpenSearch
? (e) => {
e.preventDefault();
onOpenSearch();
}
: undefined
}
/>
<Separator className="my-2 bg-sidebar-border" />
<SidebarItem
href={base || "/"}
icon={<Home />}
label="Home"
collapsed={collapsed}
active={homeActive}
/>
<div>
<Button
type="button"
variant="ghost"
className={cn(
"mb-0.5 flex h-8 w-full items-center justify-between rounded-md px-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
collapsed && "justify-center px-0",
)}
onClick={() => setFavoritesOpen((o) => !o)}
>
{!collapsed ? (
<>
<span className="flex items-center gap-1.5">
<Star className="size-3.5" />
Favorites
</span>
<ChevronDown
className={cn(
"size-3.5 shrink-0 transition-transform duration-200",
!favoritesOpen && "-rotate-90",
)}
/>
</>
) : (
<Star className="size-4 text-muted-foreground" />
)}
</Button>
{favoritesOpen || collapsed
? favoriteItems.map((fav) => (
<SidebarItem
key={fav.href}
href={fav.href}
icon={<Star className="size-[15px]" />}
label={fav.label}
collapsed={collapsed}
active={pathname === fav.href}
/>
))
: null}
</div>
<div>
<Button
type="button"
variant="ghost"
className={cn(
"mb-0.5 flex h-8 w-full items-center justify-between rounded-md px-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
collapsed && "justify-center px-0",
)}
onClick={() => setProjectsOpen((o) => !o)}
>
{!collapsed ? (
<>
<span className="flex items-center gap-1.5">
<FolderKanban className="size-3.5" />
Projects
</span>
<ChevronDown
className={cn(
"size-3.5 shrink-0 transition-transform duration-200",
!projectsOpen && "-rotate-90",
)}
/>
</>
) : (
<FolderKanban className="size-4 text-muted-foreground" />
)}
</Button>
{projectsOpen || collapsed
? projectItems.map((p, i) => (
<SidebarItem
key={p.slug}
href={`${base}/projects/${p.slug}`}
icon={<LayoutGrid className="size-[15px]" />}
label={p.label}
collapsed={collapsed}
active={pathname === `${base}/projects/${p.slug}`}
dotColor={PROJECT_DOTS[i % PROJECT_DOTS.length]}
/>
))
: null}
</div>
<NavSectionLabel collapsed={collapsed}>Content</NavSectionLabel>
<SidebarItem
href={`${base}/documents`}
icon={<FileText />}
label="Documents"
collapsed={collapsed}
active={pathname.startsWith(`${base}/documents`)}
/>
<SidebarItem
href={`${base}/whiteboards`}
icon={<Presentation />}
label="Whiteboards"
collapsed={collapsed}
active={pathname.startsWith(`${base}/whiteboards`)}
/>
<NavSectionLabel collapsed={collapsed}>Workspace</NavSectionLabel>
<SidebarItem
href={`${base}/settings`}
icon={<Settings />}
label="Settings"
collapsed={collapsed}
active={pathname.startsWith(`${base}/settings`)}
/>
</nav>
</ScrollArea>
);
}

View file

@ -0,0 +1,43 @@
"use client";
import { PanelLeftClose, PanelLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useSidebarStore } from "@/lib/stores/sidebar-store";
import { cn } from "@/lib/utils";
export function SidebarToggle({ className }: { className?: string }) {
const { isCollapsed, toggle } = useSidebarStore();
return (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"size-8 shrink-0 text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
className,
)}
onClick={toggle}
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{isCollapsed ? (
<PanelLeft className="size-4" />
) : (
<PanelLeftClose className="size-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
</TooltipContent>
</Tooltip>
);
}

View file

@ -0,0 +1,29 @@
"use client";
import { cn } from "@/lib/utils";
import { useSidebarStore } from "@/lib/stores/sidebar-store";
import { SidebarHeader } from "./sidebar-header";
import { SidebarNav } from "./sidebar-nav";
import { SidebarToggle } from "./sidebar-toggle";
export function Sidebar({ onOpenSearch }: { onOpenSearch?: () => void }) {
const collapsed = useSidebarStore((s) => s.isCollapsed);
return (
<aside
className={cn(
"flex h-full shrink-0 flex-col overflow-hidden border-r border-sidebar-border bg-sidebar text-sidebar-foreground transition-[width] duration-200 ease-out",
collapsed ? "w-12" : "w-[240px]",
)}
>
<SidebarHeader collapsed={collapsed} />
<SidebarNav collapsed={collapsed} onOpenSearch={onOpenSearch} />
<div className="mt-auto shrink-0 border-t border-sidebar-border px-1 py-2">
<div className={cn("flex", collapsed ? "justify-center" : "justify-end")}>
<SidebarToggle />
</div>
</div>
</aside>
);
}

View file

@ -0,0 +1,124 @@
"use client";
import { Building2, Check, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
useWorkspaceStore,
type WorkspaceInfo,
} from "@/lib/stores/workspace-store";
import { cn } from "@/lib/utils";
const MOCK_WORKSPACES: WorkspaceInfo[] = [
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
slug: "personal",
name: "Personal",
},
{
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
slug: "team-alpha",
name: "Team Alpha",
},
{
id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
slug: "acme",
name: "Acme Corp",
},
];
export function WorkspaceSwitcher({
collapsed,
className,
}: {
collapsed?: boolean;
className?: string;
}) {
const current = useWorkspaceStore((s) => s.currentWorkspace);
const setWorkspace = useWorkspaceStore((s) => s.setWorkspace);
const display =
current ??
MOCK_WORKSPACES[0] ??
({ id: "", slug: "", name: "Workspace" } satisfies WorkspaceInfo);
const trigger = (
<Button
type="button"
variant="ghost"
className={cn(
"h-auto min-h-9 w-full justify-start gap-2 rounded-md px-2 py-1.5 text-left font-semibold text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
collapsed && "justify-center px-0",
className,
)}
>
<Building2 className="size-4 shrink-0 text-primary" />
{!collapsed ? (
<span className="min-w-0 flex-1 truncate text-sm">{display.name}</span>
) : null}
</Button>
);
return (
<div className={cn("min-w-0", collapsed && "flex justify-center")}>
<DropdownMenu>
{collapsed ? (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="right">{display.name}</TooltipContent>
</Tooltip>
) : (
<DropdownMenuTrigger asChild className="min-w-0 flex-1">
{trigger}
</DropdownMenuTrigger>
)}
<DropdownMenuContent className="w-56" align="start" side="bottom">
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
Workspaces
</DropdownMenuLabel>
{MOCK_WORKSPACES.map((ws) => {
const selected = display.id === ws.id;
return (
<DropdownMenuItem
key={ws.id}
className="gap-2"
onClick={() => setWorkspace(ws)}
>
<Building2 className="size-4 shrink-0 opacity-70" />
<span className="flex-1 truncate">{ws.name}</span>
{selected ? (
<Check className="size-4 shrink-0 text-primary" />
) : null}
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
<DropdownMenuItem
className="gap-2 text-muted-foreground focus:text-foreground"
onClick={() => {
// Conductor: wire create workspace flow
}}
>
<Plus className="size-4" />
Create Workspace
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View file

@ -0,0 +1,9 @@
export {
TemplatePicker,
BUILTIN_TEMPLATES,
TEMPLATE_PICKER_CREATE_SENTINEL,
type TemplatePickerProps,
type PickerTemplate,
type TemplateSchemaJson,
} from "./template-picker";
export { TemplateEditor, type TemplateEditorProps } from "./template-editor";

View file

@ -0,0 +1,383 @@
"use client";
import * as React from "react";
import {
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical, Trash2 } from "lucide-react";
import type { inferRouterOutputs } from "@trpc/server";
import type { AppRouter } from "@/server/root";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import type { TemplateSchemaJson } from "./template-picker";
type TemplateRow = inferRouterOutputs<AppRouter>["templates"]["getById"];
const TARGET_TYPES = ["task", "document", "project"] as const;
const FIELD_TYPES = [
"text",
"textarea",
"number",
"date",
"select",
"checkbox",
"url",
"email",
] as const;
type PropertyRow = {
id: string;
name: string;
fieldType: string;
defaultValue: string;
};
function newPropertyRow(): PropertyRow {
return {
id:
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `p-${Date.now()}-${Math.random().toString(16).slice(2)}`,
name: "",
fieldType: "text",
defaultValue: "",
};
}
function schemaToRows(schema: TemplateSchemaJson | null | undefined): PropertyRow[] {
const props = schema?.properties ?? [];
return props.map((p, i) => ({
id:
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `p-${i}`,
name: p.name,
fieldType: p.fieldType,
defaultValue:
p.defaultValue === undefined || p.defaultValue === null
? ""
: typeof p.defaultValue === "string"
? p.defaultValue
: JSON.stringify(p.defaultValue),
}));
}
function rowsToSchema(
rows: PropertyRow[],
defaultContent: string,
): TemplateSchemaJson {
return {
properties: rows
.filter((r) => r.name.trim() !== "")
.map((r) => {
let defaultValue: unknown = r.defaultValue;
if (r.fieldType === "number" && r.defaultValue.trim() !== "") {
const n = Number(r.defaultValue);
defaultValue = Number.isFinite(n) ? n : r.defaultValue;
} else if (r.fieldType === "checkbox") {
defaultValue = r.defaultValue === "true" || r.defaultValue === "1";
} else if (r.defaultValue.trim() === "") {
defaultValue = undefined;
}
return {
name: r.name.trim(),
fieldType: r.fieldType,
...(defaultValue !== undefined ? { defaultValue } : {}),
};
}),
...(defaultContent.trim() !== "" ? { defaultContent } : {}),
};
}
function SortablePropertyRow({
row,
onChange,
onRemove,
}: {
row: PropertyRow;
onChange: (id: string, patch: Partial<PropertyRow>) => void;
onRemove: (id: string) => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: row.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"flex flex-col gap-2 rounded-md border bg-card p-3 sm:flex-row sm:items-end",
isDragging && "z-10 opacity-90 shadow-md",
)}
>
<button
type="button"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-dashed text-muted-foreground hover:bg-muted"
{...attributes}
{...listeners}
aria-label="Reorder property"
>
<GripVertical className="h-4 w-4" />
</button>
<div className="grid min-w-0 flex-1 gap-2 sm:grid-cols-3">
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Name</label>
<Input
value={row.name}
onChange={(e) => onChange(row.id, { name: e.target.value })}
placeholder="Property name"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Field type</label>
<select
className={cn(
"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",
)}
value={row.fieldType}
onChange={(e) => onChange(row.id, { fieldType: e.target.value })}
>
{FIELD_TYPES.map((ft) => (
<option key={ft} value={ft}>
{ft}
</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Default value</label>
<Input
value={row.defaultValue}
onChange={(e) => onChange(row.id, { defaultValue: e.target.value })}
placeholder="Optional"
/>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-destructive hover:text-destructive"
onClick={() => onRemove(row.id)}
aria-label="Remove property"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
}
export type TemplateEditorProps = {
template?: TemplateRow;
workspaceId: string;
onSave: () => void;
};
export function TemplateEditor({ template, workspaceId, onSave }: TemplateEditorProps) {
const [name, setName] = React.useState(template?.name ?? "");
const [targetType, setTargetType] = React.useState(
template?.targetType && TARGET_TYPES.includes(template.targetType as (typeof TARGET_TYPES)[number])
? template.targetType
: "task",
);
const [defaultContent, setDefaultContent] = React.useState(
(template?.schema as TemplateSchemaJson | null | undefined)?.defaultContent ?? "",
);
const [rows, setRows] = React.useState<PropertyRow[]>(() =>
template?.schema ? schemaToRows(template.schema as TemplateSchemaJson) : [newPropertyRow()],
);
React.useEffect(() => {
if (!template) return;
setName(template.name);
setTargetType(
TARGET_TYPES.includes(template.targetType as (typeof TARGET_TYPES)[number])
? template.targetType
: "task",
);
const sch = template.schema as TemplateSchemaJson | null | undefined;
setDefaultContent(sch?.defaultContent ?? "");
setRows(schemaToRows(sch));
}, [template]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const createMut = api.templates.create.useMutation({ onSuccess: onSave });
const updateMut = api.templates.update.useMutation({ onSuccess: onSave });
const pending = createMut.isPending || updateMut.isPending;
const updateRow = React.useCallback((id: string, patch: Partial<PropertyRow>) => {
setRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
}, []);
const removeRow = React.useCallback((id: string) => {
setRows((prev) => (prev.length <= 1 ? prev : prev.filter((r) => r.id !== id)));
}, []);
const onDragEnd = React.useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setRows((items) => {
const oldIndex = items.findIndex((i) => i.id === active.id);
const newIndex = items.findIndex((i) => i.id === over.id);
if (oldIndex < 0 || newIndex < 0) return items;
return arrayMove(items, oldIndex, newIndex);
});
}, []);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const schema = rowsToSchema(rows, defaultContent);
if (!name.trim()) return;
if (template?.id) {
updateMut.mutate({
id: template.id,
name: name.trim(),
schema,
});
} else {
createMut.mutate({
workspaceId,
name: name.trim(),
targetType,
schema,
});
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<label className="text-sm font-medium">Name</label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Template name" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Target type</label>
<select
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm",
)}
value={targetType}
onChange={(e) => setTargetType(e.target.value)}
disabled={Boolean(template?.id)}
>
{TARGET_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
{template?.id ? (
<p className="text-xs text-muted-foreground">Target type cannot be changed after creation.</p>
) : null}
</div>
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">Properties</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setRows((r) => [...r, newPropertyRow()])}
>
Add property
</Button>
</div>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext items={rows.map((r) => r.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-2">
{rows.map((row) => (
<SortablePropertyRow
key={row.id}
row={row}
onChange={updateRow}
onRemove={removeRow}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
<Separator />
<div className="space-y-2">
<label className="text-sm font-medium">Default content</label>
<textarea
className={cn(
"flex min-h-[120px] 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",
)}
value={defaultContent}
onChange={(e) => setDefaultContent(e.target.value)}
placeholder="Initial body text or outline for new objects using this template"
/>
</div>
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="outline"
disabled={pending}
onClick={() => {
if (template) {
setName(template.name);
setTargetType(
TARGET_TYPES.includes(
template.targetType as (typeof TARGET_TYPES)[number],
)
? template.targetType
: "task",
);
const sch = template.schema as TemplateSchemaJson | null | undefined;
setDefaultContent(sch?.defaultContent ?? "");
setRows(schemaToRows(sch));
} else {
setName("");
setTargetType("task");
setDefaultContent("");
setRows([newPropertyRow()]);
}
}}
>
Cancel
</Button>
<Button type="submit" disabled={pending || !name.trim()}>
Save
</Button>
</div>
</form>
);
}

View file

@ -0,0 +1,337 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { FileStack, Search, X } from "lucide-react";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
export type TemplateSchemaJson = {
properties?: {
name: string;
fieldType: string;
defaultValue?: unknown;
}[];
defaultContent?: string;
};
export type PickerTemplate = {
id: string;
workspaceId: string;
name: string;
targetType: string;
schema: TemplateSchemaJson | null;
isBuiltin?: boolean;
};
/** Preset templates for UI / offline use; merge with API `templates.list` when available. */
export const BUILTIN_TEMPLATES: PickerTemplate[] = [
{
id: "00000000-0000-4000-8000-000000000001",
workspaceId: "",
name: "Bug Report",
targetType: "task",
isBuiltin: true,
schema: {
properties: [
{ name: "Priority", fieldType: "select", defaultValue: "medium" },
{ name: "Severity", fieldType: "select", defaultValue: "major" },
{
name: "Steps to Reproduce",
fieldType: "textarea",
defaultValue: "",
},
{
name: "Expected Behavior",
fieldType: "textarea",
defaultValue: "",
},
],
defaultContent:
"## Summary\n\n## Steps to reproduce\n\n1. \n\n## Expected behavior\n\n",
},
},
{
id: "00000000-0000-4000-8000-000000000002",
workspaceId: "",
name: "Meeting Notes",
targetType: "document",
isBuiltin: true,
schema: {
properties: [
{ name: "Date", fieldType: "date", defaultValue: "" },
{ name: "Attendees", fieldType: "text", defaultValue: "" },
{ name: "Agenda", fieldType: "textarea", defaultValue: "" },
{ name: "Action Items", fieldType: "textarea", defaultValue: "" },
],
defaultContent: "# Meeting\n\n## Agenda\n\n## Notes\n\n## Action items\n\n",
},
},
{
id: "00000000-0000-4000-8000-000000000003",
workspaceId: "",
name: "Sprint",
targetType: "project",
isBuiltin: true,
schema: {
properties: [
{ name: "Sprint Goal", fieldType: "text", defaultValue: "" },
{ name: "Start Date", fieldType: "date", defaultValue: "" },
{ name: "End Date", fieldType: "date", defaultValue: "" },
{ name: "Velocity", fieldType: "number", defaultValue: null },
],
defaultContent: "## Sprint goal\n\n## Commitments\n\n",
},
},
];
const CREATE_SENTINEL = "__create__";
function schemaPreview(schema: TemplateSchemaJson | null | undefined): string {
const props = schema?.properties ?? [];
if (props.length === 0) {
return "No custom properties";
}
const names = props.slice(0, 6).map((p) => p.name);
const extra = props.length > 6 ? ` +${props.length - 6} more` : "";
return `${names.join(" · ")}${extra}`;
}
function propertyCount(schema: TemplateSchemaJson | null | undefined): number {
return schema?.properties?.length ?? 0;
}
export type TemplatePickerProps = {
objectId: string;
objectType: string;
onSelect: (templateId: string) => void;
open: boolean;
onOpenChange: (open: boolean) => void;
};
export function TemplatePicker({
objectId,
objectType,
onSelect,
open,
onOpenChange,
}: TemplatePickerProps) {
const [search, setSearch] = React.useState("");
const [showAllTypes, setShowAllTypes] = React.useState(false);
const objectQuery = api.objects.getById.useQuery(
{ id: objectId },
{ enabled: open && Boolean(objectId) },
);
const objWorkspace = (objectQuery.data as unknown as { workspaceId?: string | null } | undefined)
?.workspaceId;
const workspaceId =
typeof objWorkspace === "string" && objWorkspace.length > 0 ? objWorkspace : undefined;
const listQuery = api.templates.list.useQuery(
{ workspaceId: workspaceId!, targetType: showAllTypes ? undefined : objectType },
{ enabled: open && Boolean(workspaceId) },
);
const merged = React.useMemo(() => {
const fromApi = listQuery.data?.templates ?? [];
const builtinFiltered = BUILTIN_TEMPLATES.filter(
(b) => showAllTypes || b.targetType === objectType,
);
const seen = new Set<string>();
const out: PickerTemplate[] = [];
for (const t of [...builtinFiltered, ...fromApi]) {
if (seen.has(t.id)) continue;
seen.add(t.id);
out.push({
id: t.id,
workspaceId: t.workspaceId,
name: t.name,
targetType: t.targetType,
schema: (t.schema as TemplateSchemaJson | null) ?? null,
isBuiltin: "isBuiltin" in t ? t.isBuiltin : false,
});
}
return out;
}, [listQuery.data?.templates, objectType, showAllTypes]);
const filtered = React.useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return merged;
return merged.filter((t) => t.name.toLowerCase().includes(q));
}, [merged, search]);
const grouped = React.useMemo(() => {
if (!showAllTypes) {
return new Map([[objectType, filtered]]);
}
const m = new Map<string, PickerTemplate[]>();
const order = ["project", "task", "document", "whiteboard", "group", "workspace"];
for (const t of filtered) {
const k = t.targetType;
if (!m.has(k)) m.set(k, []);
m.get(k)!.push(t);
}
const keys = [...m.keys()].sort(
(a, b) => order.indexOf(a) - order.indexOf(b) || a.localeCompare(b),
);
return new Map(keys.map((k) => [k, m.get(k)!]));
}, [filtered, objectType, showAllTypes]);
React.useEffect(() => {
if (!open) {
setSearch("");
setShowAllTypes(false);
}
}, [open]);
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
<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",
)}
/>
<DialogPrimitive.Content
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-0 rounded-lg border bg-background p-0 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",
)}
>
<div className="flex items-start justify-between gap-3 border-b px-4 py-3">
<div className="min-w-0 space-y-1">
<DialogPrimitive.Title className="text-lg font-semibold leading-none tracking-tight">
Use a template
</DialogPrimitive.Title>
<DialogPrimitive.Description className="text-sm text-muted-foreground">
Apply structure and default fields to this {objectType}.
</DialogPrimitive.Description>
</div>
<DialogPrimitive.Close asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" aria-label="Close">
<X className="h-4 w-4" />
</Button>
</DialogPrimitive.Close>
</div>
<div className="space-y-3 px-4 py-3">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search templates…"
className="pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<label className="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
className="rounded border-input"
checked={showAllTypes}
onChange={(e) => setShowAllTypes(e.target.checked)}
/>
Show all types
</label>
</div>
<ScrollArea className="max-h-[min(420px,55vh)] px-4">
<div className="space-y-4 pb-3 pr-3">
{listQuery.isPending && workspaceId ? (
<p className="text-sm text-muted-foreground">Loading templates</p>
) : null}
{!workspaceId && objectQuery.isPending ? (
<p className="text-sm text-muted-foreground">Loading object</p>
) : null}
{!workspaceId && objectQuery.isError ? (
<p className="text-sm text-destructive">Could not load workspace.</p>
) : null}
{Array.from(grouped.entries()).map(([typeKey, items], gi) => (
<React.Fragment key={typeKey}>
{gi > 0 && showAllTypes ? <Separator className="my-2" /> : null}
<div className="space-y-2">
{showAllTypes ? (
<div className="flex items-center gap-2 pt-1">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{typeKey}
</span>
<Separator className="flex-1" />
</div>
) : null}
{items.length === 0 ? (
<p className="text-sm text-muted-foreground">
No templates match this filter.
</p>
) : (
items.map((t) => (
<div
key={t.id}
className="rounded-lg border bg-card p-3 shadow-sm transition-colors hover:bg-accent/40"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium leading-tight">{t.name}</span>
<Badge variant="secondary" className="text-[10px] uppercase">
{t.targetType}
</Badge>
{t.isBuiltin ? (
<Badge variant="outline" className="text-[10px]">
Built-in
</Badge>
) : null}
</div>
<p className="text-xs text-muted-foreground">
{propertyCount(t.schema)} properties ·{" "}
<span className="line-clamp-2">{schemaPreview(t.schema)}</span>
</p>
</div>
<Button
size="sm"
className="shrink-0"
onClick={() => {
onSelect(t.id);
onOpenChange(false);
}}
>
Use Template
</Button>
</div>
</div>
))
)}
</div>
</React.Fragment>
))}
</div>
</ScrollArea>
<div className="border-t px-4 py-3">
<Button
variant="outline"
className="w-full gap-2"
onClick={() => {
onSelect(CREATE_SENTINEL);
onOpenChange(false);
}}
>
<FileStack className="h-4 w-4" />
Create New Template
</Button>
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
export { CREATE_SENTINEL as TEMPLATE_PICKER_CREATE_SENTINEL };

View file

@ -0,0 +1,50 @@
"use client";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<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.ElementRef<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.ElementRef<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 text-sm font-medium text-muted-foreground",
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View file

@ -0,0 +1,40 @@
"use client";
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",
muted:
"border-transparent bg-muted text-muted-foreground hover:bg-muted/80",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View file

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

View file

@ -0,0 +1,201 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<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.ElementRef<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 gap-2 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 [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<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;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<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 DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View file

@ -0,0 +1,9 @@
export * from "./avatar";
export * from "./badge";
export * from "./button";
export * from "./dropdown-menu";
export * from "./input";
export * from "./scroll-area";
export * from "./separator";
export * from "./sheet";
export * from "./tooltip";

View file

@ -0,0 +1,26 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ 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 file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };

View file

@ -0,0 +1,168 @@
"use client";
import * as React from "react";
import { RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { colorFromUserName } from "@/lib/yjs-provider";
export type PresenceUser = {
clientId: number;
name: string;
color: string;
imageUrl?: string;
};
function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return `${parts[0][0] ?? ""}${parts[1][0] ?? ""}`.toUpperCase();
}
export type PresenceAvatarsProps = {
users: PresenceUser[];
/** @default 5 */
maxVisible?: number;
className?: string;
};
/**
* Horizontal stack of collaborator avatars (initials or image) with cursor-colored borders.
*/
export function PresenceAvatars({
users,
maxVisible = 5,
className,
}: PresenceAvatarsProps) {
const visible = users.slice(0, maxVisible);
const overflow = Math.max(0, users.length - maxVisible);
if (users.length === 0) {
return null;
}
return (
<TooltipProvider delayDuration={150}>
<div
className={cn("flex flex-row items-center -space-x-2", className)}
aria-label="Collaborators in this document"
>
{visible.map((u) => {
const border = u.color || colorFromUserName(u.name);
return (
<Tooltip key={u.clientId}>
<TooltipTrigger asChild>
<div
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full border-2 bg-muted text-[11px] font-semibold text-foreground shadow-sm ring-2 ring-background transition-transform hover:z-10 hover:scale-105",
)}
style={{ borderColor: border }}
>
{u.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={u.imageUrl}
alt=""
className="size-full rounded-full object-cover"
/>
) : (
<span className="select-none">{initials(u.name)}</span>
)}
</div>
</TooltipTrigger>
<TooltipContent side="bottom">{u.name}</TooltipContent>
</Tooltip>
);
})}
{overflow > 0 ? (
<div
className="z-0 flex size-8 shrink-0 items-center justify-center rounded-full border-2 border-dashed border-muted-foreground/40 bg-muted/80 text-[10px] font-medium text-muted-foreground ring-2 ring-background"
title={`${overflow} more`}
>
+{overflow}
</div>
) : null}
</div>
</TooltipProvider>
);
}
export type ConnectionUiStatus = "connected" | "connecting" | "disconnected";
export type ConnectionStatusProps = {
status: ConnectionUiStatus;
onRetry?: () => void;
className?: string;
};
const statusConfig: Record<
ConnectionUiStatus,
{ label: string; dot: string; pulse?: boolean }
> = {
connected: {
label: "Connected",
dot: "bg-emerald-500 shadow-[0_0_0_3px_rgba(16,185,129,0.25)]",
},
connecting: {
label: "Connecting…",
dot: "bg-amber-400 shadow-[0_0_0_3px_rgba(251,191,36,0.3)]",
pulse: true,
},
disconnected: {
label: "Disconnected",
dot: "bg-red-500 shadow-[0_0_0_3px_rgba(239,68,68,0.25)]",
},
};
/**
* Compact live connection indicator with optional retry when offline.
*/
export function ConnectionStatus({
status,
onRetry,
className,
}: ConnectionStatusProps) {
const cfg = statusConfig[status];
return (
<div
className={cn(
"inline-flex items-center gap-2 rounded-full border border-border/60 bg-background/80 px-2.5 py-1 text-xs text-muted-foreground shadow-sm backdrop-blur-sm",
className,
)}
>
<span className="relative flex size-2.5 items-center justify-center">
<span
className={cn(
"size-2 rounded-full",
cfg.dot,
cfg.pulse && "animate-pulse",
)}
aria-hidden
/>
</span>
<span className="font-medium text-foreground/90">{cfg.label}</span>
{status === "disconnected" && onRetry ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 gap-1 px-1.5 text-[11px] text-foreground"
onClick={onRetry}
>
<RefreshCw className="size-3" />
Retry
</Button>
) : null}
</div>
);
}

View file

@ -0,0 +1,48 @@
"use client";
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils";
const ScrollArea = React.forwardRef<
React.ElementRef<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.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };

View file

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

View file

@ -0,0 +1,145 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
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.ElementRef<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 sheetVariants = cva(
"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-200 data-[state=open]:duration-200",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
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",
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",
},
},
defaultVariants: {
side: "right",
},
},
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>,
VariantProps<typeof sheetVariants> {
showClose?: boolean;
}
const SheetContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, showClose = true, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
{showClose ? (
<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>
) : null}
</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 SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef<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;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
SheetDescription.displayName = DialogPrimitive.Description.displayName;
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
sheetVariants,
};

View file

@ -0,0 +1,32 @@
"use client";
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.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<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}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View file

@ -0,0 +1,209 @@
"use client";
import * as React from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Calendar, GripVertical } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import type { ViewObject } from "@/lib/hooks/use-view-data";
import { usePanelStore } from "@/lib/stores/panel-store";
import { cn } from "@/lib/utils";
function getPriorityValue(object: ViewObject): string {
const v = object.propertyValues?.find(
(p) => p.propertyDefinition.name === "Priority",
)?.value;
return typeof v === "string" ? v : "—";
}
function getDueDateValue(object: ViewObject): string {
const v = object.propertyValues?.find(
(p) => p.propertyDefinition.name === "Due Date",
)?.value;
return typeof v === "string" ? v : "—";
}
function formatDueDisplay(iso: string): string {
if (iso === "—") return "";
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
} catch {
return iso;
}
}
function priorityTone(p: string): string {
switch (p) {
case "High":
return "border-destructive/40 bg-destructive/10 text-destructive";
case "Medium":
return "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-400";
case "Low":
return "border-muted-foreground/30 bg-muted/80 text-muted-foreground";
default:
return "border-border bg-muted/50 text-muted-foreground";
}
}
function initials(name: string | null): string {
return (name ?? "?")
.split(/\s+/)
.map((p) => p[0])
.join("")
.slice(0, 2)
.toUpperCase();
}
function CardMeta({ object }: { object: ViewObject }) {
const openDetail = usePanelStore((s) => s.open);
const priority = getPriorityValue(object);
const dueRaw = getDueDateValue(object);
const due = formatDueDisplay(dueRaw);
const assignees = object.assignees ?? [];
const shown = assignees.slice(0, 3);
const extra = assignees.length - shown.length;
return (
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<button
type="button"
onClick={() => openDetail("object-detail", object.id)}
className="min-w-0 text-left"
>
<span className="line-clamp-2 text-sm font-medium leading-snug text-foreground">
{object.title}
</span>
</button>
<div className="flex flex-wrap items-center gap-2 pl-1">
<Badge
variant="outline"
className={cn(
"h-5 max-w-[7rem] shrink-0 truncate px-1.5 py-0 text-[10px] font-semibold uppercase tracking-wide",
priorityTone(priority),
)}
>
{priority}
</Badge>
<div className="ml-auto flex min-w-0 items-center gap-2">
{due && (
<span className="flex items-center gap-0.5 text-[11px] tabular-nums text-muted-foreground">
<Calendar className="h-3 w-3 shrink-0 opacity-70" />
{due}
</span>
)}
<div className="flex -space-x-1.5">
{shown.map((a) => (
<Avatar
key={a.user.id}
className="h-6 w-6 border-2 border-card text-[9px]"
>
<AvatarImage src={a.user.avatarUrl ?? undefined} alt="" />
<AvatarFallback>{initials(a.user.name)}</AvatarFallback>
</Avatar>
))}
{extra > 0 && (
<span className="flex h-6 min-w-[1.5rem] items-center justify-center rounded-full border-2 border-card bg-muted px-1 text-[10px] font-medium text-muted-foreground">
+{extra}
</span>
)}
</div>
</div>
</div>
</div>
);
}
export interface BoardCardPreviewProps {
object: ViewObject;
}
/** Used inside DragOverlay — no sortable hooks; matches card visuals while dragging. */
export function BoardCardPreview({ object }: BoardCardPreviewProps) {
return (
<div
className={cn(
"flex min-h-[80px] max-h-[100px] rounded-lg border border-border/80 bg-card p-2.5 shadow-xl ring-2 ring-primary/25",
"rotate-2",
)}
>
<div className="flex min-h-0 flex-1 gap-2">
<span
className="mt-0.5 shrink-0 cursor-grabbing rounded p-0.5 text-muted-foreground"
aria-hidden
>
<GripVertical className="h-4 w-4" />
</span>
<CardMeta object={object} />
</div>
</div>
);
}
export interface BoardCardProps {
object: ViewObject;
}
export function BoardCard({ object }: BoardCardProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
isOver,
} = useSortable({
id: object.id,
data: { type: "card", object },
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div className="relative">
{!isDragging && isOver && (
<div
className="absolute -top-1 left-0 right-0 z-10 h-0.5 rounded-full bg-primary shadow-[0_0_8px_hsl(var(--primary))]"
aria-hidden
/>
)}
<div
ref={setNodeRef}
style={style}
className={cn(
"group/card flex min-h-[80px] max-h-[100px] rounded-lg border border-border/80 bg-card p-2.5 shadow-sm transition-[box-shadow,transform,opacity]",
"hover:shadow-md",
isDragging && "opacity-40",
)}
>
<div className="flex min-h-0 flex-1 gap-2">
<button
type="button"
className={cn(
"mt-0.5 shrink-0 cursor-grab touch-none rounded p-0.5 text-muted-foreground opacity-0 transition-opacity hover:bg-muted hover:text-foreground",
"group-hover/card:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
aria-label="Drag to reorder or move"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</button>
<CardMeta object={object} />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,101 @@
"use client";
import * as React from "react";
import { useDroppable } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { ViewObject } from "@/lib/hooks/use-view-data";
import { cn } from "@/lib/utils";
import { BoardCard } from "./board-card";
export interface BoardColumnProps {
columnId: string;
label: string;
items: ViewObject[];
dotClass: string;
borderTopClass: string;
onAddTask?: () => void;
}
function formatColumnLabel(id: string): string {
return id.replace(/_/g, " ");
}
export function BoardColumn({
columnId,
label,
items,
dotClass,
borderTopClass,
onAddTask,
}: BoardColumnProps) {
const { setNodeRef, isOver } = useDroppable({
id: columnId,
data: { type: "column", columnId },
});
const ids = items.map((i) => i.id);
const displayLabel = label || formatColumnLabel(columnId);
const showEmptyDropLine = items.length === 0 && isOver;
return (
<div
className={cn(
"flex h-full min-h-[min(420px,70vh)] w-[min(100%,290px)] min-w-[280px] max-w-[300px] shrink-0 flex-col overflow-hidden rounded-lg border border-border/60 bg-muted/40 shadow-sm dark:bg-muted/25",
borderTopClass,
)}
>
<header className="shrink-0 border-b border-border/50 px-3 py-2.5">
<div className="flex items-center gap-2">
<span
className={cn("h-2 w-2 shrink-0 rounded-full", dotClass)}
aria-hidden
/>
<h3 className="min-w-0 flex-1 truncate text-sm font-semibold capitalize text-foreground">
{displayLabel}
</h3>
<span className="tabular-nums text-xs font-medium text-muted-foreground">
{items.length}
</span>
</div>
</header>
<div ref={setNodeRef} className="relative flex min-h-0 flex-1 flex-col">
{showEmptyDropLine && (
<div
className="pointer-events-none absolute inset-x-2 top-2 z-0 h-0.5 rounded-full bg-primary/80 shadow-[0_0_10px_hsl(var(--primary)/0.5)]"
aria-hidden
/>
)}
<ScrollArea className="min-h-0 flex-1 px-2 pt-2">
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
<ul className="flex flex-col gap-2 pb-2">
{items.map((object) => (
<li key={object.id}>
<BoardCard object={object} />
</li>
))}
</ul>
</SortableContext>
</ScrollArea>
<div className="shrink-0 border-t border-border/40 p-2">
<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-start gap-2 text-muted-foreground hover:text-foreground"
onClick={onAddTask}
>
<Plus className="h-4 w-4" />
Add task
</Button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,319 @@
"use client";
import * as React from "react";
import {
DndContext,
DragOverlay,
PointerSensor,
closestCorners,
useSensor,
useSensors,
type DragEndEvent,
type DragOverEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import { arrayMove } from "@dnd-kit/sortable";
import type { ViewConfig, ViewObject } from "@/lib/hooks/use-view-data";
import { useViewData } from "@/lib/hooks/use-view-data";
import { cn } from "@/lib/utils";
import { BoardCardPreview } from "./board-card";
import { BoardColumn } from "./board-column";
const STATUS_ORDER = ["open", "in_progress", "done", "closed"] as const;
const COLUMN_THEME: Record<
string,
{ dot: string; borderTop: string; label: string }
> = {
open: {
dot: "bg-muted-foreground/55",
borderTop: "border-t-[3px] border-t-muted-foreground/45",
label: "Open",
},
in_progress: {
dot: "bg-blue-500",
borderTop: "border-t-[3px] border-t-blue-500",
label: "In progress",
},
done: {
dot: "bg-green-500",
borderTop: "border-t-[3px] border-t-green-500",
label: "Done",
},
closed: {
dot: "bg-slate-500",
borderTop: "border-t-[3px] border-t-slate-500",
label: "Closed",
},
};
function getColumnTheme(columnId: string) {
return (
COLUMN_THEME[columnId] ?? {
dot: "bg-muted-foreground/50",
borderTop: "border-t-[3px] border-t-muted-foreground/35",
label: columnId.replace(/_/g, " "),
}
);
}
function getColumnKeys(
groupBy: string | null,
grouped: Record<string, ViewObject[]>,
): string[] {
if (groupBy === "status" || groupBy === null) {
return [...STATUS_ORDER];
}
return Object.keys(grouped).sort();
}
function buildColumnsFromGrouped(
columnKeys: string[],
grouped: Record<string, ViewObject[]>,
): Record<string, ViewObject[]> {
const out: Record<string, ViewObject[]> = {};
for (const k of columnKeys) {
out[k] = grouped[k] ? [...grouped[k]] : [];
}
return out;
}
function findContainer(
id: string,
cols: Record<string, ViewObject[]>,
): string | undefined {
if (id in cols) return id;
for (const key of Object.keys(cols)) {
if (cols[key].some((o) => o.id === id)) return key;
}
return undefined;
}
function patchObjectForColumn(
object: ViewObject,
columnId: string,
groupField: string,
): ViewObject {
const field = groupField === "status" ? "status" : groupField;
if (field === "status") {
return { ...object, status: columnId };
}
return { ...object, [field]: columnId } as ViewObject;
}
export interface BoardViewProps {
config: ViewConfig;
className?: string;
}
export function BoardView({ config, className }: BoardViewProps) {
const effectiveConfig = React.useMemo(
() => ({
...config,
groupBy: config.groupBy ?? "status",
}),
[config],
);
const { grouped, isLoading, total } = useViewData(effectiveConfig);
const groupField = effectiveConfig.groupBy ?? "status";
const columnKeys = React.useMemo(
() => getColumnKeys(effectiveConfig.groupBy, grouped),
[effectiveConfig.groupBy, grouped],
);
const initialColumns = React.useMemo(
() => buildColumnsFromGrouped(columnKeys, grouped),
[columnKeys, grouped],
);
const [columns, setColumns] =
React.useState<Record<string, ViewObject[]>>(initialColumns);
const [activeId, setActiveId] = React.useState<string | null>(null);
React.useEffect(() => {
setColumns(initialColumns);
}, [initialColumns]);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
}),
);
const activeObject = React.useMemo(() => {
if (!activeId) return null;
for (const list of Object.values(columns)) {
const found = list.find((o) => o.id === activeId);
if (found) return found;
}
return null;
}, [activeId, columns]);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(String(event.active.id));
};
const handleDragCancel = () => {
setActiveId(null);
};
const handleDragOver = (event: DragOverEvent) => {
const { active, over } = event;
if (!over) return;
const activeIdStr = String(active.id);
const overIdStr = String(over.id);
if (activeIdStr === overIdStr) return;
setColumns((prev) => {
const activeContainer = findContainer(activeIdStr, prev);
const overContainer = findContainer(overIdStr, prev);
if (!activeContainer || !overContainer) return prev;
if (activeContainer === overContainer) return prev;
const activeItems = [...prev[activeContainer]];
const overItems = [...prev[overContainer]];
const activeIndex = activeItems.findIndex((i) => i.id === activeIdStr);
if (activeIndex === -1) return prev;
let newIndex: number;
if (overIdStr in prev) {
newIndex = overItems.length;
} else {
const overItemIndex = overItems.findIndex((i) => i.id === overIdStr);
const isBelowOverItem =
over.rect &&
active.rect.current.translated &&
active.rect.current.translated.top > over.rect.top + over.rect.height;
const modifier = isBelowOverItem ? 1 : 0;
newIndex =
overItemIndex >= 0 ? overItemIndex + modifier : overItems.length;
}
const [removed] = activeItems.splice(activeIndex, 1);
const patched = patchObjectForColumn(removed, overContainer, groupField);
const nextOver = [...overItems];
nextOver.splice(newIndex, 0, patched);
return {
...prev,
[activeContainer]: activeItems,
[overContainer]: nextOver,
};
});
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
if (!over) return;
const activeIdStr = String(active.id);
const overIdStr = String(over.id);
setColumns((prev) => {
const activeContainer = findContainer(activeIdStr, prev);
const overContainer = findContainer(overIdStr, prev);
if (!activeContainer || !overContainer) return prev;
if (activeContainer !== overContainer) {
const activeItems = [...prev[activeContainer]];
const overItems = [...prev[overContainer]];
const activeIndex = activeItems.findIndex((i) => i.id === activeIdStr);
if (activeIndex === -1) return prev;
const [removed] = activeItems.splice(activeIndex, 1);
const patched = patchObjectForColumn(removed, overContainer, groupField);
let newIndex = overItems.length;
if (!(overIdStr in prev)) {
const overItemIndex = overItems.findIndex((i) => i.id === overIdStr);
if (overItemIndex >= 0) newIndex = overItemIndex;
}
const nextOver = [...overItems];
nextOver.splice(newIndex, 0, patched);
return {
...prev,
[activeContainer]: activeItems,
[overContainer]: nextOver,
};
}
const list = [...prev[activeContainer]];
const oldIndex = list.findIndex((i) => i.id === activeIdStr);
if (oldIndex === -1) return prev;
if (overIdStr in prev) {
return prev;
}
const newIndex = list.findIndex((i) => i.id === overIdStr);
if (newIndex === -1 || oldIndex === newIndex) return prev;
return {
...prev,
[activeContainer]: arrayMove(list, oldIndex, newIndex),
};
});
};
if (isLoading) {
return (
<div className={cn("flex flex-1 items-center justify-center p-8", className)}>
<p className="text-sm text-muted-foreground">Loading board</p>
</div>
);
}
return (
<div className={cn("flex min-h-0 flex-1 flex-col gap-3", className)}>
<div className="shrink-0 px-1 text-xs text-muted-foreground">
{total} task{total === 1 ? "" : "s"}
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div className="min-h-0 flex-1 overflow-x-auto overflow-y-hidden pb-2">
<div className="flex h-full min-h-[min(420px,70vh)] gap-3 px-1 pb-1">
{columnKeys.map((columnId) => {
const theme = getColumnTheme(columnId);
return (
<BoardColumn
key={columnId}
columnId={columnId}
label={theme.label}
items={columns[columnId] ?? []}
dotClass={theme.dot}
borderTopClass={theme.borderTop}
/>
);
})}
</div>
</div>
<DragOverlay dropAnimation={null}>
{activeObject ? (
<div className="w-[min(100%,290px)] min-w-[260px] max-w-[300px] cursor-grabbing">
<BoardCardPreview object={activeObject} />
</div>
) : null}
</DragOverlay>
</DndContext>
</div>
);
}

View file

@ -0,0 +1,6 @@
export { BoardView } from "./board-view";
export type { BoardViewProps } from "./board-view";
export { BoardColumn } from "./board-column";
export type { BoardColumnProps } from "./board-column";
export { BoardCard, BoardCardPreview } from "./board-card";
export type { BoardCardProps, BoardCardPreviewProps } from "./board-card";

View file

@ -0,0 +1,359 @@
"use client";
import * as Popover from "@radix-ui/react-popover";
import * as Select from "@radix-ui/react-select";
import { Filter, Plus, X } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import type { ViewFilter } from "@/lib/hooks/use-view-data";
import { useViewStore } from "@/lib/stores/view-store";
import { cn } from "@/lib/utils";
const FILTER_FIELDS = [
{ value: "title", label: "Title" },
{ value: "status", label: "Status" },
{ value: "type", label: "Type" },
{ value: "createdAt", label: "Created" },
] as const;
type FilterField = (typeof FILTER_FIELDS)[number]["value"];
type FilterOperator = ViewFilter["operator"];
const OPERATOR_LABELS: Record<FilterOperator, string> = {
eq: "is",
neq: "is not",
contains: "contains",
gt: "after",
lt: "before",
in: "is any of",
isEmpty: "is empty",
isNotEmpty: "is not empty",
};
function operatorsForField(field: FilterField): FilterOperator[] {
switch (field) {
case "title":
return ["eq", "neq", "contains", "isEmpty", "isNotEmpty"];
case "status":
case "type":
return ["eq", "neq", "in", "isEmpty", "isNotEmpty"];
case "createdAt":
return ["eq", "gt", "lt", "isEmpty", "isNotEmpty"];
default:
return ["eq", "neq", "contains", "isEmpty", "isNotEmpty"];
}
}
function defaultFilter(): ViewFilter {
return { field: "title", operator: "contains", value: "" };
}
function normalizeOperator(field: FilterField, op: FilterOperator): FilterOperator {
const allowed = operatorsForField(field);
return allowed.includes(op) ? op : allowed[0];
}
function fieldLabel(field: string): string {
return FILTER_FIELDS.find((f) => f.value === field)?.label ?? field;
}
function formatFilterSummary(f: ViewFilter): string {
const fl = fieldLabel(f.field);
const op = OPERATOR_LABELS[f.operator];
if (f.operator === "isEmpty" || f.operator === "isNotEmpty") {
return `${fl} ${op}`;
}
const v =
f.operator === "in" && Array.isArray(f.value)
? f.value.join(", ")
: f.value !== undefined && f.value !== null && f.value !== ""
? String(f.value)
: "…";
return `${fl} ${op} ${v}`;
}
const selectTriggerClass = cn(
"flex h-8 min-w-[5.5rem] shrink-0 items-center justify-between gap-1 rounded-md border border-input bg-background px-2 text-xs",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background",
);
const selectContentClass = cn(
"z-[60] max-h-[min(60vh,20rem)] overflow-hidden rounded-md border bg-popover 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",
);
const selectItemClass =
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-xs outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50";
function FilterRowSelect({
value,
options,
onChange,
placeholder,
className,
}: {
value: string;
options: { value: string; label: string }[];
onChange: (v: string) => void;
placeholder?: string;
className?: string;
}) {
return (
<Select.Root value={value} onValueChange={onChange}>
<Select.Trigger className={cn(selectTriggerClass, className)} aria-label={placeholder}>
<Select.Value placeholder={placeholder} />
<Select.Icon className="opacity-50">
<svg width="12" height="12" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M4.93179 5.43179C4.75605 5.60753 4.75605 5.89245 4.93179 6.06819C5.10753 6.24392 5.39245 6.24392 5.56819 6.06819L7.49999 4.13638L9.43179 6.06819C9.60753 6.24392 9.89245 6.24392 10.0682 6.06819C10.2439 5.89245 10.2439 5.60753 10.0682 5.43179L7.81819 3.18179C7.73379 3.0974 7.61933 3.04999 7.49999 3.04999C7.38064 3.04999 7.26618 3.0974 7.18179 3.18179L4.93179 5.43179ZM10.0682 9.56819C10.2439 9.39245 10.2439 9.10753 10.0682 8.93179C9.89245 8.75606 9.60753 8.75606 9.43179 8.93179L7.49999 10.8636L5.56819 8.93179C5.39245 8.75606 5.10753 8.75606 4.93179 8.93179C4.75605 9.10753 4.75605 9.39245 4.93179 9.56819L7.18179 11.8182C7.35753 11.9939 7.64245 11.9939 7.81819 11.8182L10.0682 9.56819Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
/>
</svg>
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content position="popper" className={selectContentClass} sideOffset={4}>
<Select.Viewport className="p-1">
{options.map((o) => (
<Select.Item key={o.value} value={o.value} className={selectItemClass}>
<Select.ItemText>{o.label}</Select.ItemText>
</Select.Item>
))}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
);
}
function FilterEditorRow({
filter,
index,
onChange,
onRemove,
}: {
filter: ViewFilter;
index: number;
onChange: (next: ViewFilter) => void;
onRemove: () => void;
}) {
const field = (FILTER_FIELDS.some((f) => f.value === filter.field)
? filter.field
: "title") as FilterField;
const ops = operatorsForField(field);
const op = normalizeOperator(field, filter.operator);
const setField = (nextField: string) => {
const f = nextField as FilterField;
const nextOp = normalizeOperator(f, filter.operator);
let nextVal: unknown = filter.value;
if (nextOp === "isEmpty" || nextOp === "isNotEmpty") {
nextVal = undefined;
} else if (nextOp === "in" && !Array.isArray(nextVal)) {
nextVal =
typeof filter.value === "string" && filter.value.includes(",")
? filter.value.split(",").map((s) => s.trim())
: [];
}
onChange({ field: f, operator: nextOp, value: nextVal });
};
const setOp = (nextOp: string) => {
const o = nextOp as FilterOperator;
let nextVal: unknown = filter.value;
const prevOp = filter.operator;
if (o === "isEmpty" || o === "isNotEmpty") {
nextVal = undefined;
} else if (o === "in") {
nextVal = Array.isArray(filter.value) ? filter.value : [];
} else if (prevOp === "in") {
nextVal = "";
}
onChange({ ...filter, operator: o, value: nextVal });
};
const valueNeedsInput = op !== "isEmpty" && op !== "isNotEmpty";
return (
<div className="flex flex-col gap-2 rounded-md border border-border/80 bg-muted/30 p-2 sm:flex-row sm:items-center">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
<FilterRowSelect
value={field}
onChange={setField}
options={FILTER_FIELDS.map((f) => ({ value: f.value, label: f.label }))}
placeholder="Field"
className="min-w-[6.5rem]"
/>
<FilterRowSelect
value={op}
onChange={setOp}
options={ops.map((o) => ({ value: o, label: OPERATOR_LABELS[o] }))}
placeholder="Operator"
className="min-w-[7rem]"
/>
{valueNeedsInput && (
<Input
className="h-8 min-w-[8rem] flex-1 text-xs"
value={
op === "in"
? Array.isArray(filter.value)
? filter.value.join(", ")
: String(filter.value ?? "")
: String(filter.value ?? "")
}
onChange={(e) => {
const raw = e.target.value;
if (op === "in") {
onChange({
...filter,
value: raw.split(",").map((s) => s.trim()).filter(Boolean),
});
} else {
onChange({ ...filter, value: raw });
}
}}
placeholder={op === "in" ? "a, b, c" : "Value"}
aria-label={`Filter ${index + 1} value`}
/>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 self-end sm:self-center"
onClick={onRemove}
aria-label={`Remove filter ${index + 1}`}
>
<X className="size-4" />
</Button>
</div>
);
}
function clearAllFilters(
count: number,
removeFilter: (index: number) => void,
) {
for (let i = count - 1; i >= 0; i--) {
removeFilter(i);
}
}
/** Popover + actions for the toolbar row. */
export function FilterBar() {
const filters = useViewStore((s) => s.config.filters);
const addFilter = useViewStore((s) => s.addFilter);
const removeFilter = useViewStore((s) => s.removeFilter);
const updateFilter = useViewStore((s) => s.updateFilter);
return (
<div className="flex flex-wrap items-center gap-1.5">
<Popover.Root>
<Popover.Trigger asChild>
<Button type="button" variant="ghost" size="sm" className="h-8 gap-1.5 px-2 text-xs">
<Filter className="size-3.5" />
Filter
{filters.length > 0 ? (
<span className="rounded-full bg-primary/15 px-1.5 text-[10px] font-semibold text-primary">
{filters.length}
</span>
) : null}
</Button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
className="z-[60] w-[min(calc(100vw-2rem),22rem)] rounded-md border bg-popover p-3 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
sideOffset={6}
align="start"
>
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-xs font-semibold">Filters</p>
{filters.length > 0 ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-[11px] text-muted-foreground"
onClick={() => clearAllFilters(filters.length, removeFilter)}
>
Clear all
</Button>
) : null}
</div>
<div className="flex max-h-[min(50vh,18rem)] flex-col gap-2 overflow-y-auto pr-0.5">
{filters.map((f, i) => (
<FilterEditorRow
key={i}
filter={f}
index={i}
onChange={(next) => updateFilter(i, next)}
onRemove={() => removeFilter(i)}
/>
))}
</div>
<Separator className="my-3" />
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-full justify-start gap-2 text-xs"
onClick={() => addFilter(defaultFilter())}
>
<Plus className="size-3.5" />
Add filter
</Button>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
{filters.length > 0 ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-[11px] text-muted-foreground"
onClick={() => clearAllFilters(filters.length, removeFilter)}
>
Clear all
</Button>
) : null}
</div>
);
}
/** Removable filter summary chips; place below the main toolbar row. */
export function FilterBadges() {
const filters = useViewStore((s) => s.config.filters);
const removeFilter = useViewStore((s) => s.removeFilter);
if (filters.length === 0) return null;
return (
<div className="flex flex-wrap items-center gap-1.5">
{filters.map((f, i) => (
<Badge
key={i}
variant="secondary"
className="group gap-1 border border-primary/25 bg-primary/10 pl-2 pr-1 text-xs font-normal text-foreground hover:bg-primary/15"
>
<span className="max-w-[14rem] truncate">{formatFilterSummary(f)}</span>
<button
type="button"
className="rounded-sm p-0.5 text-muted-foreground hover:bg-primary/20 hover:text-foreground"
onClick={() => removeFilter(i)}
aria-label={`Remove filter ${i + 1}`}
>
<X className="size-3" />
</button>
</Badge>
))}
</div>
);
}

View file

@ -0,0 +1,71 @@
"use client";
import { Check, ChevronsUpDown, Layers } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useViewStore } from "@/lib/stores/view-store";
import { cn } from "@/lib/utils";
const GROUP_OPTIONS: { label: string; value: string | null }[] = [
{ label: "None", value: null },
{ label: "Status", value: "status" },
{ label: "Type", value: "type" },
{ label: "Priority", value: "priority" },
{ label: "Assignee", value: "assignee" },
];
function labelForValue(value: string | null): string {
return GROUP_OPTIONS.find((o) => o.value === value)?.label ?? "None";
}
export function GroupConfig() {
const groupBy = useViewStore((s) => s.config.groupBy);
const setGroupBy = useViewStore((s) => s.setGroupBy);
const selectedLabel = labelForValue(groupBy);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 max-w-[11rem] gap-1.5 px-2 text-xs"
>
<Layers className="size-3.5 shrink-0" />
<span className="truncate">
Group by{" "}
<span className="font-medium text-foreground">{selectedLabel}</span>
</span>
<ChevronsUpDown className="size-3 shrink-0 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
{GROUP_OPTIONS.map((opt) => (
<DropdownMenuItem
key={opt.label}
className="gap-2 text-xs"
onClick={() => setGroupBy(opt.value)}
>
<span
className={cn(
"flex size-4 items-center justify-center",
groupBy === opt.value ? "opacity-100" : "opacity-0",
)}
>
<Check className="size-3.5" />
</span>
{opt.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -0,0 +1,5 @@
export { FilterBadges, FilterBar } from "./filter-bar";
export { GroupConfig } from "./group-config";
export { SortConfig } from "./sort-config";
export { ViewSwitcher } from "./view-switcher";
export { ViewToolbar, type ViewToolbarProps } from "./view-toolbar";

View file

@ -0,0 +1,198 @@
"use client";
import * as Popover from "@radix-ui/react-popover";
import * as Select from "@radix-ui/react-select";
import { ArrowDown, ArrowDownAZ, ArrowUpAZ, Plus, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import type { ViewSort } from "@/lib/hooks/use-view-data";
import { useViewStore } from "@/lib/stores/view-store";
import { cn } from "@/lib/utils";
const SORT_FIELDS = [
{ value: "title", label: "Title" },
{ value: "status", label: "Status" },
{ value: "sortOrder", label: "Order" },
{ value: "createdAt", label: "Created" },
{ value: "updatedAt", label: "Updated" },
] as const;
const selectTriggerClass = cn(
"flex h-8 min-w-[5.5rem] shrink-0 items-center justify-between gap-1 rounded-md border border-input bg-background px-2 text-xs",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background",
);
const selectContentClass = cn(
"z-[60] max-h-[min(60vh,20rem)] overflow-hidden rounded-md border bg-popover 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",
);
const selectItemClass =
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-xs outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50";
function SortSelect({
value,
options,
onChange,
placeholder,
className,
}: {
value: string;
options: { value: string; label: string }[];
onChange: (v: string) => void;
placeholder?: string;
className?: string;
}) {
return (
<Select.Root value={value} onValueChange={onChange}>
<Select.Trigger className={cn(selectTriggerClass, className)} aria-label={placeholder}>
<Select.Value placeholder={placeholder} />
<Select.Icon className="opacity-50">
<svg width="12" height="12" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M4.93179 5.43179C4.75605 5.60753 4.75605 5.89245 4.93179 6.06819C5.10753 6.24392 5.39245 6.24392 5.56819 6.06819L7.49999 4.13638L9.43179 6.06819C9.60753 6.24392 9.89245 6.24392 10.0682 6.06819C10.2439 5.89245 10.2439 5.60753 10.0682 5.43179L7.81819 3.18179C7.73379 3.0974 7.61933 3.04999 7.49999 3.04999C7.38064 3.04999 7.26618 3.0974 7.18179 3.18179L4.93179 5.43179ZM10.0682 9.56819C10.2439 9.39245 10.2439 9.10753 10.0682 8.93179C9.89245 8.75606 9.60753 8.75606 9.43179 8.93179L7.49999 10.8636L5.56819 8.93179C5.39245 8.75606 5.10753 8.75606 4.93179 8.93179C4.75605 9.10753 4.75605 9.39245 4.93179 9.56819L7.18179 11.8182C7.35753 11.9939 7.64245 11.9939 7.81819 11.8182L10.0682 9.56819Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
/>
</svg>
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content position="popper" className={selectContentClass} sideOffset={4}>
<Select.Viewport className="p-1">
{options.map((o) => (
<Select.Item key={o.value} value={o.value} className={selectItemClass}>
<Select.ItemText>{o.label}</Select.ItemText>
</Select.Item>
))}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
);
}
function defaultSort(): ViewSort {
return { field: "sortOrder", direction: "asc" };
}
function SortRow({
sort,
index,
onChange,
onRemove,
}: {
sort: ViewSort;
index: number;
onChange: (next: ViewSort) => void;
onRemove: () => void;
}) {
const field = SORT_FIELDS.some((f) => f.value === sort.field) ? sort.field : "sortOrder";
const direction = sort.direction === "desc" ? "desc" : "asc";
return (
<div className="flex flex-col gap-2 rounded-md border border-border/80 bg-muted/30 p-2 sm:flex-row sm:items-center">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
<SortSelect
value={field}
onChange={(v) => onChange({ ...sort, field: v })}
options={SORT_FIELDS.map((f) => ({ value: f.value, label: f.label }))}
placeholder="Field"
className="min-w-[7rem] flex-1"
/>
<div className="flex items-center gap-0.5 rounded-md border border-input bg-background p-0.5">
<Button
type="button"
variant={direction === "asc" ? "secondary" : "ghost"}
size="sm"
className="h-7 gap-1 px-2 text-[11px]"
onClick={() => onChange({ ...sort, direction: "asc" })}
aria-pressed={direction === "asc"}
aria-label={`Sort ${index + 1} ascending`}
>
<ArrowUpAZ className="size-3.5" />
Asc
</Button>
<Button
type="button"
variant={direction === "desc" ? "secondary" : "ghost"}
size="sm"
className="h-7 gap-1 px-2 text-[11px]"
onClick={() => onChange({ ...sort, direction: "desc" })}
aria-pressed={direction === "desc"}
aria-label={`Sort ${index + 1} descending`}
>
<ArrowDownAZ className="size-3.5" />
Desc
</Button>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 self-end sm:self-center"
onClick={onRemove}
aria-label={`Remove sort ${index + 1}`}
>
<X className="size-4" />
</Button>
</div>
);
}
export function SortConfig() {
const sorts = useViewStore((s) => s.config.sorts);
const addSort = useViewStore((s) => s.addSort);
const removeSort = useViewStore((s) => s.removeSort);
const updateSort = useViewStore((s) => s.updateSort);
return (
<Popover.Root>
<Popover.Trigger asChild>
<Button type="button" variant="ghost" size="sm" className="h-8 gap-1.5 px-2 text-xs">
<ArrowDown className="size-3.5" />
Sort
{sorts.length > 0 ? (
<span className="rounded-full bg-muted px-1.5 text-[10px] font-semibold text-muted-foreground">
{sorts.length}
</span>
) : null}
</Button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
className="z-[60] w-[min(calc(100vw-2rem),22rem)] rounded-md border bg-popover p-3 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
sideOffset={6}
align="start"
>
<p className="mb-2 text-xs font-semibold">Sort by</p>
<div className="flex max-h-[min(50vh,18rem)] flex-col gap-2 overflow-y-auto pr-0.5">
{sorts.map((s, i) => (
<SortRow
key={i}
sort={s}
index={i}
onChange={(next) => updateSort(i, next)}
onRemove={() => removeSort(i)}
/>
))}
</div>
<Separator className="my-3" />
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-full justify-start gap-2 text-xs"
onClick={() => addSort(defaultSort())}
>
<Plus className="size-3.5" />
Add sort
</Button>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}

View file

@ -0,0 +1,47 @@
"use client";
import * as Tabs from "@radix-ui/react-tabs";
import { LayoutGrid, List, Table2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { type ActiveViewType, useViewStore } from "@/lib/stores/view-store";
const tabs: { id: ActiveViewType; label: string; icon: typeof List }[] = [
{ id: "list", label: "List", icon: List },
{ id: "board", label: "Board", icon: LayoutGrid },
{ id: "table", label: "Table", icon: Table2 },
];
export function ViewSwitcher() {
const activeView = useViewStore((s) => s.activeView);
const setActiveView = useViewStore((s) => s.setActiveView);
const tabValue = tabs.some((t) => t.id === activeView) ? activeView : "list";
return (
<Tabs.Root value={tabValue} onValueChange={(v) => setActiveView(v as ActiveViewType)}>
<Tabs.List
className="inline-flex h-8 items-stretch gap-1 border-b border-border"
aria-label="View type"
>
{tabs.map(({ id, label, icon: Icon }) => (
<Tabs.Trigger
key={id}
value={id}
className={cn(
"relative inline-flex items-center gap-1.5 px-2.5 text-xs font-medium text-muted-foreground transition-colors",
"hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"data-[state=active]:text-primary",
"after:pointer-events-none after:absolute after:bottom-0 after:left-1 after:right-1 after:h-0.5 after:rounded-full after:bg-primary after:opacity-0 after:transition-opacity",
"data-[state=active]:after:opacity-100",
)}
>
<Icon className="size-3.5 shrink-0" aria-hidden />
<span>{label}</span>
</Tabs.Trigger>
))}
</Tabs.List>
</Tabs.Root>
);
}

View file

@ -0,0 +1,31 @@
"use client";
import { FilterBadges, FilterBar } from "@/components/views/config/filter-bar";
import { GroupConfig } from "@/components/views/config/group-config";
import { SortConfig } from "@/components/views/config/sort-config";
import { ViewSwitcher } from "@/components/views/config/view-switcher";
export interface ViewToolbarProps {
/** Total item count shown at the end of the toolbar. */
totalCount: number;
/** Optional label for the count (default "items"). */
countLabel?: string;
}
export function ViewToolbar({ totalCount, countLabel = "items" }: ViewToolbarProps) {
return (
<div className="flex min-w-0 flex-col gap-2">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1.5">
<ViewSwitcher />
<FilterBar />
<SortConfig />
<GroupConfig />
<div className="min-w-[0.5rem] flex-1" aria-hidden />
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{totalCount} {countLabel}
</span>
</div>
<FilterBadges />
</div>
);
}

Some files were not shown because too many files have changed in this diff Show more