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
275 lines
7.8 KiB
TypeScript
275 lines
7.8 KiB
TypeScript
"use client";
|
|
|
|
import { Node, mergeAttributes } from "@tiptap/react";
|
|
import { PluginKey } from "@tiptap/pm/state";
|
|
import Suggestion, { type SuggestionProps } from "@tiptap/suggestion";
|
|
import { ReactRenderer } from "@tiptap/react";
|
|
import * as React from "react";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export type MentionEntityType = "user" | "object";
|
|
|
|
export type MentionItem = {
|
|
id: string;
|
|
label: string;
|
|
type: MentionEntityType;
|
|
};
|
|
|
|
const MOCK_ITEMS: MentionItem[] = [
|
|
{ id: "u1", label: "Alice Chen", type: "user" },
|
|
{ id: "u2", label: "Jordan Smith", type: "user" },
|
|
{ id: "u3", label: "Sam Rivera", type: "user" },
|
|
{ id: "o1", label: "Q1 Roadmap", type: "object" },
|
|
{ id: "o2", label: "Design System", type: "object" },
|
|
{ id: "o3", label: "Customer Onboarding", type: "object" },
|
|
];
|
|
|
|
export const mentionSuggestionPluginKey = new PluginKey("mentionSuggestion");
|
|
|
|
function filterItems(query: string): MentionItem[] {
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) return MOCK_ITEMS.slice(0, 8);
|
|
return MOCK_ITEMS.filter((item) =>
|
|
item.label.toLowerCase().includes(q),
|
|
).slice(0, 8);
|
|
}
|
|
|
|
function positionList(el: HTMLElement, props: SuggestionProps<MentionItem>) {
|
|
const rect = props.clientRect?.();
|
|
if (!rect) return;
|
|
el.style.position = "fixed";
|
|
el.style.left = `${rect.left}px`;
|
|
el.style.top = `${rect.bottom + 8}px`;
|
|
el.style.zIndex = "50";
|
|
el.style.minWidth = `${Math.max(rect.width, 220)}px`;
|
|
}
|
|
|
|
type ListProps = {
|
|
items: MentionItem[];
|
|
command: (item: MentionItem) => void;
|
|
selectedIndex: number;
|
|
};
|
|
|
|
function MentionList({ items, command, selectedIndex }: ListProps) {
|
|
const ref = React.useRef<HTMLDivElement>(null);
|
|
|
|
React.useEffect(() => {
|
|
const el = ref.current?.children[selectedIndex] as HTMLElement | undefined;
|
|
el?.scrollIntoView({ block: "nearest" });
|
|
}, [selectedIndex]);
|
|
|
|
if (items.length === 0) {
|
|
return (
|
|
<div className="rounded-lg border border-border bg-popover px-3 py-2 text-sm text-muted-foreground shadow-lg">
|
|
No matches
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className="max-h-[min(280px,50vh)] overflow-auto rounded-lg border border-border bg-popover p-1 shadow-lg"
|
|
role="listbox"
|
|
>
|
|
{items.map((item, index) => (
|
|
<button
|
|
key={`${item.type}-${item.id}`}
|
|
className={cn(
|
|
"flex w-full items-center rounded-md px-2 py-1.5 text-left text-sm outline-none transition-colors",
|
|
item.type === "user"
|
|
? "text-foreground"
|
|
: "text-foreground",
|
|
index === selectedIndex && "bg-accent",
|
|
)}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={index === selectedIndex}
|
|
onClick={() => command(item)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"mr-2 rounded-md px-1.5 py-0.5 text-xs font-medium",
|
|
item.type === "user"
|
|
? "bg-primary/15 text-primary dark:bg-primary/25"
|
|
: "bg-teal/15 text-teal dark:bg-teal/25",
|
|
)}
|
|
>
|
|
{item.type === "user" ? "User" : "Object"}
|
|
</span>
|
|
<span className="truncate">@{item.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const Mention = Node.create({
|
|
name: "mention",
|
|
group: "inline",
|
|
inline: true,
|
|
atom: true,
|
|
selectable: true,
|
|
|
|
addAttributes() {
|
|
return {
|
|
id: {
|
|
default: null as string | null,
|
|
parseHTML: (el) => el.getAttribute("data-id"),
|
|
renderHTML: (attrs) => (attrs.id ? { "data-id": attrs.id } : {}),
|
|
},
|
|
label: {
|
|
default: "",
|
|
parseHTML: (el) =>
|
|
el.textContent?.replace(/^@/, "").trim() ?? "",
|
|
renderHTML: () => ({}),
|
|
},
|
|
type: {
|
|
default: "user" as MentionEntityType,
|
|
parseHTML: (el) =>
|
|
(el.getAttribute("data-mention-type") as MentionEntityType | null) ??
|
|
"user",
|
|
renderHTML: (attrs) => ({
|
|
"data-mention-type": attrs.type ?? "user",
|
|
}),
|
|
},
|
|
};
|
|
},
|
|
|
|
parseHTML() {
|
|
return [
|
|
{
|
|
tag: 'span[data-type="mention"]',
|
|
},
|
|
];
|
|
},
|
|
|
|
renderHTML({ node, HTMLAttributes }) {
|
|
const label = String(node.attrs.label ?? "");
|
|
const isUser = node.attrs.type === "user";
|
|
return [
|
|
"span",
|
|
mergeAttributes(HTMLAttributes, {
|
|
"data-type": "mention",
|
|
"data-id": node.attrs.id ?? "",
|
|
"data-mention-type": node.attrs.type ?? "user",
|
|
class: cn(
|
|
"mention-chip rounded-md px-1.5 py-0.5 text-sm font-medium",
|
|
isUser
|
|
? "bg-primary/15 text-primary dark:bg-primary/25"
|
|
: "bg-teal/15 text-teal dark:bg-teal/25",
|
|
),
|
|
}),
|
|
`@${label}`,
|
|
];
|
|
},
|
|
|
|
renderText({ node }) {
|
|
return `@${node.attrs.label ?? ""}`;
|
|
},
|
|
|
|
addProseMirrorPlugins() {
|
|
const extensionName = this.name;
|
|
|
|
let renderer: ReactRenderer | null = null;
|
|
let lastProps: SuggestionProps<MentionItem> | null = null;
|
|
let selectedIndex = 0;
|
|
|
|
const updateSelected = (next: number) => {
|
|
if (!lastProps || !renderer) return;
|
|
const len = lastProps.items.length;
|
|
if (len === 0) return;
|
|
selectedIndex = ((next % len) + len) % len;
|
|
renderer.updateProps({
|
|
items: lastProps.items,
|
|
command: lastProps.command,
|
|
selectedIndex,
|
|
});
|
|
};
|
|
|
|
return [
|
|
Suggestion<MentionItem, MentionItem>({
|
|
editor: this.editor,
|
|
pluginKey: mentionSuggestionPluginKey,
|
|
char: "@",
|
|
allowSpaces: false,
|
|
items: ({ query }) => filterItems(query),
|
|
command: ({ editor, range, props }) => {
|
|
editor
|
|
.chain()
|
|
.focus()
|
|
.insertContentAt(range, [
|
|
{
|
|
type: extensionName,
|
|
attrs: {
|
|
id: props.id,
|
|
label: props.label,
|
|
type: props.type,
|
|
},
|
|
},
|
|
{ type: "text", text: " " },
|
|
])
|
|
.run();
|
|
},
|
|
render: () => ({
|
|
onStart: (props) => {
|
|
lastProps = props;
|
|
selectedIndex = 0;
|
|
renderer = new ReactRenderer(MentionList, {
|
|
editor: props.editor,
|
|
props: {
|
|
items: props.items,
|
|
command: props.command,
|
|
selectedIndex: 0,
|
|
},
|
|
});
|
|
renderer.element.style.pointerEvents = "auto";
|
|
document.body.appendChild(renderer.element);
|
|
positionList(renderer.element, props);
|
|
},
|
|
onUpdate: (props) => {
|
|
lastProps = props;
|
|
selectedIndex = Math.min(
|
|
selectedIndex,
|
|
Math.max(0, props.items.length - 1),
|
|
);
|
|
renderer?.updateProps({
|
|
items: props.items,
|
|
command: props.command,
|
|
selectedIndex,
|
|
});
|
|
if (renderer) positionList(renderer.element, props);
|
|
},
|
|
onExit: () => {
|
|
renderer?.destroy();
|
|
renderer = null;
|
|
lastProps = null;
|
|
selectedIndex = 0;
|
|
},
|
|
onKeyDown: ({ event }) => {
|
|
if (event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
updateSelected(selectedIndex + 1);
|
|
return true;
|
|
}
|
|
if (event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
updateSelected(selectedIndex - 1);
|
|
return true;
|
|
}
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
const item = lastProps?.items[selectedIndex];
|
|
if (item && lastProps) {
|
|
lastProps.command(item);
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
}),
|
|
}),
|
|
];
|
|
},
|
|
});
|