ubiquitous-invention/apps/web/components/search/search-dialog.tsx
Randall Stillwell a508ece6e7 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 22:39:16 -05:00

470 lines
16 KiB
TypeScript

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