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
134 lines
3.6 KiB
TypeScript
134 lines
3.6 KiB
TypeScript
"use client";
|
|
|
|
import { Node, mergeAttributes } from "@tiptap/react";
|
|
import {
|
|
NodeViewContent,
|
|
NodeViewWrapper,
|
|
ReactNodeViewRenderer,
|
|
type NodeViewProps,
|
|
} from "@tiptap/react";
|
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
|
import * as React from "react";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
function ToggleView(props: NodeViewProps) {
|
|
const { node, updateAttributes, selected } = props;
|
|
const open = Boolean(node.attrs.open);
|
|
const title = typeof node.attrs.title === "string" ? node.attrs.title : "";
|
|
|
|
const onTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
updateAttributes({ title: e.target.value });
|
|
};
|
|
|
|
return (
|
|
<NodeViewWrapper
|
|
as="details"
|
|
className={cn(
|
|
"toggle-block my-2 rounded-lg border border-border bg-card/60 dark:bg-card/40",
|
|
selected && "ring-2 ring-primary/40 ring-offset-2 ring-offset-background",
|
|
)}
|
|
data-type="toggle"
|
|
open={open}
|
|
onToggle={(e: React.SyntheticEvent<HTMLDetailsElement>) => {
|
|
updateAttributes({ open: e.currentTarget.open });
|
|
}}
|
|
>
|
|
<summary
|
|
className="flex cursor-pointer list-none items-center gap-2 px-2 py-1.5 text-[15px] font-medium text-foreground marker:hidden [&::-webkit-details-marker]:hidden"
|
|
>
|
|
<span
|
|
className="flex size-6 shrink-0 items-center justify-center text-muted-foreground"
|
|
contentEditable={false}
|
|
>
|
|
{open ? (
|
|
<ChevronDown className="size-4" aria-hidden />
|
|
) : (
|
|
<ChevronRight className="size-4" aria-hidden />
|
|
)}
|
|
</span>
|
|
<input
|
|
className="min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
|
|
placeholder="Toggle"
|
|
type="text"
|
|
value={title}
|
|
onChange={onTitleChange}
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
</summary>
|
|
<div
|
|
className="toggle-body border-t border-border px-2 py-2 pl-9 [&_.ProseMirror_p]:my-1"
|
|
data-toggle-body="true"
|
|
>
|
|
<NodeViewContent />
|
|
</div>
|
|
</NodeViewWrapper>
|
|
);
|
|
}
|
|
|
|
export const Toggle = Node.create({
|
|
name: "toggle",
|
|
group: "block",
|
|
content: "block+",
|
|
defining: true,
|
|
|
|
addAttributes() {
|
|
return {
|
|
open: {
|
|
default: true,
|
|
parseHTML: (el) =>
|
|
el instanceof HTMLElement && el.hasAttribute("open"),
|
|
renderHTML: (attrs) => (attrs.open ? { open: "" } : {}),
|
|
},
|
|
title: {
|
|
default: "",
|
|
parseHTML: (el) => {
|
|
if (!(el instanceof HTMLElement)) return "";
|
|
const s = el.querySelector(":scope > summary");
|
|
return s?.textContent?.trim() ?? "";
|
|
},
|
|
renderHTML: (attrs) => ({}),
|
|
},
|
|
};
|
|
},
|
|
|
|
parseHTML() {
|
|
return [
|
|
{
|
|
tag: 'details[data-type="toggle"]',
|
|
contentElement: 'div[data-toggle-body="true"]',
|
|
},
|
|
];
|
|
},
|
|
|
|
renderHTML({ node, HTMLAttributes }) {
|
|
return [
|
|
"details",
|
|
mergeAttributes(HTMLAttributes, {
|
|
"data-type": "toggle",
|
|
...(node.attrs.open ? { open: "" } : {}),
|
|
}),
|
|
[
|
|
"summary",
|
|
{ class: "toggle-summary" },
|
|
node.attrs.title ? String(node.attrs.title) : "\u00a0",
|
|
],
|
|
["div", { "data-toggle-body": "true", class: "toggle-body" }, 0],
|
|
];
|
|
},
|
|
|
|
addNodeView() {
|
|
return ReactNodeViewRenderer(ToggleView);
|
|
},
|
|
|
|
addKeyboardShortcuts() {
|
|
return {
|
|
"Mod-Shift-Alt-t": () =>
|
|
this.editor.commands.insertContent({
|
|
type: this.name,
|
|
attrs: { open: true, title: "" },
|
|
content: [{ type: "paragraph" }],
|
|
}),
|
|
};
|
|
},
|
|
});
|