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
344 lines
9.7 KiB
TypeScript
344 lines
9.7 KiB
TypeScript
"use client";
|
|
|
|
import type { ReactNode } from "react";
|
|
import type { Editor } from "@tiptap/react";
|
|
import {
|
|
Bold,
|
|
Italic,
|
|
Underline,
|
|
Strikethrough,
|
|
Code,
|
|
Highlighter,
|
|
ChevronDown,
|
|
List,
|
|
ListOrdered,
|
|
ListTodo,
|
|
AlignLeft,
|
|
AlignCenter,
|
|
AlignRight,
|
|
ImageIcon,
|
|
Minus,
|
|
Table2,
|
|
Undo2,
|
|
Redo2,
|
|
Heading1,
|
|
Heading2,
|
|
Heading3,
|
|
Pilcrow,
|
|
SquareCode,
|
|
} from "lucide-react";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
|
|
function ch(editor: Editor) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
return editor.chain().focus() as any;
|
|
}
|
|
|
|
type EditorToolbarProps = {
|
|
editor: Editor | null;
|
|
className?: string;
|
|
};
|
|
|
|
export function EditorToolbar({ editor, className }: EditorToolbarProps) {
|
|
if (!editor) return null;
|
|
|
|
const blockLabel = editor.isActive("heading", { level: 1 })
|
|
? "Heading 1"
|
|
: editor.isActive("heading", { level: 2 })
|
|
? "Heading 2"
|
|
: editor.isActive("heading", { level: 3 })
|
|
? "Heading 3"
|
|
: "Paragraph";
|
|
|
|
return (
|
|
<TooltipProvider delayDuration={200}>
|
|
<div
|
|
className={cn(
|
|
"flex flex-wrap items-center gap-0.5 border-b border-border bg-muted/30 px-2 py-1.5",
|
|
className,
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-0.5">
|
|
<ToolbarIcon
|
|
label="Bold"
|
|
shortcut="⌘B"
|
|
active={editor.isActive("bold")}
|
|
onClick={() => ch(editor).toggleBold().run()}
|
|
>
|
|
<Bold className="size-4" />
|
|
</ToolbarIcon>
|
|
<ToolbarIcon
|
|
label="Italic"
|
|
shortcut="⌘I"
|
|
active={editor.isActive("italic")}
|
|
onClick={() => ch(editor).toggleItalic().run()}
|
|
>
|
|
<Italic className="size-4" />
|
|
</ToolbarIcon>
|
|
<ToolbarIcon
|
|
label="Underline"
|
|
shortcut="⌘U"
|
|
active={editor.isActive("underline")}
|
|
onClick={() => ch(editor).toggleUnderline().run()}
|
|
>
|
|
<Underline className="size-4" />
|
|
</ToolbarIcon>
|
|
<ToolbarIcon
|
|
label="Strikethrough"
|
|
shortcut="⌘⇧S"
|
|
active={editor.isActive("strike")}
|
|
onClick={() => ch(editor).toggleStrike().run()}
|
|
>
|
|
<Strikethrough className="size-4" />
|
|
</ToolbarIcon>
|
|
<ToolbarIcon
|
|
label="Code"
|
|
shortcut="⌘E"
|
|
active={editor.isActive("code")}
|
|
onClick={() => ch(editor).toggleCode().run()}
|
|
>
|
|
<Code className="size-4" />
|
|
</ToolbarIcon>
|
|
<ToolbarIcon
|
|
label="Highlight"
|
|
shortcut="⌘⇧H"
|
|
active={editor.isActive("highlight")}
|
|
onClick={() => ch(editor).toggleHighlight().run()}
|
|
>
|
|
<Highlighter className="size-4" />
|
|
</ToolbarIcon>
|
|
</div>
|
|
|
|
<Separator orientation="vertical" className="mx-1 h-7" />
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className={cn(
|
|
"h-8 gap-1 px-2 text-xs font-normal text-muted-foreground",
|
|
editor.isActive("heading") && "text-primary",
|
|
)}
|
|
>
|
|
{blockLabel}
|
|
<ChevronDown className="size-3.5 opacity-60" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className="w-48">
|
|
<DropdownMenuItem
|
|
onClick={() => ch(editor).setParagraph().run()}
|
|
className="gap-2"
|
|
>
|
|
<Pilcrow className="size-4" />
|
|
Paragraph
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => ch(editor).setHeading({ level: 1 }).run()}
|
|
className="gap-2"
|
|
>
|
|
<Heading1 className="size-4" />
|
|
Heading 1
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => ch(editor).setHeading({ level: 2 }).run()}
|
|
className="gap-2"
|
|
>
|
|
<Heading2 className="size-4" />
|
|
Heading 2
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => ch(editor).setHeading({ level: 3 }).run()}
|
|
className="gap-2"
|
|
>
|
|
<Heading3 className="size-4" />
|
|
Heading 3
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<Separator orientation="vertical" className="mx-1 h-7" />
|
|
|
|
<div className="flex items-center gap-0.5">
|
|
<ToolbarIcon
|
|
label="Bullet list"
|
|
shortcut="⌘⇧8"
|
|
active={editor.isActive("bulletList")}
|
|
onClick={() => ch(editor).toggleBulletList().run()}
|
|
>
|
|
<List className="size-4" />
|
|
</ToolbarIcon>
|
|
<ToolbarIcon
|
|
label="Ordered list"
|
|
shortcut="⌘⇧7"
|
|
active={editor.isActive("orderedList")}
|
|
onClick={() => ch(editor).toggleOrderedList().run()}
|
|
>
|
|
<ListOrdered className="size-4" />
|
|
</ToolbarIcon>
|
|
<ToolbarIcon
|
|
label="Task list"
|
|
shortcut="⌘⇧9"
|
|
active={editor.isActive("taskList")}
|
|
onClick={() => ch(editor).toggleTaskList().run()}
|
|
>
|
|
<ListTodo className="size-4" />
|
|
</ToolbarIcon>
|
|
</div>
|
|
|
|
<Separator orientation="vertical" className="mx-1 h-7" />
|
|
|
|
<div className="flex items-center gap-0.5">
|
|
<ToolbarIcon
|
|
label="Align left"
|
|
shortcut=""
|
|
active={
|
|
editor.isActive({ textAlign: "left" }) ||
|
|
(!editor.isActive({ textAlign: "center" }) &&
|
|
!editor.isActive({ textAlign: "right" }))
|
|
}
|
|
onClick={() => ch(editor).setTextAlign("left").run()}
|
|
>
|
|
<AlignLeft className="size-4" />
|
|
</ToolbarIcon>
|
|
<ToolbarIcon
|
|
label="Align center"
|
|
shortcut=""
|
|
active={editor.isActive({ textAlign: "center" })}
|
|
onClick={() => ch(editor).setTextAlign("center").run()}
|
|
>
|
|
<AlignCenter className="size-4" />
|
|
</ToolbarIcon>
|
|
<ToolbarIcon
|
|
label="Align right"
|
|
shortcut=""
|
|
active={editor.isActive({ textAlign: "right" })}
|
|
onClick={() => ch(editor).setTextAlign("right").run()}
|
|
>
|
|
<AlignRight className="size-4" />
|
|
</ToolbarIcon>
|
|
</div>
|
|
|
|
<Separator orientation="vertical" className="mx-1 h-7" />
|
|
|
|
<div className="flex items-center gap-0.5">
|
|
<ToolbarIcon
|
|
label="Insert image"
|
|
shortcut=""
|
|
active={false}
|
|
onClick={() => {
|
|
const src = window.prompt("Image URL");
|
|
if (src) ch(editor).setImage({ src }).run();
|
|
}}
|
|
>
|
|
<ImageIcon className="size-4" />
|
|
</ToolbarIcon>
|
|
<ToolbarIcon
|
|
label="Horizontal rule"
|
|
shortcut=""
|
|
active={editor.isActive("horizontalRule")}
|
|
onClick={() => ch(editor).setHorizontalRule().run()}
|
|
>
|
|
<Minus className="size-4" />
|
|
</ToolbarIcon>
|
|
<ToolbarIcon
|
|
label="Insert table"
|
|
shortcut=""
|
|
active={editor.isActive("table")}
|
|
onClick={() =>
|
|
ch(editor)
|
|
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
|
.run()
|
|
}
|
|
>
|
|
<Table2 className="size-4" />
|
|
</ToolbarIcon>
|
|
<ToolbarIcon
|
|
label="Code block"
|
|
shortcut="⌘⌥C"
|
|
active={editor.isActive("codeBlock")}
|
|
onClick={() => ch(editor).toggleCodeBlock().run()}
|
|
>
|
|
<SquareCode className="size-4" />
|
|
</ToolbarIcon>
|
|
</div>
|
|
|
|
<Separator orientation="vertical" className="mx-1 h-7" />
|
|
|
|
<div className="flex items-center gap-0.5">
|
|
<ToolbarIcon
|
|
label="Undo"
|
|
shortcut="⌘Z"
|
|
active={false}
|
|
onClick={() => ch(editor).undo().run()}
|
|
>
|
|
<Undo2 className="size-4" />
|
|
</ToolbarIcon>
|
|
<ToolbarIcon
|
|
label="Redo"
|
|
shortcut="⌘⇧Z"
|
|
active={false}
|
|
onClick={() => ch(editor).redo().run()}
|
|
>
|
|
<Redo2 className="size-4" />
|
|
</ToolbarIcon>
|
|
</div>
|
|
</div>
|
|
</TooltipProvider>
|
|
);
|
|
}
|
|
|
|
function ToolbarIcon({
|
|
label,
|
|
shortcut,
|
|
active,
|
|
onClick,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
shortcut: string;
|
|
active: boolean;
|
|
onClick: () => void;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className={cn(
|
|
"size-8 shrink-0",
|
|
active && "bg-primary/15 text-primary hover:bg-primary/20 hover:text-primary",
|
|
)}
|
|
onClick={onClick}
|
|
aria-pressed={active}
|
|
>
|
|
{children}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">
|
|
<span>{label}</span>
|
|
{shortcut ? (
|
|
<span className="ml-2 text-xs opacity-70">{shortcut}</span>
|
|
) : null}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|