ubiquitous-invention/apps/web/components/search/search-result.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

159 lines
4.1 KiB
TypeScript

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