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
159 lines
4.1 KiB
TypeScript
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>
|
|
);
|
|
}
|