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
413 lines
12 KiB
TypeScript
413 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Extension,
|
||
type Editor,
|
||
type Range,
|
||
ReactRenderer,
|
||
} from "@tiptap/react";
|
||
import Suggestion, { type SuggestionKeyDownProps } from "@tiptap/suggestion";
|
||
import { PluginKey } from "@tiptap/pm/state";
|
||
import * as React from "react";
|
||
import {
|
||
Heading1,
|
||
Heading2,
|
||
Heading3,
|
||
List,
|
||
ListOrdered,
|
||
ListTodo,
|
||
ImageIcon,
|
||
Code2,
|
||
Table2,
|
||
Minus,
|
||
MessageSquareQuote,
|
||
ChevronRight,
|
||
Sparkles,
|
||
Type,
|
||
} from "lucide-react";
|
||
|
||
import { cn } from "@/lib/utils";
|
||
import { Button } from "@/components/ui/button";
|
||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
import { Separator } from "@/components/ui/separator";
|
||
|
||
export const slashCommandPluginKey = new PluginKey("slashCommand");
|
||
|
||
export type SlashItem = {
|
||
title: string;
|
||
description: string;
|
||
section: string;
|
||
icon: React.ComponentType<{ className?: string }>;
|
||
command: (opts: { editor: Editor; range: Range }) => void;
|
||
};
|
||
|
||
/** TipTap chain typing does not merge all extension commands on the base Editor type. */
|
||
function afterSlashDelete(editor: Editor, range: Range) {
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
return editor.chain().focus().deleteRange(range) as any;
|
||
}
|
||
|
||
function getSlashItems(): SlashItem[] {
|
||
return [
|
||
{
|
||
title: "Paragraph",
|
||
description: "Plain text block",
|
||
section: "Text",
|
||
icon: Type,
|
||
command: ({ editor, range }) => {
|
||
afterSlashDelete(editor, range).setParagraph().run();
|
||
},
|
||
},
|
||
{
|
||
title: "Heading 1",
|
||
description: "Large section title",
|
||
section: "Text",
|
||
icon: Heading1,
|
||
command: ({ editor, range }) => {
|
||
afterSlashDelete(editor, range).setHeading({ level: 1 }).run();
|
||
},
|
||
},
|
||
{
|
||
title: "Heading 2",
|
||
description: "Medium section title",
|
||
section: "Text",
|
||
icon: Heading2,
|
||
command: ({ editor, range }) => {
|
||
afterSlashDelete(editor, range).setHeading({ level: 2 }).run();
|
||
},
|
||
},
|
||
{
|
||
title: "Heading 3",
|
||
description: "Small section title",
|
||
section: "Text",
|
||
icon: Heading3,
|
||
command: ({ editor, range }) => {
|
||
afterSlashDelete(editor, range).setHeading({ level: 3 }).run();
|
||
},
|
||
},
|
||
{
|
||
title: "Bullet List",
|
||
description: "Unordered list",
|
||
section: "Lists",
|
||
icon: List,
|
||
command: ({ editor, range }) => {
|
||
afterSlashDelete(editor, range).toggleBulletList().run();
|
||
},
|
||
},
|
||
{
|
||
title: "Numbered List",
|
||
description: "Ordered list",
|
||
section: "Lists",
|
||
icon: ListOrdered,
|
||
command: ({ editor, range }) => {
|
||
afterSlashDelete(editor, range).toggleOrderedList().run();
|
||
},
|
||
},
|
||
{
|
||
title: "Task List",
|
||
description: "Checklist with tasks",
|
||
section: "Lists",
|
||
icon: ListTodo,
|
||
command: ({ editor, range }) => {
|
||
afterSlashDelete(editor, range).toggleTaskList().run();
|
||
},
|
||
},
|
||
{
|
||
title: "Image",
|
||
description: "Embed an image by URL",
|
||
section: "Media",
|
||
icon: ImageIcon,
|
||
command: ({ editor, range }) => {
|
||
const url = window.prompt("Image URL");
|
||
if (!url) return;
|
||
afterSlashDelete(editor, range).setImage({ src: url }).run();
|
||
},
|
||
},
|
||
{
|
||
title: "Code Block",
|
||
description: "Syntax-highlighted code",
|
||
section: "Media",
|
||
icon: Code2,
|
||
command: ({ editor, range }) => {
|
||
afterSlashDelete(editor, range).toggleCodeBlock().run();
|
||
},
|
||
},
|
||
{
|
||
title: "Table",
|
||
description: "3×3 table with header",
|
||
section: "Media",
|
||
icon: Table2,
|
||
command: ({ editor, range }) => {
|
||
afterSlashDelete(editor, range)
|
||
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||
.run();
|
||
},
|
||
},
|
||
{
|
||
title: "Horizontal Rule",
|
||
description: "Divider line",
|
||
section: "Media",
|
||
icon: Minus,
|
||
command: ({ editor, range }) => {
|
||
afterSlashDelete(editor, range).setHorizontalRule().run();
|
||
},
|
||
},
|
||
{
|
||
title: "Callout",
|
||
description: "Highlighted callout block",
|
||
section: "Advanced",
|
||
icon: Sparkles,
|
||
command: ({ editor, range }) => {
|
||
afterSlashDelete(editor, range)
|
||
.insertContent(
|
||
'<blockquote class="notion-callout border-l-4 border-teal pl-4 py-2 my-2 rounded-r-md bg-muted/50"><p>Callout</p></blockquote>',
|
||
)
|
||
.run();
|
||
},
|
||
},
|
||
{
|
||
title: "Toggle",
|
||
description: "Collapsible section (placeholder)",
|
||
section: "Advanced",
|
||
icon: ChevronRight,
|
||
command: ({ editor, range }) => {
|
||
afterSlashDelete(editor, range)
|
||
.insertContent({
|
||
type: "heading",
|
||
attrs: { level: 3 },
|
||
content: [{ type: "text", text: "Toggle heading" }],
|
||
})
|
||
.run();
|
||
},
|
||
},
|
||
{
|
||
title: "Quote",
|
||
description: "Blockquote citation",
|
||
section: "Advanced",
|
||
icon: MessageSquareQuote,
|
||
command: ({ editor, range }) => {
|
||
afterSlashDelete(editor, range).toggleBlockquote().run();
|
||
},
|
||
},
|
||
];
|
||
}
|
||
|
||
function filterItems(query: string): SlashItem[] {
|
||
const q = query.trim().toLowerCase();
|
||
const all = getSlashItems();
|
||
if (!q) return all;
|
||
return all.filter(
|
||
(item) =>
|
||
item.title.toLowerCase().includes(q) ||
|
||
item.description.toLowerCase().includes(q) ||
|
||
item.section.toLowerCase().includes(q),
|
||
);
|
||
}
|
||
|
||
export type SlashMenuProps = {
|
||
items: SlashItem[];
|
||
command: (item: SlashItem) => void;
|
||
editor: Editor;
|
||
};
|
||
|
||
export type SlashMenuHandle = {
|
||
onKeyDown: (props: SuggestionKeyDownProps) => boolean;
|
||
};
|
||
|
||
export const SlashMenu = React.forwardRef<SlashMenuHandle, SlashMenuProps>(
|
||
function SlashMenu({ items, command }, ref) {
|
||
const [selected, setSelected] = React.useState(0);
|
||
|
||
React.useEffect(() => {
|
||
setSelected(0);
|
||
}, [items]);
|
||
|
||
const grouped = React.useMemo(() => {
|
||
const map = new Map<string, SlashItem[]>();
|
||
for (const item of items) {
|
||
const list = map.get(item.section) ?? [];
|
||
list.push(item);
|
||
map.set(item.section, list);
|
||
}
|
||
return map;
|
||
}, [items]);
|
||
|
||
React.useImperativeHandle(ref, () => ({
|
||
onKeyDown: ({ event }) => {
|
||
if (items.length === 0) return false;
|
||
if (event.key === "ArrowDown") {
|
||
event.preventDefault();
|
||
setSelected((i) => (i + 1) % Math.max(items.length, 1));
|
||
return true;
|
||
}
|
||
if (event.key === "ArrowUp") {
|
||
event.preventDefault();
|
||
setSelected((i) =>
|
||
i === 0 ? Math.max(items.length - 1, 0) : i - 1,
|
||
);
|
||
return true;
|
||
}
|
||
if (event.key === "Enter") {
|
||
event.preventDefault();
|
||
const item = items[selected];
|
||
if (item) command(item);
|
||
return true;
|
||
}
|
||
return false;
|
||
},
|
||
}));
|
||
|
||
if (items.length === 0) {
|
||
return (
|
||
<div className="animate-in fade-in-0 zoom-in-95 duration-150 z-[100] w-72 rounded-lg border border-border bg-popover p-3 text-sm text-muted-foreground shadow-lg">
|
||
No matching commands
|
||
</div>
|
||
);
|
||
}
|
||
|
||
let flatIndex = 0;
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"animate-in fade-in-0 zoom-in-95 duration-150 z-[100] w-80 overflow-hidden rounded-lg border border-border bg-popover shadow-lg",
|
||
)}
|
||
>
|
||
<ScrollArea className="max-h-[min(320px,50vh)]">
|
||
<div className="p-1.5">
|
||
{Array.from(grouped.entries()).map(
|
||
([section, sectionItems], sectionIndex, sections) => (
|
||
<div key={section}>
|
||
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||
{section}
|
||
</div>
|
||
<div className="space-y-0.5">
|
||
{sectionItems.map((item) => {
|
||
const idx = flatIndex++;
|
||
const Icon = item.icon;
|
||
const isActive = idx === selected;
|
||
return (
|
||
<Button
|
||
key={`${section}-${item.title}`}
|
||
type="button"
|
||
variant="ghost"
|
||
className={cn(
|
||
"h-auto w-full justify-start gap-3 px-2 py-2 text-left font-normal",
|
||
isActive &&
|
||
"bg-primary/10 text-primary dark:bg-primary/20",
|
||
)}
|
||
onClick={() => command(item)}
|
||
onMouseEnter={() => setSelected(idx)}
|
||
>
|
||
<span
|
||
className={cn(
|
||
"flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-muted/60",
|
||
isActive && "border-primary/40 bg-primary/5",
|
||
)}
|
||
>
|
||
<Icon className="size-4 text-foreground" />
|
||
</span>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block text-sm font-medium leading-tight">
|
||
{item.title}
|
||
</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
{item.description}
|
||
</span>
|
||
</span>
|
||
</Button>
|
||
);
|
||
})}
|
||
</div>
|
||
{sectionIndex < sections.length - 1 ? (
|
||
<Separator className="my-2 opacity-50" />
|
||
) : null}
|
||
</div>
|
||
),
|
||
)}
|
||
</div>
|
||
</ScrollArea>
|
||
</div>
|
||
);
|
||
},
|
||
);
|
||
|
||
SlashMenu.displayName = "SlashMenu";
|
||
|
||
export const SlashCommand = Extension.create({
|
||
name: "slashCommand",
|
||
|
||
addProseMirrorPlugins() {
|
||
return [
|
||
Suggestion({
|
||
editor: this.editor,
|
||
pluginKey: slashCommandPluginKey,
|
||
char: "/",
|
||
allowSpaces: true,
|
||
startOfLine: false,
|
||
command: ({ editor: ed, range, props }) => {
|
||
props.command({ editor: ed, range });
|
||
},
|
||
items: ({ query }) => filterItems(query),
|
||
render: () => {
|
||
let component: ReactRenderer | null = null;
|
||
|
||
return {
|
||
onStart: (props) => {
|
||
component = new ReactRenderer(SlashMenu, {
|
||
props: {
|
||
...props,
|
||
command: (item: SlashItem) => {
|
||
item.command({
|
||
editor: props.editor,
|
||
range: props.range,
|
||
});
|
||
},
|
||
},
|
||
editor: props.editor,
|
||
});
|
||
component.element.style.position = "absolute";
|
||
component.element.style.zIndex = "100";
|
||
document.body.appendChild(component.element);
|
||
updatePosition(props);
|
||
},
|
||
onUpdate: (props) => {
|
||
component?.updateProps({
|
||
...props,
|
||
command: (item: SlashItem) => {
|
||
item.command({
|
||
editor: props.editor,
|
||
range: props.range,
|
||
});
|
||
},
|
||
});
|
||
updatePosition(props);
|
||
},
|
||
onExit: () => {
|
||
component?.destroy();
|
||
component = null;
|
||
},
|
||
onKeyDown: (keyProps) => {
|
||
const ref = component?.ref as SlashMenuHandle | null;
|
||
if (ref?.onKeyDown) {
|
||
return ref.onKeyDown(keyProps);
|
||
}
|
||
return false;
|
||
},
|
||
};
|
||
|
||
function updatePosition(p: {
|
||
clientRect?: (() => DOMRect | null) | null;
|
||
}) {
|
||
if (!component) return;
|
||
const rect = p.clientRect?.();
|
||
if (!rect) return;
|
||
const el = component.element;
|
||
el.style.left = `${rect.left}px`;
|
||
el.style.top = `${rect.bottom + 6}px`;
|
||
}
|
||
},
|
||
}),
|
||
];
|
||
},
|
||
});
|