"use client"; import { AnimatePresence, LayoutGroup, MotionConfig, motion, useReducedMotion } from "motion/react"; import { BoldIcon, CheckIcon, ChevronDownIcon, ChevronRightIcon, FileTextIcon, ItalicIcon, LinkIcon, ListIcon, MessageSquarePlusIcon, MessageSquareTextIcon, QuoteIcon, RedoIcon, Share2Icon, SparklesIcon, StrikethroughIcon, UnderlineIcon, UndoIcon, XIcon, } from "lucide-react"; import { Fragment, type ReactNode, useEffect, useId, useState } from "react"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Kbd } from "@/components/ui/kbd"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; export type CollaboratorTone = "info" | "success" | "violet" | "warning"; export interface Collaborator { id: string; name: string; initials: string; tone: CollaboratorTone; agent?: boolean; } export interface DocComment { id: string; block: string; author: string; text: string; time: string; reply?: { author: string; text: string }; } export interface DocSuggestion { block: string; text: string; note: string; } export interface CoWritingDocProps { title?: string; people?: Collaborator[]; comments?: DocComment[]; suggestions?: DocSuggestion[]; writer?: string; className?: string; } const toneCls: Record = { info: { caret: "bg-info", tag: "bg-blue-600 text-white", soft: "bg-info/12", avatar: "bg-info/16 text-info-foreground", mark: "decoration-info/60", text: "text-info-foreground", border: "border-info/32", }, success: { caret: "bg-success", tag: "bg-emerald-700 text-white", soft: "bg-success/16", avatar: "bg-success/16 text-success-foreground", mark: "decoration-success/60", text: "text-success-foreground", border: "border-success/32", }, violet: { caret: "bg-violet-500", tag: "bg-violet-600 text-white", soft: "bg-violet-500/14", avatar: "bg-violet-500/16 text-violet-700 dark:text-violet-300", mark: "decoration-violet-500/60", text: "text-violet-700 dark:text-violet-300", border: "border-violet-500/32", }, warning: { caret: "bg-warning", tag: "bg-amber-700 text-white", soft: "bg-warning/14", avatar: "bg-warning/18 text-amber-800 dark:text-warning-foreground", mark: "decoration-warning/70", text: "text-warning-foreground", border: "border-warning/32", }, }; const defaultPeople: Collaborator[] = [ { id: "mina", name: "Mina Park", initials: "MP", tone: "info" }, { id: "scout", name: "Scout", initials: "S", tone: "success", agent: true }, { id: "quill", name: "Quill", initials: "Q", tone: "violet", agent: true }, { id: "theo", name: "Theo Brandt", initials: "TB", tone: "warning" }, ]; type Block = | { id: string; type: "h1" | "h2"; text: string } | { id: string; type: "p"; text: string; mark?: { phrase: string; by: string } } | { id: string; type: "li"; text: string; mark?: { phrase: string; by: string } }; const blocks: Block[] = [ { id: "title", type: "h1", text: "Orbit Calendar: launch narrative" }, { id: "h-why", type: "h2", text: "Why now" }, { id: "why", type: "p", text: "Small agencies run their week on calendars that were built for one person. Producers juggle three or four of them, and most clashes are found the morning they happen.", mark: { phrase: "built for one person", by: "scout" }, }, { id: "gap", type: "p", text: "Nobody owns the shared view. Studio leads keep a spreadsheet on the side, and it is out of date by Tuesday.", }, { id: "h-story", type: "h2", text: "The story we tell" }, { id: "story", type: "p", text: "Orbit Calendar is the planner the whole studio shares. Every booking, shoot and deadline sits on one page.", }, { id: "promise", type: "p", text: "We lead with time saved, not features.", }, { id: "h-proof", type: "h2", text: "Proof points" }, { id: "p1", type: "li", text: "Pilot studios cut scheduling clashes by 41% in six weeks.", mark: { phrase: "41% in six weeks", by: "mina" } }, { id: "p2", type: "li", text: "Producers saved about three hours a week on planning." }, { id: "p3", type: "li", text: "Nine of twelve interviewed agencies use three or more calendars today." }, ]; const defaultComments: DocComment[] = [ { id: "c1", block: "why", author: "scout", text: "Source for this: 9 of 12 interviewed agencies run three or more calendars. Want me to add the quote?", time: "2m", }, { id: "c2", block: "p1", author: "mina", text: "Can we lead with the agency count instead? It's the stronger number.", time: "14m", reply: { author: "scout", text: "Moved it up in the outline. Kept 41% as the second point." }, }, ]; const defaultSuggestions: DocSuggestion[] = [ { block: "story", text: " No more checking three apps to know who's free on Thursday.", note: "Added a concrete moment so the promise lands.", }, { block: "promise", text: " Three hours back every week is the headline.", note: "Turned the principle into the number we lead with.", }, ]; function Caret({ person, label, layoutId, idle }: { person: Collaborator; label?: string; layoutId: string; idle?: boolean }) { const t = toneCls[person.tone]; const reduce = useReducedMotion(); const text = label ?? person.name; const [tag, setTag] = useState(true); useEffect(() => { setTag(true); const id = setTimeout(() => setTag(false), 1800); return () => clearTimeout(id); }, [text]); return ( {person.agent && } {text} ); } const minaStops = [ { block: "gap", after: "shared view." }, { block: "gap", after: "spreadsheet" }, { block: "p2", after: "three hours" }, { block: "gap", after: "Tuesday." }, ]; export function CoWritingDoc({ title = "Orbit Calendar: launch narrative", people = defaultPeople, comments: initialComments = defaultComments, suggestions = defaultSuggestions, writer = "quill", className, }: CoWritingDocProps) { const reduce = useReducedMotion(); const group = useId(); const [extra, setExtra] = useState>({}); const [flash, setFlash] = useState(null); const [sIndex, setSIndex] = useState(0); const [typed, setTyped] = useState(0); const [comments, setComments] = useState(initialComments); const [resolved, setResolved] = useState([]); const [activeComment, setActiveComment] = useState(null); const [openInline, setOpenInline] = useState(null); const [mina, setMina] = useState(0); const [draftFor, setDraftFor] = useState(null); const [draft, setDraft] = useState(""); const [marks, setMarks] = useState([]); const byId = (pid: string) => people.find((p) => p.id === pid) ?? people[0]; const quill = byId(writer); const suggestion = suggestions[sIndex]; const typing = Boolean(suggestion) && typed < (suggestion?.text.length ?? 0); const pending = Boolean(suggestion) && !typing; useEffect(() => { if (!suggestion) return; if (reduce) { setTyped(suggestion.text.length); return; } if (typed >= suggestion.text.length) return; const delay = typed === 0 ? 1600 : suggestion.text[typed - 1] === " " ? 70 : 38; const t = setTimeout(() => setTyped((n) => n + 1), delay); return () => clearTimeout(t); }, [suggestion, typed, reduce]); useEffect(() => { if (reduce) return; const t = setInterval(() => setMina((m) => (m + 1) % minaStops.length), 3400); return () => clearInterval(t); }, [reduce]); useEffect(() => { if (!flash) return; const t = setTimeout(() => setFlash(null), 1400); return () => clearTimeout(t); }, [flash]); const resolveSuggestion = (accept: boolean) => { if (!suggestion) return; if (accept) { setExtra((e) => ({ ...e, [suggestion.block]: (e[suggestion.block] ?? "") + suggestion.text })); setFlash(suggestion.block); } setTyped(0); setSIndex((i) => i + 1); }; const replay = () => { setExtra({}); setTyped(0); setSIndex(0); setResolved([]); setComments(initialComments); }; const visibleComments = comments.filter((c) => !resolved.includes(c.id)); const minaStop = minaStops[mina]; const scout = byId("scout"); const minaP = byId("mina"); const renderText = (b: Block) => { const base = b.text + (extra[b.id] ?? ""); const parts: ReactNode[] = []; const rawMark = "mark" in b ? b.mark : undefined; const markFrom = rawMark ? base.indexOf(rawMark.phrase) : -1; const mark = rawMark && markFrom >= 0 ? { from: markFrom, to: markFrom + rawMark.phrase.length, by: rawMark.by } : undefined; const stopAt = minaStop.block === b.id ? base.indexOf(minaStop.after) : -1; const cursorAt = stopAt >= 0 ? stopAt + minaStop.after.length : -1; const cuts = new Set([0, base.length]); if (mark) { cuts.add(mark.from); cuts.add(mark.to); } if (cursorAt >= 0) cuts.add(Math.min(cursorAt, base.length)); const points = [...cuts].sort((a, z) => a - z); for (let i = 0; i < points.length - 1; i++) { const from = points[i]; const to = points[i + 1]; if (from === cursorAt) parts.push(); const seg = base.slice(from, to); const inMark = mark && from >= mark.from && to <= mark.to; if (inMark && mark) { const who = byId(mark.by); const comment = visibleComments.find((c) => c.block === b.id); parts.push( {seg} , ); if (mark.by === "scout" && to === mark.to) parts.push(); } else { parts.push({seg}); } } if (cursorAt >= base.length && cursorAt >= 0 && !points.includes(cursorAt)) parts.push(); if (flash === b.id) { return ( <> {parts} Accepted ); } if (suggestion && suggestion.block === b.id) { parts.push( {suggestion.text.slice(0, typed)} , ); parts.push( , ); if (pending) { parts.push( , ); } } else if (!suggestion && b.id === suggestions.at(-1)?.block) { parts.push(); } return parts; }; const commentCard = (c: DocComment, compact = false) => { const a = byId(c.author); const anchor = blocks.find((b) => b.id === c.block); const quote = anchor && "mark" in anchor ? anchor.mark : undefined; const r = c.reply ? byId(c.reply.author) : null; return ( setActiveComment(c.id)} onMouseLeave={() => setActiveComment(null)} transition={{ type: "spring", stiffness: 380, damping: 34 }} >
{a.agent ? : a.initials} {a.name} {c.time} setResolved((r) => [...r, c.id])} size="icon-xs" variant="ghost" /> } > Resolve
{quote && (

{quote.phrase}

)}

