ubiquitous-invention/apps/web/components/editor/extensions/callout.tsx
Randall Stillwell a508ece6e7 feat: Full project management application scaffold
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
2026-03-26 22:39:16 -05:00

167 lines
4.2 KiB
TypeScript

"use client";
import { Node, mergeAttributes } from "@tiptap/react";
import {
NodeViewContent,
NodeViewWrapper,
ReactNodeViewRenderer,
type NodeViewProps,
} from "@tiptap/react";
import {
AlertTriangle,
CheckCircle2,
Info,
XCircle,
} from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
export type CalloutType = "info" | "warning" | "success" | "error";
const TYPE_ICON: Record<
CalloutType,
React.ComponentType<{ className?: string }>
> = {
info: Info,
warning: AlertTriangle,
success: CheckCircle2,
error: XCircle,
};
const TYPE_STYLES: Record<
CalloutType,
{ box: string; icon: string }
> = {
info: {
box: "border border-blue-500/25 bg-blue-500/10 dark:bg-blue-500/15 dark:border-blue-400/30",
icon: "text-blue-600 dark:text-blue-400",
},
warning: {
box: "border border-amber-500/30 bg-amber-500/10 dark:bg-amber-500/15 dark:border-amber-400/35",
icon: "text-amber-700 dark:text-amber-400",
},
success: {
box: "border border-emerald-500/25 bg-emerald-500/10 dark:bg-emerald-500/15 dark:border-emerald-400/30",
icon: "text-emerald-700 dark:text-emerald-400",
},
error: {
box: "border border-red-500/25 bg-red-500/10 dark:bg-red-500/15 dark:border-red-400/30",
icon: "text-red-600 dark:text-red-400",
},
};
const DEFAULT_EMOJI: Record<CalloutType, string> = {
info: "💡",
warning: "⚠️",
success: "✅",
error: "⛔",
};
function CalloutView(props: NodeViewProps) {
const { node, updateAttributes, selected } = props;
const type = (node.attrs.type as CalloutType) ?? "info";
const emoji =
typeof node.attrs.emoji === "string" && node.attrs.emoji.length > 0
? node.attrs.emoji
: DEFAULT_EMOJI[type];
const Icon = TYPE_ICON[type];
const styles = TYPE_STYLES[type];
return (
<NodeViewWrapper
className={cn(
"callout-block my-3 flex gap-3 rounded-lg px-3 py-2.5",
styles.box,
selected && "ring-2 ring-primary/40 ring-offset-2 ring-offset-background",
)}
data-type="callout"
data-callout-type={type}
>
<div className="flex shrink-0 flex-col items-center gap-1 pt-0.5">
<span
className="cursor-pointer select-none text-lg leading-none"
contentEditable={false}
onClick={() => {
const order: CalloutType[] = [
"info",
"warning",
"success",
"error",
];
const next = order[(order.indexOf(type) + 1) % order.length];
updateAttributes({
type: next,
emoji: DEFAULT_EMOJI[next],
});
}}
title="Cycle callout type"
>
{emoji}
</span>
<Icon className={cn("size-4", styles.icon)} aria-hidden />
</div>
<div className="min-w-0 flex-1 [&_.ProseMirror_p]:my-1 [&_.ProseMirror_p:first-child]:mt-0 [&_.ProseMirror_p:last-child]:mb-0">
<NodeViewContent className="callout-content outline-none" />
</div>
</NodeViewWrapper>
);
}
export const Callout = Node.create({
name: "callout",
group: "block",
content: "block+",
defining: true,
addAttributes() {
return {
type: {
default: "info",
parseHTML: (el) =>
(el.getAttribute("data-callout-type") as CalloutType | null) ??
"info",
renderHTML: (attrs) => ({
"data-callout-type": attrs.type ?? "info",
}),
},
emoji: {
default: "",
parseHTML: (el) => el.getAttribute("data-emoji") ?? "",
renderHTML: (attrs) =>
attrs.emoji ? { "data-emoji": attrs.emoji } : {},
},
};
},
parseHTML() {
return [
{
tag: 'div[data-type="callout"]',
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(HTMLAttributes, { "data-type": "callout" }),
0,
];
},
addNodeView() {
return ReactNodeViewRenderer(CalloutView);
},
addKeyboardShortcuts() {
return {
"Mod-Shift-Alt-c": () =>
this.editor.commands.insertContent({
type: this.name,
attrs: { type: "info", emoji: DEFAULT_EMOJI.info },
content: [{ type: "paragraph" }],
}),
};
},
});