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
197 lines
5.6 KiB
TypeScript
197 lines
5.6 KiB
TypeScript
"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>
|
|
);
|
|
}
|