{c.text}

{c.reply && r && (
{r.agent ? : r.initials}

{r.name} {c.reply.text}

)} {draftFor === c.id ? (
{ e.preventDefault(); if (!draft.trim()) return; setComments((cs) => cs.map((x) => (x.id === c.id ? { ...x, reply: { author: "theo", text: draft.trim() } } : x))); setDraft(""); setDraftFor(null); }} > setDraft(e.target.value)} onKeyDown={(e) => e.key === "Escape" && setDraftFor(null)} placeholder="Reply…" value={draft} />
) : ( )}
); }; const toolbar: { icon: typeof BoldIcon; label: string; kbd?: string; wide?: boolean }[] = [ { icon: BoldIcon, label: "Bold", kbd: "⌘B" }, { icon: ItalicIcon, label: "Italic", kbd: "⌘I" }, { icon: UnderlineIcon, label: "Underline", kbd: "⌘U", wide: true }, { icon: StrikethroughIcon, label: "Strikethrough", wide: true }, ]; return (
{ if (!pending || e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); resolveSuggestion(true); } else if (e.key === "Backspace") { e.preventDefault(); resolveSuggestion(false); } }} >
Saved · {people.filter((p) => p.agent).length} agents editing
{people.map((p) => ( } > {p.agent ? : p.initials} {p.name} {p.agent ? " · agent" : ""} ))}
{toolbar.map((t) => ( setMarks((m) => (m.includes(t.label) ? m.filter((x) => x !== t.label) : [...m, t.label]))} size="icon-sm" variant="ghost" /> } > {t.label} {t.kbd && {t.kbd}} ))}
{!suggestion && ( )}
{blocks.map((b, i) => { const anchored = visibleComments.filter((c) => c.block === b.id); const prevIsLi = blocks[i - 1]?.type === "li"; return (
{b.type === "h1" ? (

{renderText(b)}

) : b.type === "h2" ? (

{renderText(b)}

) : (

{b.type === "li" && ( )} {renderText(b)}

)} {b.type === "h1" && (
Draft {people .filter((p) => p.id !== "theo") .map((p) => ( {p.agent ? : p.initials} ))} Mina Park, Scout and Quill · Edited just now
)} {anchored.length > 0 && (
{openInline === b.id && ( {anchored.map((c) => ( {commentCard(c, true)} ))} )}
)}
); })}
); }