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:
commit
a508ece6e7
183 changed files with 28573 additions and 0 deletions
40
.env.example
Normal file
40
.env.example
Normal 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
39
.gitignore
vendored
Normal 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/
|
||||
25
apps/collab-server/package.json
Normal file
25
apps/collab-server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
249
apps/collab-server/src/index.ts
Normal file
249
apps/collab-server/src/index.ts
Normal 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);
|
||||
});
|
||||
28
apps/collab-server/src/utils.ts
Normal file
28
apps/collab-server/src/utils.ts
Normal 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;
|
||||
}
|
||||
15
apps/collab-server/tsconfig.json
Normal file
15
apps/collab-server/tsconfig.json
Normal 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"]
|
||||
}
|
||||
23
apps/mcp-server/package.json
Normal file
23
apps/mcp-server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
2
apps/mcp-server/src/db.ts
Normal file
2
apps/mcp-server/src/db.ts
Normal 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";
|
||||
22
apps/mcp-server/src/drizzle.ts
Normal file
22
apps/mcp-server/src/drizzle.ts
Normal 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;
|
||||
24
apps/mcp-server/src/index.ts
Normal file
24
apps/mcp-server/src/index.ts
Normal 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");
|
||||
14
apps/mcp-server/src/resources/index.ts
Normal file
14
apps/mcp-server/src/resources/index.ts
Normal 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";
|
||||
70
apps/mcp-server/src/resources/object-resource.ts
Normal file
70
apps/mcp-server/src/resources/object-resource.ts
Normal 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),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
82
apps/mcp-server/src/resources/view-resource.ts
Normal file
82
apps/mcp-server/src/resources/view-resource.ts
Normal 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),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
106
apps/mcp-server/src/resources/workspace-tree-resource.ts
Normal file
106
apps/mcp-server/src/resources/workspace-tree-resource.ts
Normal 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),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
2
apps/mcp-server/src/schema.ts
Normal file
2
apps/mcp-server/src/schema.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/** Re-export so tsup bundles schema with the MCP server. */
|
||||
export * from "../../../packages/database/src/schema/index.ts";
|
||||
1
apps/mcp-server/src/shared-types.ts
Normal file
1
apps/mcp-server/src/shared-types.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { objectTypes, viewTypes } from "../../../packages/shared/src/types/index.ts";
|
||||
53
apps/mcp-server/src/tools/create-object.ts
Normal file
53
apps/mcp-server/src/tools/create-object.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
20
apps/mcp-server/src/tools/index.ts
Normal file
20
apps/mcp-server/src/tools/index.ts
Normal 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";
|
||||
68
apps/mcp-server/src/tools/list-objects.ts
Normal file
68
apps/mcp-server/src/tools/list-objects.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
125
apps/mcp-server/src/tools/manage-object.ts
Normal file
125
apps/mcp-server/src/tools/manage-object.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
62
apps/mcp-server/src/tools/search-objects.ts
Normal file
62
apps/mcp-server/src/tools/search-objects.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
29
apps/mcp-server/src/tools/tool-result.ts
Normal file
29
apps/mcp-server/src/tools/tool-result.ts
Normal 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);
|
||||
}
|
||||
49
apps/mcp-server/src/tools/update-object.ts
Normal file
49
apps/mcp-server/src/tools/update-object.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
15
apps/mcp-server/tsconfig.json
Normal file
15
apps/mcp-server/tsconfig.json
Normal 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"]
|
||||
}
|
||||
20
apps/web/app/(app)/[workspaceSlug]/[projectId]/layout.tsx
Normal file
20
apps/web/app/(app)/[workspaceSlug]/[projectId]/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
apps/web/app/(app)/[workspaceSlug]/[projectId]/page.tsx
Normal file
24
apps/web/app/(app)/[workspaceSlug]/[projectId]/page.tsx
Normal 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} />;
|
||||
}
|
||||
}
|
||||
19
apps/web/app/(app)/[workspaceSlug]/docs/page.tsx
Normal file
19
apps/web/app/(app)/[workspaceSlug]/docs/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
apps/web/app/(app)/[workspaceSlug]/layout.tsx
Normal file
17
apps/web/app/(app)/[workspaceSlug]/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
156
apps/web/app/(app)/[workspaceSlug]/page.tsx
Normal file
156
apps/web/app/(app)/[workspaceSlug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
apps/web/app/(app)/[workspaceSlug]/whiteboards/page.tsx
Normal file
21
apps/web/app/(app)/[workspaceSlug]/whiteboards/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
apps/web/app/(app)/layout.tsx
Normal file
11
apps/web/app/(app)/layout.tsx
Normal 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>;
|
||||
}
|
||||
19
apps/web/app/(auth)/layout.tsx
Normal file
19
apps/web/app/(auth)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
202
apps/web/app/(auth)/sign-in/page.tsx
Normal file
202
apps/web/app/(auth)/sign-in/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
148
apps/web/app/(auth)/sign-up/page.tsx
Normal file
148
apps/web/app/(auth)/sign-up/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
apps/web/app/api/auth/[...nextauth]/route.ts
Normal file
5
apps/web/app/api/auth/[...nextauth]/route.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { handlers } from "@/lib/auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
13
apps/web/app/api/trpc/[trpc]/route.ts
Normal file
13
apps/web/app/api/trpc/[trpc]/route.ts
Normal 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
33
apps/web/app/layout.tsx
Normal 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
5
apps/web/app/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function HomePage() {
|
||||
redirect("/sign-in");
|
||||
}
|
||||
322
apps/web/components/ai/chat-panel.tsx
Normal file
322
apps/web/components/ai/chat-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
565
apps/web/components/ai/command-palette.tsx
Normal file
565
apps/web/components/ai/command-palette.tsx
Normal 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]">
|
||||
>
|
||||
</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">></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>
|
||||
);
|
||||
}
|
||||
3
apps/web/components/ai/index.ts
Normal file
3
apps/web/components/ai/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { AIChatPanel } from "./chat-panel";
|
||||
export { AIMessage } from "./message";
|
||||
export type { AIMessageProps } from "./message";
|
||||
197
apps/web/components/ai/message.tsx
Normal file
197
apps/web/components/ai/message.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
442
apps/web/components/editor/collaboration.tsx
Normal file
442
apps/web/components/editor/collaboration.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
101
apps/web/components/editor/drag-handle.tsx
Normal file
101
apps/web/components/editor/drag-handle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
298
apps/web/components/editor/editor.tsx
Normal file
298
apps/web/components/editor/editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
462
apps/web/components/editor/extensions/ai-block.tsx
Normal file
462
apps/web/components/editor/extensions/ai-block.tsx
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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: "",
|
||||
}),
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
167
apps/web/components/editor/extensions/callout.tsx
Normal file
167
apps/web/components/editor/extensions/callout.tsx
Normal 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" }],
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
145
apps/web/components/editor/extensions/divider.tsx
Normal file
145
apps/web/components/editor/extensions/divider.tsx
Normal 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" },
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
217
apps/web/components/editor/extensions/embed.tsx
Normal file
217
apps/web/components/editor/extensions/embed.tsx
Normal 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: "",
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
5
apps/web/components/editor/extensions/index.ts
Normal file
5
apps/web/components/editor/extensions/index.ts
Normal 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";
|
||||
275
apps/web/components/editor/extensions/mention.tsx
Normal file
275
apps/web/components/editor/extensions/mention.tsx
Normal 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;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
134
apps/web/components/editor/extensions/toggle.tsx
Normal file
134
apps/web/components/editor/extensions/toggle.tsx
Normal 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" }],
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
3
apps/web/components/editor/index.ts
Normal file
3
apps/web/components/editor/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { BlockEditor, type BlockEditorProps } from "./editor";
|
||||
export { EditorToolbar } from "./toolbar";
|
||||
export { SlashMenu } from "./slash-menu";
|
||||
413
apps/web/components/editor/slash-menu.tsx
Normal file
413
apps/web/components/editor/slash-menu.tsx
Normal 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`;
|
||||
}
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
344
apps/web/components/editor/toolbar.tsx
Normal file
344
apps/web/components/editor/toolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
apps/web/components/layout/app-shell.tsx
Normal file
41
apps/web/components/layout/app-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
apps/web/components/layout/workspace-sync.tsx
Normal file
35
apps/web/components/layout/workspace-sync.tsx
Normal 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}</>;
|
||||
}
|
||||
137
apps/web/components/panels/assignee-picker.tsx
Normal file
137
apps/web/components/panels/assignee-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
apps/web/components/panels/index.ts
Normal file
2
apps/web/components/panels/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./panel-header";
|
||||
export * from "./right-panel";
|
||||
706
apps/web/components/panels/object-detail.tsx
Normal file
706
apps/web/components/panels/object-detail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
apps/web/components/panels/panel-header.tsx
Normal file
48
apps/web/components/panels/panel-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
322
apps/web/components/panels/property-editor.tsx
Normal file
322
apps/web/components/panels/property-editor.tsx
Normal 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"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
28
apps/web/components/panels/right-panel.tsx
Normal file
28
apps/web/components/panels/right-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
apps/web/components/providers/theme-provider.tsx
Normal file
11
apps/web/components/providers/theme-provider.tsx
Normal 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>;
|
||||
}
|
||||
27
apps/web/components/providers/trpc-provider.tsx
Normal file
27
apps/web/components/providers/trpc-provider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
apps/web/components/search/index.ts
Normal file
3
apps/web/components/search/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { SearchDialog } from "./search-dialog";
|
||||
export { SearchResultRow as SearchResult, highlightText } from "./search-result";
|
||||
export type { SearchResultItem } from "./search-result";
|
||||
470
apps/web/components/search/search-dialog.tsx
Normal file
470
apps/web/components/search/search-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
159
apps/web/components/search/search-result.tsx
Normal file
159
apps/web/components/search/search-result.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
apps/web/components/sidebar/index.ts
Normal file
5
apps/web/components/sidebar/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export * from "./sidebar";
|
||||
export * from "./sidebar-header";
|
||||
export * from "./sidebar-item";
|
||||
export * from "./sidebar-nav";
|
||||
export * from "./sidebar-toggle";
|
||||
708
apps/web/components/sidebar/nav-tree.tsx
Normal file
708
apps/web/components/sidebar/nav-tree.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
apps/web/components/sidebar/sidebar-header.tsx
Normal file
126
apps/web/components/sidebar/sidebar-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
apps/web/components/sidebar/sidebar-item.tsx
Normal file
84
apps/web/components/sidebar/sidebar-item.tsx
Normal 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;
|
||||
}
|
||||
227
apps/web/components/sidebar/sidebar-nav.tsx
Normal file
227
apps/web/components/sidebar/sidebar-nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
apps/web/components/sidebar/sidebar-toggle.tsx
Normal file
43
apps/web/components/sidebar/sidebar-toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
apps/web/components/sidebar/sidebar.tsx
Normal file
29
apps/web/components/sidebar/sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
apps/web/components/sidebar/workspace-switcher.tsx
Normal file
124
apps/web/components/sidebar/workspace-switcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
apps/web/components/templates/index.ts
Normal file
9
apps/web/components/templates/index.ts
Normal 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";
|
||||
383
apps/web/components/templates/template-editor.tsx
Normal file
383
apps/web/components/templates/template-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
337
apps/web/components/templates/template-picker.tsx
Normal file
337
apps/web/components/templates/template-picker.tsx
Normal 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 };
|
||||
50
apps/web/components/ui/avatar.tsx
Normal file
50
apps/web/components/ui/avatar.tsx
Normal 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 };
|
||||
40
apps/web/components/ui/badge.tsx
Normal file
40
apps/web/components/ui/badge.tsx
Normal 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 };
|
||||
58
apps/web/components/ui/button.tsx
Normal file
58
apps/web/components/ui/button.tsx
Normal 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 };
|
||||
201
apps/web/components/ui/dropdown-menu.tsx
Normal file
201
apps/web/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
};
|
||||
9
apps/web/components/ui/index.ts
Normal file
9
apps/web/components/ui/index.ts
Normal 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";
|
||||
26
apps/web/components/ui/input.tsx
Normal file
26
apps/web/components/ui/input.tsx
Normal 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 };
|
||||
168
apps/web/components/ui/presence.tsx
Normal file
168
apps/web/components/ui/presence.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
apps/web/components/ui/scroll-area.tsx
Normal file
48
apps/web/components/ui/scroll-area.tsx
Normal 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 };
|
||||
31
apps/web/components/ui/separator.tsx
Normal file
31
apps/web/components/ui/separator.tsx
Normal 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 };
|
||||
145
apps/web/components/ui/sheet.tsx
Normal file
145
apps/web/components/ui/sheet.tsx
Normal 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,
|
||||
};
|
||||
32
apps/web/components/ui/tooltip.tsx
Normal file
32
apps/web/components/ui/tooltip.tsx
Normal 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 };
|
||||
209
apps/web/components/views/board/board-card.tsx
Normal file
209
apps/web/components/views/board/board-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
apps/web/components/views/board/board-column.tsx
Normal file
101
apps/web/components/views/board/board-column.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
319
apps/web/components/views/board/board-view.tsx
Normal file
319
apps/web/components/views/board/board-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
apps/web/components/views/board/index.ts
Normal file
6
apps/web/components/views/board/index.ts
Normal 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";
|
||||
359
apps/web/components/views/config/filter-bar.tsx
Normal file
359
apps/web/components/views/config/filter-bar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
apps/web/components/views/config/group-config.tsx
Normal file
71
apps/web/components/views/config/group-config.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
apps/web/components/views/config/index.ts
Normal file
5
apps/web/components/views/config/index.ts
Normal 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";
|
||||
198
apps/web/components/views/config/sort-config.tsx
Normal file
198
apps/web/components/views/config/sort-config.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
apps/web/components/views/config/view-switcher.tsx
Normal file
47
apps/web/components/views/config/view-switcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
apps/web/components/views/config/view-toolbar.tsx
Normal file
31
apps/web/components/views/config/view-toolbar.tsx
Normal 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
Loading…
Reference in a new issue