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
101 lines
2.9 KiB
TypeScript
101 lines
2.9 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import type { Editor } from "@tiptap/react";
|
|
import { cn } from "@/lib/utils";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
|
|
type BlockDragHandleProps = {
|
|
editor: Editor | null;
|
|
containerRef: React.RefObject<HTMLElement | null>;
|
|
};
|
|
|
|
/**
|
|
* Visual drag handle on the left of the hovered top-level block.
|
|
* Drag-to-reorder is not wired (would require custom NodeViews).
|
|
*/
|
|
export function BlockDragHandle({ editor, containerRef }: BlockDragHandleProps) {
|
|
const [pos, setPos] = React.useState<{
|
|
top: number;
|
|
left: number;
|
|
} | null>(null);
|
|
|
|
React.useEffect(() => {
|
|
if (!editor) return;
|
|
|
|
const root = editor.view.dom;
|
|
const onMove = (e: MouseEvent) => {
|
|
const coords = editor.view.posAtCoords({
|
|
left: e.clientX,
|
|
top: e.clientY,
|
|
});
|
|
if (!coords) {
|
|
setPos(null);
|
|
return;
|
|
}
|
|
|
|
const $pos = editor.state.doc.resolve(coords.pos);
|
|
for (let d = $pos.depth; d > 0; d--) {
|
|
const node = $pos.node(d);
|
|
const parent = $pos.node(d - 1);
|
|
if (parent.type.name === "doc" && node.isBlock) {
|
|
const start = $pos.before(d);
|
|
const el = editor.view.nodeDOM(start);
|
|
if (el instanceof HTMLElement) {
|
|
const cr = el.getBoundingClientRect();
|
|
const wrap = containerRef.current?.getBoundingClientRect();
|
|
if (wrap) {
|
|
setPos({
|
|
top: cr.top - wrap.top + cr.height / 2 - 14,
|
|
left: -2,
|
|
});
|
|
} else {
|
|
setPos({
|
|
top: cr.top + cr.height / 2 - 14,
|
|
left: cr.left - 28,
|
|
});
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
setPos(null);
|
|
};
|
|
|
|
const onLeave = () => setPos(null);
|
|
|
|
root.addEventListener("mousemove", onMove);
|
|
root.addEventListener("mouseleave", onLeave);
|
|
return () => {
|
|
root.removeEventListener("mousemove", onMove);
|
|
root.removeEventListener("mouseleave", onLeave);
|
|
};
|
|
}, [editor, containerRef]);
|
|
|
|
if (!editor || !pos) return null;
|
|
|
|
return (
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
draggable
|
|
onDragStart={(e) => {
|
|
e.preventDefault();
|
|
}}
|
|
className={cn(
|
|
"pointer-events-auto absolute z-10 flex size-7 items-center justify-center rounded-md",
|
|
"text-muted-foreground transition-colors hover:bg-accent hover:text-primary",
|
|
)}
|
|
style={{ top: pos.top, left: pos.left }}
|
|
aria-label="Drag block (visual only)"
|
|
>
|
|
<span className="select-none text-base leading-none tracking-tight" aria-hidden>
|
|
⠿
|
|
</span>
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="left">Drag to reorder (coming soon)</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|