'use client'; import { memo, useCallback, useRef, useState } from 'react'; import { Reorder } from 'motion/react'; import { MoreHorizontal } from 'lucide-react'; import { cn } from '@/lib/utils'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { SceneThumbnailContent } from '@/components/stage/scene-thumbnail-content'; import { SCENE_CREATION_ENABLED } from '@/lib/edit/scene-creation-enabled'; import { sceneHasIssues } from '@/lib/edit/content-validation'; import { useNearViewport } from '@/lib/hooks/use-near-viewport'; import type { Scene } from '@/lib/types/stage'; import { useCanvasStore } from '@/lib/store/canvas'; import { useStageStore } from '@/lib/store/stage'; import { useI18n } from '@/lib/hooks/use-i18n'; interface ThumbItemProps { readonly scene: Scene; readonly index: number; readonly active: boolean; readonly canDelete: boolean; readonly onActivate: () => void; readonly onDuplicate: () => void; readonly onDelete: () => void; } function ThumbItemComponent({ scene, index, active, canDelete, onActivate, onDuplicate, onDelete, }: ThumbItemProps) { const { t } = useI18n(); const viewportSize = useCanvasStore.use.viewportSize(); const viewportRatio = useCanvasStore.use.viewportRatio(); const updateScene = useStageStore.use.updateScene(); const ref = useRef(null); const visible = useNearViewport(ref); // Inline title-edit state. `draft` is only used while renaming; when // idle we derive the visible title from `scene.title` directly so an // external rename (other tab / Duplicate suffix) shows up without a // sync effect. `startRename` seeds `draft` once at session start. const [renaming, setRenaming] = useState(false); const [draft, setDraft] = useState(scene.title); const inputRef = useRef(null); const startRename = useCallback(() => { setDraft(scene.title); setRenaming(true); // Focus + select on next tick so the input is mounted. queueMicrotask(() => { const el = inputRef.current; if (el) { el.focus(); el.select(); } }); }, [scene.title]); const commitRename = useCallback(() => { const trimmed = draft.trim(); if (trimmed && trimmed !== scene.title) { updateScene(scene.id, { title: trimmed }); } setRenaming(false); }, [draft, scene.id, scene.title, updateScene]); const cancelRename = useCallback(() => { // Reset draft to the canonical title so the next rename session // starts from a clean state. setDraft(scene.title); setRenaming(false); }, [scene.title]); return (
{ if (renaming) return; if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onActivate(); } }} // Matches playback `SceneSidebar` tile family — index badge + // title header row above an aspect-video thumbnail card, whole // tile flipped to violet-50 + ring when active. Differences from // playback: inline title edit via the more-actions menu, and a // hover-revealed three-dot menu (the only editor affordance // overlaid on the playback shape). className={cn( 'group/thumb relative flex cursor-pointer select-none flex-col gap-1 rounded-lg p-1.5', 'outline-none transition-colors duration-150', active ? 'bg-violet-50 ring-1 ring-violet-200 dark:bg-violet-900/20 dark:ring-violet-700' : 'hover:bg-zinc-50/80 dark:hover:bg-zinc-800/50', )} > {/* Page-level "incomplete content" dot — surfaces a scene with any content issue (blank narration / no actions / unbound cue …) so the user can spot it in the rail without opening every page. */} {sceneHasIssues(scene) && ( )} {/* Scene header — index badge + title. Title doubles as the inline rename surface when `renaming` is true. */}
{index + 1} {renaming ? ( setDraft(e.target.value)} onKeyDown={(e) => { e.stopPropagation(); if (e.key === 'Enter') { e.preventDefault(); commitRename(); } else if (e.key === 'Escape') { e.preventDefault(); cancelRename(); } }} onBlur={commitRename} onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} aria-label={t('edit.nav.rename')} className={cn( 'min-w-0 flex-1 truncate rounded-sm bg-white px-1 py-0 text-xs font-bold outline-none', 'ring-1 ring-violet-400 focus:ring-violet-500', 'text-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 dark:ring-violet-500', )} /> ) : ( { e.stopPropagation(); startRename(); }} className={cn( 'truncate text-xs font-bold transition-colors', active ? 'text-violet-700 dark:text-violet-300' : 'text-zinc-600 group-hover/thumb:text-zinc-900 dark:text-zinc-300 dark:group-hover/thumb:text-zinc-100', )} title={scene.title} > {scene.title || `${t('edit.sceneType.' + scene.type)} ${index + 1}`} )}
{/* Three-dot overflow menu — hover-revealed, always visible while open. Hidden during inline rename so the input has the full header row. */} {!renaming && ( e.stopPropagation()} > {t('edit.nav.rename')} {SCENE_CREATION_ENABLED && ( {t('edit.nav.duplicate')} )} {t('edit.nav.delete')} )}
{/* Thumbnail card — same shape as playback SceneSidebar's tile. */}
); } /** * One tile in the Pro mode rail. Visual structure deliberately mirrors * playback `SceneSidebar` — index badge + title row above an aspect- * video thumbnail card, whole tile rounded with a violet background + * ring when active — so the two sidebars read as the same component * family across mode toggle. Editor-only additions: hover-revealed * three-dot menu (Rename / Duplicate / Delete) and inline title rename * (also reachable via double-click on the title text). * * All scene types are first-class — slides render a live * `ThumbnailSlide`, non-slide scenes render the same stylised mockups * playback's `SceneSidebar` uses. EditShell renders non-slide scenes * read-only inside Pro mode (no auto-exit on click). */ export const ThumbItem = memo(ThumbItemComponent);