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
217 lines
5.5 KiB
TypeScript
217 lines
5.5 KiB
TypeScript
"use client";
|
|
|
|
import { Node, mergeAttributes } from "@tiptap/react";
|
|
import {
|
|
NodeViewWrapper,
|
|
ReactNodeViewRenderer,
|
|
type NodeViewProps,
|
|
} from "@tiptap/react";
|
|
import { ExternalLink, Link2 } from "lucide-react";
|
|
import * as React from "react";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
function mockEmbedMeta(url: string): {
|
|
title: string;
|
|
description: string;
|
|
image: string;
|
|
} {
|
|
try {
|
|
const u = new URL(url);
|
|
const host = u.hostname.replace(/^www\./, "");
|
|
return {
|
|
title: host || "Link preview",
|
|
description: `Preview for ${url}`,
|
|
image: `https://picsum.photos/seed/${encodeURIComponent(host)}/400/200`,
|
|
};
|
|
} catch {
|
|
return {
|
|
title: "Link preview",
|
|
description: url,
|
|
image: "",
|
|
};
|
|
}
|
|
}
|
|
|
|
function EmbedView(props: NodeViewProps) {
|
|
const { node, updateAttributes, selected } = props;
|
|
const url = String(node.attrs.url ?? "");
|
|
const title = String(node.attrs.title ?? "");
|
|
const description = String(node.attrs.description ?? "");
|
|
const image = String(node.attrs.image ?? "");
|
|
const [draft, setDraft] = React.useState(url);
|
|
|
|
React.useEffect(() => {
|
|
setDraft(url);
|
|
}, [url]);
|
|
|
|
const applyUrl = React.useCallback(() => {
|
|
const next = draft.trim();
|
|
if (!next) return;
|
|
let normalized = next;
|
|
if (!/^https?:\/\//i.test(normalized)) {
|
|
normalized = `https://${normalized}`;
|
|
}
|
|
const meta = mockEmbedMeta(normalized);
|
|
updateAttributes({
|
|
url: normalized,
|
|
title: meta.title,
|
|
description: meta.description,
|
|
image: meta.image,
|
|
});
|
|
}, [draft, updateAttributes]);
|
|
|
|
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
applyUrl();
|
|
}
|
|
};
|
|
|
|
if (!url) {
|
|
return (
|
|
<NodeViewWrapper
|
|
className={cn(
|
|
"embed-block my-4 rounded-lg border border-dashed border-border bg-muted/30 p-4 dark:bg-muted/15",
|
|
selected && "ring-2 ring-primary/40 ring-offset-2 ring-offset-background",
|
|
)}
|
|
data-type="embed"
|
|
>
|
|
<div className="flex items-center gap-2 text-muted-foreground">
|
|
<Link2 className="size-4 shrink-0" aria-hidden />
|
|
<input
|
|
className="min-w-0 flex-1 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground outline-none ring-primary/30 focus:ring-2"
|
|
placeholder="Paste a URL and press Enter…"
|
|
type="url"
|
|
value={draft}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
onBlur={applyUrl}
|
|
onKeyDown={onKeyDown}
|
|
/>
|
|
</div>
|
|
</NodeViewWrapper>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<NodeViewWrapper
|
|
className={cn(
|
|
"embed-block my-4 overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-sm",
|
|
selected && "ring-2 ring-primary/40 ring-offset-2 ring-offset-background",
|
|
)}
|
|
data-type="embed"
|
|
data-url={url}
|
|
>
|
|
{image ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
alt=""
|
|
className="h-40 w-full object-cover"
|
|
src={image}
|
|
/>
|
|
) : null}
|
|
<div className="flex flex-col gap-1 p-4">
|
|
<a
|
|
className="inline-flex items-center gap-1.5 text-base font-semibold text-primary hover:underline"
|
|
href={url}
|
|
rel="noopener noreferrer"
|
|
target="_blank"
|
|
>
|
|
{title || url}
|
|
<ExternalLink className="size-3.5 opacity-70" aria-hidden />
|
|
</a>
|
|
{description ? (
|
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
|
{description}
|
|
</p>
|
|
) : null}
|
|
<p className="truncate text-xs text-muted-foreground/80">{url}</p>
|
|
</div>
|
|
</NodeViewWrapper>
|
|
);
|
|
}
|
|
|
|
export const Embed = Node.create({
|
|
name: "embed",
|
|
group: "block",
|
|
atom: true,
|
|
draggable: true,
|
|
selectable: true,
|
|
|
|
addAttributes() {
|
|
return {
|
|
url: {
|
|
default: "",
|
|
parseHTML: (el) => el.getAttribute("data-url") ?? "",
|
|
renderHTML: (attrs) =>
|
|
attrs.url ? { "data-url": attrs.url } : {},
|
|
},
|
|
title: {
|
|
default: "",
|
|
parseHTML: (el) => el.querySelector("a")?.textContent?.trim() ?? "",
|
|
renderHTML: () => ({}),
|
|
},
|
|
description: {
|
|
default: "",
|
|
parseHTML: (el) =>
|
|
el.querySelector("p")?.textContent?.trim() ?? "",
|
|
renderHTML: () => ({}),
|
|
},
|
|
image: {
|
|
default: "",
|
|
parseHTML: () => "",
|
|
renderHTML: () => ({}),
|
|
},
|
|
};
|
|
},
|
|
|
|
parseHTML() {
|
|
return [
|
|
{
|
|
tag: 'div[data-type="embed"]',
|
|
},
|
|
];
|
|
},
|
|
|
|
renderHTML({ node, HTMLAttributes }) {
|
|
const url = String(node.attrs.url ?? "");
|
|
const title = String(node.attrs.title ?? url);
|
|
const description = String(node.attrs.description ?? "");
|
|
return [
|
|
"div",
|
|
mergeAttributes(HTMLAttributes, {
|
|
"data-type": "embed",
|
|
...(url ? { "data-url": url } : {}),
|
|
}),
|
|
[
|
|
"a",
|
|
{
|
|
href: url || "#",
|
|
rel: "noopener noreferrer",
|
|
target: "_blank",
|
|
},
|
|
title,
|
|
],
|
|
["p", {}, description],
|
|
];
|
|
},
|
|
|
|
addNodeView() {
|
|
return ReactNodeViewRenderer(EmbedView);
|
|
},
|
|
|
|
addKeyboardShortcuts() {
|
|
return {
|
|
"Mod-Shift-Alt-e": () =>
|
|
this.editor.commands.insertContent({
|
|
type: this.name,
|
|
attrs: {
|
|
url: "",
|
|
title: "",
|
|
description: "",
|
|
image: "",
|
|
},
|
|
}),
|
|
};
|
|
},
|
|
});
|