feat: full studio build -- light theme, canvas thumbnails, i18n (fa/en)

This commit is contained in:
Soroush.Asadi
2026-05-24 17:37:21 +03:30
parent d962483359
commit c61f587767
295 changed files with 29797 additions and 265 deletions
@@ -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>
);
}