| 1 | import { useCallback, useRef } from "react"; |
| 2 | import { |
| 3 | ArrowRight, |
| 4 | Download, |
| 5 | Save, |
| 6 | GripVertical, |
| 7 | Layout, |
| 8 | Merge, |
| 9 | Scissors, |
| 10 | } from "lucide-react"; |
| 11 | |
| 12 | import { Button } from "@/components/ui/button"; |
| 13 | import { cn } from "@/lib/utils"; |
| 14 | |
| 15 | export interface StoryboardSegment { |
| 16 | id: string; |
| 17 | text: string; |
| 18 | } |
| 19 | |
| 20 | export function downloadStoryboardSegments(segments: StoryboardSegment[]) { |
| 21 | const content = segments |
| 22 | .map((seg, i) => `[SHOT ${String(i + 1).padStart(2, "0")}]\n${seg.text}`) |
| 23 | .join("\n\n"); |
| 24 | const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); |
| 25 | const url = URL.createObjectURL(blob); |
| 26 | const a = document.createElement("a"); |
| 27 | a.href = url; |
| 28 | a.download = "shot-plan.txt"; |
| 29 | a.click(); |
| 30 | URL.revokeObjectURL(url); |
| 31 | } |
| 32 | |
| 33 | interface StoryboardScriptEditorProps { |
| 34 | segments: StoryboardSegment[]; |
| 35 | onSegmentChange: (segmentId: string, text: string) => void; |
| 36 | onDeleteSegment: (segmentId: string) => void; |
| 37 | onApplyNext: () => void; |
| 38 | onAutoGenerate?: () => void; |
| 39 | autoGenerateLabel?: string; |
| 40 | onSplitAt?: ( |
| 41 | segmentId: string, |
| 42 | cursorPos: number, |
| 43 | segmentText: string, |
| 44 | ) => void; |
| 45 | onMergeUp?: (lowerSegmentId: string, mergedText: string) => void; |
| 46 | onSegmentFocus?: (segmentId: string) => void; |
| 47 | onSegmentBlur?: (segmentId: string) => void; |
| 48 | applyNextDisabled?: boolean; |
| 49 | applyNextLabel?: string; |
| 50 | splitMergeDisabled?: boolean; |
| 51 | readOnly?: boolean; |
| 52 | onSave?: () => void | Promise<void>; |
| 53 | saveDisabled?: boolean; |
| 54 | saving?: boolean; |
| 55 | } |
| 56 | |
| 57 | export function StoryboardScriptEditor({ |
| 58 | segments, |
| 59 | onSegmentChange, |
| 60 | onDeleteSegment, |
| 61 | onApplyNext, |
| 62 | onSplitAt, |
| 63 | onMergeUp, |
| 64 | onSegmentFocus, |
| 65 | onSegmentBlur, |
| 66 | applyNextDisabled = false, |
| 67 | applyNextLabel = "Next Step", |
| 68 | onAutoGenerate, |
| 69 | autoGenerateLabel = "Approve & Auto Generate", |
| 70 | splitMergeDisabled = false, |
| 71 | readOnly = false, |
| 72 | onSave, |
| 73 | saveDisabled, |
| 74 | saving = false, |
| 75 | }: StoryboardScriptEditorProps) { |
| 76 | const updateText = useCallback( |
| 77 | (id: string, text: string) => { |
| 78 | onSegmentChange(id, text); |
| 79 | }, |
| 80 | [onSegmentChange], |
| 81 | ); |
| 82 | |
| 83 | const splitAt = useCallback( |
| 84 | (id: string, cursorPos: number) => { |
| 85 | const seg = segments.find((s) => s.id === id); |
| 86 | if (!seg) return; |
| 87 | if (onSplitAt) { |
| 88 | onSplitAt(id, cursorPos, seg.text); |
| 89 | return; |
| 90 | } |
| 91 | const idx = segments.findIndex((s) => s.id === id); |
| 92 | if (idx === -1) return; |
| 93 | const before = seg.text.slice(0, cursorPos); |
| 94 | const after = seg.text.slice(cursorPos); |
| 95 | const newSeg: StoryboardSegment = { |
| 96 | id: `seg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, |
| 97 | text: after.trimStart(), |
| 98 | }; |
| 99 | const updated = [...segments]; |
| 100 | updated[idx] = { ...seg, text: before.trimEnd() }; |
| 101 | updated.splice(idx + 1, 0, newSeg); |
| 102 | onSegmentChange(id, before.trimEnd()); |
| 103 | onSegmentChange(newSeg.id, newSeg.text); |
| 104 | }, |
| 105 | [onSegmentChange, onSplitAt, segments], |
| 106 | ); |
| 107 | |
| 108 | const mergeUp = useCallback( |
| 109 | (idx: number) => { |
| 110 | if (idx <= 0) return; |
| 111 | const lower = segments[idx]; |
| 112 | const upper = segments[idx - 1]; |
| 113 | const mergedText = `${upper.text.trimEnd()}\n\n${lower.text.trimStart()}`; |
| 114 | if (onMergeUp) { |
| 115 | onMergeUp(lower.id, mergedText); |
| 116 | return; |
| 117 | } |
| 118 | const updated = [...segments]; |
| 119 | updated[idx - 1] = { |
| 120 | ...updated[idx - 1], |
| 121 | text: mergedText, |
| 122 | }; |
| 123 | updated.splice(idx, 1); |
| 124 | onSegmentChange(upper.id, mergedText); |
| 125 | }, |
| 126 | [onMergeUp, onSegmentChange, segments], |
| 127 | ); |
| 128 | |
| 129 | const handleDownload = useCallback(() => { |
| 130 | downloadStoryboardSegments(segments); |
| 131 | }, [segments]); |
| 132 | |
| 133 | if (segments.length === 0) { |
| 134 | return ( |
| 135 | <div className="flex h-full flex-col items-center justify-center px-6 text-center"> |
| 136 | <div className="relative mb-6"> |
| 137 | <div className="absolute -inset-4 rounded-2xl bg-foreground/[0.02]" /> |
| 138 | <div className="relative grid h-14 w-14 place-items-center rounded-2xl bg-foreground/[0.04] ring-1 ring-inset ring-foreground/[0.06]"> |
| 139 | <Layout className="h-6 w-6 text-foreground/40" /> |
| 140 | </div> |
| 141 | </div> |
| 142 | <p className="text-[15px] font-semibold text-foreground/90"> |
| 143 | Building shot plan |
| 144 | </p> |
| 145 | <p className="mt-2 max-w-xs text-sm leading-relaxed text-muted-foreground"> |
| 146 | The story is being divided into production-ready shots. |
| 147 | </p> |
| 148 | </div> |
| 149 | ); |
| 150 | } |
| 151 | |
| 152 | return ( |
| 153 | <div className="flex h-full flex-col"> |
| 154 | <div className="flex items-center justify-between border-b border-border/40 px-4 py-2"> |
| 155 | <div className="flex items-center gap-2 text-[11px] text-muted-foreground"> |
| 156 | <Scissors className="h-3 w-3" /> |
| 157 | Shift+Enter Split |
| 158 | <span className="mx-1 text-border/60">|</span> |
| 159 | <Merge className="h-3 w-3" /> |
| 160 | Hover center to merge |
| 161 | </div> |
| 162 | <div className="flex items-center gap-2"> |
| 163 | <button |
| 164 | type="button" |
| 165 | onClick={() => void onSave?.()} |
| 166 | disabled={saveDisabled ?? readOnly ?? saving} |
| 167 | className={cn( |
| 168 | "flex items-center gap-1 rounded-md px-2 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-foreground/[0.06] hover:text-foreground", |
| 169 | "disabled:pointer-events-none disabled:opacity-40", |
| 170 | )} |
| 171 | > |
| 172 | <Save className="h-3 w-3" /> |
| 173 | {saving ? "Saving..." : "Save"} |
| 174 | </button> |
| 175 | <button |
| 176 | type="button" |
| 177 | onClick={handleDownload} |
| 178 | className="flex items-center gap-1 rounded-md px-2 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-foreground/[0.06] hover:text-foreground" |
| 179 | > |
| 180 | <Download className="h-3 w-3" /> |
| 181 | Download |
| 182 | </button> |
| 183 | <span className="rounded-md bg-foreground/[0.05] px-2 py-0.5 text-[11px] tabular-nums text-muted-foreground"> |
| 184 | {segments.length} shots |
| 185 | </span> |
| 186 | </div> |
| 187 | </div> |
| 188 | |
| 189 | <div className="min-h-0 flex-1 overflow-y-auto px-4 py-3 scrollbar-thin"> |
| 190 | <div className="space-y-0"> |
| 191 | {segments.map((seg, idx) => ( |
| 192 | <SegmentBlock |
| 193 | key={seg.id} |
| 194 | segment={seg} |
| 195 | index={idx} |
| 196 | total={segments.length} |
| 197 | readOnly={readOnly} |
| 198 | splitMergeDisabled={splitMergeDisabled} |
| 199 | onTextChange={(text) => updateText(seg.id, text)} |
| 200 | onFocus={() => onSegmentFocus?.(seg.id)} |
| 201 | onBlur={() => onSegmentBlur?.(seg.id)} |
| 202 | onSplit={(cursorPos) => splitAt(seg.id, cursorPos)} |
| 203 | onMergeUp={() => mergeUp(idx)} |
| 204 | onDeleteSegment={(segmentId) => onDeleteSegment(segmentId)} |
| 205 | /> |
| 206 | ))} |
| 207 | </div> |
| 208 | </div> |
| 209 | |
| 210 | {/* bottom action bar */} |
| 211 | <div className="shrink-0 border-t border-border/50 px-4 py-3"> |
| 212 | {onAutoGenerate ? ( |
| 213 | <div className="flex gap-2"> |
| 214 | <Button |
| 215 | onClick={onAutoGenerate} |
| 216 | variant="outline" |
| 217 | disabled={segments.length === 0 || applyNextDisabled} |
| 218 | className={cn( |
| 219 | "w-full rounded-lg text-[13px] font-medium", |
| 220 | // "bg-foreground text-background", |
| 221 | // "hover:bg-foreground/90 active:scale-[0.99]", |
| 222 | "h-9 transition-all duration-200", |
| 223 | "disabled:opacity-40", |
| 224 | )} |
| 225 | > |
| 226 | {autoGenerateLabel} |
| 227 | <ArrowRight className="ml-1.5 h-3.5 w-3.5" /> |
| 228 | </Button> |
| 229 | <Button |
| 230 | onClick={onApplyNext} |
| 231 | disabled={segments.length === 0 || applyNextDisabled} |
| 232 | variant="outline" |
| 233 | className={cn( |
| 234 | "w-full rounded-lg text-[13px] font-medium", |
| 235 | "h-9 transition-all duration-200", |
| 236 | "disabled:opacity-40", |
| 237 | )} |
| 238 | > |
| 239 | {applyNextLabel} |
| 240 | <ArrowRight className="ml-1.5 h-3.5 w-3.5" /> |
| 241 | </Button> |
| 242 | </div> |
| 243 | ) : ( |
| 244 | <Button |
| 245 | onClick={onApplyNext} |
| 246 | disabled={segments.length === 0 || applyNextDisabled} |
| 247 | className={cn( |
| 248 | "w-full rounded-lg text-[13px] font-medium", |
| 249 | "bg-foreground text-background", |
| 250 | "hover:bg-foreground/90 active:scale-[0.99]", |
| 251 | "h-9 transition-all duration-200", |
| 252 | "disabled:opacity-40", |
| 253 | )} |
| 254 | > |
| 255 | {applyNextLabel} |
| 256 | <ArrowRight className="ml-1.5 h-3.5 w-3.5" /> |
| 257 | </Button> |
| 258 | )} |
| 259 | </div> |
| 260 | </div> |
| 261 | ); |
| 262 | } |
| 263 | |
| 264 | function SegmentBlock({ |
| 265 | segment, |
| 266 | index, |
| 267 | total, |
| 268 | readOnly = false, |
| 269 | splitMergeDisabled = false, |
| 270 | onTextChange, |
| 271 | onFocus, |
| 272 | onBlur, |
| 273 | onSplit, |
| 274 | onMergeUp, |
| 275 | onDeleteSegment, |
| 276 | }: { |
| 277 | segment: StoryboardSegment; |
| 278 | index: number; |
| 279 | total: number; |
| 280 | readOnly?: boolean; |
| 281 | splitMergeDisabled?: boolean; |
| 282 | onTextChange: (text: string) => void; |
| 283 | onFocus?: () => void; |
| 284 | onBlur?: () => void; |
| 285 | onSplit: (cursorPos: number) => void; |
| 286 | onMergeUp: () => void; |
| 287 | onDeleteSegment: (segmentId: string) => void; |
| 288 | }) { |
| 289 | const textareaRef = useRef<HTMLTextAreaElement>(null); |
| 290 | |
| 291 | const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { |
| 292 | if ( |
| 293 | readOnly || |
| 294 | splitMergeDisabled || |
| 295 | e.key !== "Enter" || |
| 296 | !e.shiftKey || |
| 297 | e.nativeEvent.isComposing |
| 298 | ) { |
| 299 | return; |
| 300 | } |
| 301 | e.preventDefault(); |
| 302 | const pos = e.currentTarget.selectionStart; |
| 303 | onSplit(pos); |
| 304 | }; |
| 305 | |
| 306 | return ( |
| 307 | <div className="group"> |
| 308 | {index > 0 && ( |
| 309 | <div className="relative flex items-center gap-2 py-1"> |
| 310 | <div className="h-px flex-1 bg-border/50" /> |
| 311 | <button |
| 312 | type="button" |
| 313 | onClick={onMergeUp} |
| 314 | disabled={splitMergeDisabled} |
| 315 | className="flex items-center gap-1 rounded-md px-2 py-0.5 text-[10px] text-muted-foreground opacity-0 transition-all duration-200 hover:bg-foreground/[0.06] hover:text-foreground group-hover:opacity-100 disabled:pointer-events-none disabled:opacity-30" |
| 316 | > |
| 317 | <Merge className="h-2.5 w-2.5" /> |
| 318 | Merge |
| 319 | </button> |
| 320 | <div className="h-px flex-1 bg-border/50" /> |
| 321 | </div> |
| 322 | )} |
| 323 | <div |
| 324 | className={cn( |
| 325 | "relative rounded-xl border border-border/40 bg-card/20 p-3 transition-all duration-200", |
| 326 | "focus-within:border-foreground/15 focus-within:bg-card/40 focus-within:shadow-sm", |
| 327 | )} |
| 328 | > |
| 329 | <div className="mb-1.5 flex items-center justify-between"> |
| 330 | <div className="flex items-center gap-1.5"> |
| 331 | <GripVertical className="h-3 w-3 text-muted-foreground/30" /> |
| 332 | <span className="text-[10px] font-semibold tabular-nums tracking-wider text-muted-foreground/60"> |
| 333 | Shot {String(index + 1).padStart(2, "0")}/ |
| 334 | {String(total).padStart(2, "0")} |
| 335 | </span> |
| 336 | </div> |
| 337 | {/* <span className="text-[10px] tabular-nums text-muted-foreground/40"> |
| 338 | {segment.text.length} chars |
| 339 | </span> */} |
| 340 | <span |
| 341 | className="text-[12px] tabular-nums text-muted-foreground/40 cursor-pointer hover:text-muted-foreground transition-all duration-200" |
| 342 | onClick={() => onDeleteSegment(segment.id)} |
| 343 | > |
| 344 | Delete |
| 345 | </span> |
| 346 | </div> |
| 347 | <textarea |
| 348 | ref={textareaRef} |
| 349 | value={segment.text} |
| 350 | readOnly={readOnly} |
| 351 | onFocus={onFocus} |
| 352 | onBlur={onBlur} |
| 353 | onChange={(e) => { |
| 354 | if (readOnly) return; |
| 355 | onTextChange(e.target.value); |
| 356 | e.target.style.height = "auto"; |
| 357 | e.target.style.height = `${e.target.scrollHeight}px`; |
| 358 | }} |
| 359 | onKeyDown={onKeyDown} |
| 360 | rows={2} |
| 361 | className={cn( |
| 362 | "w-full resize-none bg-transparent text-[13px] leading-[1.85] text-foreground/88", |
| 363 | "focus:outline-none", |
| 364 | "min-h-[56px]", |
| 365 | )} |
| 366 | /> |
| 367 | </div> |
| 368 | </div> |
| 369 | ); |
| 370 | } |
| 371 |