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
2026-03-26 23:39:16 -04:00
|
|
|
"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);
|
|
|
|
|
|
multi-tenancy: promote workspaces to top-level table
Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`,
which made it impossible to put a real RLS-friendly tenant boundary on the schema
or to give each workspace a stable URL slug. This commit:
- Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook).
- Migrates the 8 anchor tables (objects, workspace_members, object_type_defs,
property_definitions, templates, forms, markdown_backlog_items,
cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`,
with a hand-augmented data-copy migration that preserves IDs and slug-collision-
proofs on backfill.
- Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper
that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All
tenant-scoped routers (objects, types, properties, templates, forms, search,
ai, relations, favorites) now flow through it.
- Updates the web app to pass `workspace` slugs from the URL (or store) instead
of the old `workspaceId`, including a workspace-sync layer that rewrites
/<UUID>/... links to /<slug>/...
- Updates the MCP tools (list_objects, create_object, search_objects) and the
workspace://{handle}/tree resource to accept either a slug or UUID so existing
agents keep working.
- Adds a Create Workspace dialog and a Workspace Settings page (rename + slug
rename with redirect, owner-only archive).
Verified locally against a fresh Postgres: migration applies cleanly, slug
uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution
works in both directions, and ON DELETE CASCADE cleans up child rows in the
correct workspace only.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
|
|
|
const workspaceHandle = workspace?.slug ?? workspace?.id;
|
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
2026-03-26 23:39:16 -04:00
|
|
|
|
|
|
|
|
const [query, setQuery] = useState("");
|
|
|
|
|
const debounced = useDebouncedValue(query, DEBOUNCE_MS);
|
|
|
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
multi-tenancy: promote workspaces to top-level table
Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`,
which made it impossible to put a real RLS-friendly tenant boundary on the schema
or to give each workspace a stable URL slug. This commit:
- Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook).
- Migrates the 8 anchor tables (objects, workspace_members, object_type_defs,
property_definitions, templates, forms, markdown_backlog_items,
cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`,
with a hand-augmented data-copy migration that preserves IDs and slug-collision-
proofs on backfill.
- Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper
that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All
tenant-scoped routers (objects, types, properties, templates, forms, search,
ai, relations, favorites) now flow through it.
- Updates the web app to pass `workspace` slugs from the URL (or store) instead
of the old `workspaceId`, including a workspace-sync layer that rewrites
/<UUID>/... links to /<slug>/...
- Updates the MCP tools (list_objects, create_object, search_objects) and the
workspace://{handle}/tree resource to accept either a slug or UUID so existing
agents keep working.
- Adds a Create Workspace dialog and a Workspace Settings page (rename + slug
rename with redirect, owner-only archive).
Verified locally against a fresh Postgres: migration applies cleanly, slug
uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution
works in both directions, and ON DELETE CASCADE cleans up child rows in the
correct workspace only.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
|
|
|
const searchEnabled =
|
|
|
|
|
open && debounced.trim().length > 0 && Boolean(workspaceHandle);
|
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
2026-03-26 23:39:16 -04:00
|
|
|
|
|
|
|
|
const searchQuery = api.search.search.useQuery(
|
|
|
|
|
{
|
multi-tenancy: promote workspaces to top-level table
Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`,
which made it impossible to put a real RLS-friendly tenant boundary on the schema
or to give each workspace a stable URL slug. This commit:
- Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook).
- Migrates the 8 anchor tables (objects, workspace_members, object_type_defs,
property_definitions, templates, forms, markdown_backlog_items,
cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`,
with a hand-augmented data-copy migration that preserves IDs and slug-collision-
proofs on backfill.
- Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper
that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All
tenant-scoped routers (objects, types, properties, templates, forms, search,
ai, relations, favorites) now flow through it.
- Updates the web app to pass `workspace` slugs from the URL (or store) instead
of the old `workspaceId`, including a workspace-sync layer that rewrites
/<UUID>/... links to /<slug>/...
- Updates the MCP tools (list_objects, create_object, search_objects) and the
workspace://{handle}/tree resource to accept either a slug or UUID so existing
agents keep working.
- Adds a Create Workspace dialog and a Workspace Settings page (rename + slug
rename with redirect, owner-only archive).
Verified locally against a fresh Postgres: migration applies cleanly, slug
uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution
works in both directions, and ON DELETE CASCADE cleans up child rows in the
correct workspace only.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
|
|
|
workspace: workspaceHandle!,
|
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
2026-03-26 23:39:16 -04:00
|
|
|
query: debounced.trim(),
|
|
|
|
|
limit: 20,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
enabled: searchEnabled,
|
|
|
|
|
retry: false,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const recentQuery = api.search.recent.useQuery(
|
multi-tenancy: promote workspaces to top-level table
Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`,
which made it impossible to put a real RLS-friendly tenant boundary on the schema
or to give each workspace a stable URL slug. This commit:
- Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook).
- Migrates the 8 anchor tables (objects, workspace_members, object_type_defs,
property_definitions, templates, forms, markdown_backlog_items,
cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`,
with a hand-augmented data-copy migration that preserves IDs and slug-collision-
proofs on backfill.
- Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper
that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All
tenant-scoped routers (objects, types, properties, templates, forms, search,
ai, relations, favorites) now flow through it.
- Updates the web app to pass `workspace` slugs from the URL (or store) instead
of the old `workspaceId`, including a workspace-sync layer that rewrites
/<UUID>/... links to /<slug>/...
- Updates the MCP tools (list_objects, create_object, search_objects) and the
workspace://{handle}/tree resource to accept either a slug or UUID so existing
agents keep working.
- Adds a Create Workspace dialog and a Workspace Settings page (rename + slug
rename with redirect, owner-only archive).
Verified locally against a fresh Postgres: migration applies cleanly, slug
uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution
works in both directions, and ON DELETE CASCADE cleans up child rows in the
correct workspace only.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
|
|
|
{ workspace: workspaceHandle!, limit: 10 },
|
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
2026-03-26 23:39:16 -04:00
|
|
|
{
|
multi-tenancy: promote workspaces to top-level table
Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`,
which made it impossible to put a real RLS-friendly tenant boundary on the schema
or to give each workspace a stable URL slug. This commit:
- Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook).
- Migrates the 8 anchor tables (objects, workspace_members, object_type_defs,
property_definitions, templates, forms, markdown_backlog_items,
cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`,
with a hand-augmented data-copy migration that preserves IDs and slug-collision-
proofs on backfill.
- Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper
that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All
tenant-scoped routers (objects, types, properties, templates, forms, search,
ai, relations, favorites) now flow through it.
- Updates the web app to pass `workspace` slugs from the URL (or store) instead
of the old `workspaceId`, including a workspace-sync layer that rewrites
/<UUID>/... links to /<slug>/...
- Updates the MCP tools (list_objects, create_object, search_objects) and the
workspace://{handle}/tree resource to accept either a slug or UUID so existing
agents keep working.
- Adds a Create Workspace dialog and a Workspace Settings page (rename + slug
rename with redirect, owner-only archive).
Verified locally against a fresh Postgres: migration applies cleanly, slug
uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution
works in both directions, and ON DELETE CASCADE cleans up child rows in the
correct workspace only.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
|
|
|
enabled: open && Boolean(workspaceHandle),
|
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
2026-03-26 23:39:16 -04:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|