feat: full studio build -- light theme, canvas thumbnails, i18n (fa/en)
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Copy, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
clampSceneDuration,
|
||||
formatTimelineTime,
|
||||
SCENE_THUMB_GRADIENTS,
|
||||
STRIP_PX_PER_SECOND,
|
||||
} from "@/lib/studio-timeline";
|
||||
import type { Scene } from "@/lib/studio-types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SceneThumbnailBlockProps {
|
||||
scene: Scene;
|
||||
colorIndex: number;
|
||||
isActive: boolean;
|
||||
canDelete: boolean;
|
||||
pxPerSecond?: number;
|
||||
onSelect: () => void;
|
||||
onRename: (name: string) => void;
|
||||
onDurationChange: (duration: number) => void;
|
||||
onDuplicate: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function SceneThumbnailBlock({
|
||||
scene,
|
||||
colorIndex,
|
||||
isActive,
|
||||
canDelete,
|
||||
pxPerSecond = STRIP_PX_PER_SECOND,
|
||||
onSelect,
|
||||
onRename,
|
||||
onDurationChange,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
}: SceneThumbnailBlockProps) {
|
||||
const [resizeDuration, setResizeDuration] = useState<number | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editName, setEditName] = useState(scene.name);
|
||||
const resizeRef = useRef({ startX: 0, startDuration: scene.duration });
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const duration = resizeDuration ?? scene.duration;
|
||||
const width = Math.max(80, duration * pxPerSecond);
|
||||
const gradient = SCENE_THUMB_GRADIENTS[colorIndex % SCENE_THUMB_GRADIENTS.length];
|
||||
|
||||
// Format duration: show seconds for short clips, MM:SS for long
|
||||
const durationLabel =
|
||||
duration >= 60 ? formatTimelineTime(duration) : `${duration}s`;
|
||||
|
||||
useEffect(() => {
|
||||
setEditName(scene.name);
|
||||
}, [scene.name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const commitRename = () => {
|
||||
const trimmed = editName.trim();
|
||||
if (trimmed && trimmed !== scene.name) {
|
||||
onRename(trimmed);
|
||||
} else {
|
||||
setEditName(scene.name);
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleResizeStart = useCallback(
|
||||
(clientX: number) => {
|
||||
resizeRef.current = { startX: clientX, startDuration: scene.duration };
|
||||
setResizeDuration(scene.duration);
|
||||
|
||||
const handleMove = (event: globalThis.MouseEvent) => {
|
||||
const deltaSeconds =
|
||||
(event.clientX - resizeRef.current.startX) / pxPerSecond;
|
||||
setResizeDuration(
|
||||
clampSceneDuration(resizeRef.current.startDuration + deltaSeconds)
|
||||
);
|
||||
};
|
||||
|
||||
const handleUp = (event: globalThis.MouseEvent) => {
|
||||
const deltaSeconds =
|
||||
(event.clientX - resizeRef.current.startX) / pxPerSecond;
|
||||
onDurationChange(
|
||||
clampSceneDuration(resizeRef.current.startDuration + deltaSeconds)
|
||||
);
|
||||
setResizeDuration(null);
|
||||
document.removeEventListener("mousemove", handleMove);
|
||||
document.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMove);
|
||||
document.addEventListener("mouseup", handleUp);
|
||||
},
|
||||
[scene.duration, pxPerSecond, onDurationChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="shrink-0" style={{ width }}>
|
||||
{/* ── Thumbnail block ── */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onSelect}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"group relative h-20 w-full cursor-pointer overflow-hidden rounded-lg",
|
||||
isActive
|
||||
? "ring-2 ring-blue-500 ring-offset-1 ring-offset-gray-50"
|
||||
: "hover:ring-1 hover:ring-gray-300"
|
||||
)}
|
||||
>
|
||||
{/* Background: real thumbnail or gradient placeholder */}
|
||||
{scene.thumbnailUrl ? (
|
||||
<Image
|
||||
src={scene.thumbnailUrl}
|
||||
alt=""
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover"
|
||||
sizes={`${width}px`}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0" style={gradient} />
|
||||
)}
|
||||
|
||||
{/* Dark overlay so text labels stay readable */}
|
||||
<div className="absolute inset-0 bg-black/20" />
|
||||
|
||||
{/* Duration badge — top-left */}
|
||||
<span className="absolute left-1.5 top-1.5 z-10 rounded bg-black/60 px-1.5 py-0.5 text-[9px] font-medium tabular-nums text-white/90 backdrop-blur-sm">
|
||||
{durationLabel}
|
||||
</span>
|
||||
|
||||
{/* Action buttons — top-right, revealed on hover */}
|
||||
<div className="absolute right-1 top-1 z-10 flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDuplicate();
|
||||
}}
|
||||
className="flex h-6 w-6 items-center justify-center rounded bg-black/60 text-white hover:bg-black/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
aria-label={`Duplicate ${scene.name}`}
|
||||
>
|
||||
<Copy className="h-3 w-3" aria-hidden />
|
||||
</button>
|
||||
{canDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="flex h-6 w-6 items-center justify-center rounded bg-black/60 text-white hover:bg-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
aria-label={`Delete ${scene.name}`}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Duration bar — thin colored bar across the bottom */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 h-1 w-full opacity-80"
|
||||
style={gradient}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
{/* Right-edge drag handle to resize duration */}
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={`Resize ${scene.name} duration`}
|
||||
onMouseDown={(event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
handleResizeStart(event.clientX);
|
||||
}}
|
||||
className="absolute right-0 top-0 z-20 h-full w-2 cursor-ew-resize hover:bg-white/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Scene name below the block ── */}
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={editName}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={(event) => setEditName(event.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") commitRename();
|
||||
if (event.key === "Escape") {
|
||||
setEditName(scene.name);
|
||||
setIsEditing(false);
|
||||
}
|
||||
}}
|
||||
className="mt-1 w-full rounded border border-gray-300 bg-white px-1 py-0.5 text-[10px] text-gray-800 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500"
|
||||
aria-label="Scene name"
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
className="mt-1 truncate text-center text-[10px] text-gray-400"
|
||||
onDoubleClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsEditing(true);
|
||||
}}
|
||||
title="Double-click to rename"
|
||||
>
|
||||
{scene.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user