3fc7bf2b97
Build backend images / build content-svc (push) Failing after 3m39s
Build backend images / build file-svc (push) Failing after 52s
Build backend images / build gateway (push) Failing after 58s
Build backend images / build identity-svc (push) Failing after 1m21s
Build backend images / build notification-svc (push) Failing after 1m0s
Build backend images / build render-svc (push) Failing after 58s
Build backend images / build studio-svc (push) Failing after 55s
AI SEO content generator - content-svc: per-tenant OpenAI config (ai_settings) + /v1/ai endpoints (settings GET/PUT, seo-post) with SEO-expert prompt → structured article - admin UI to configure token/base-url/model and generate + save as blog - configurable base URL for restricted networks Full data-driven admin panel - generic /api/admin/resource proxy + reusable AdminResource component - categories/tags/fonts/blogs (CRUD), users (list + ban), plans/slides - AI content section; nav + i18n i18n localization sweep - localized 116 user-facing + studio/editor components to next-intl (fa+en) under the auto.* namespace; merge tooling in scripts/merge-i18n.js Branding + assets - Monoline F logo (LogoMark + favicon) - offline SVG placeholder generator (/api/placeholder), dropped picsum.photos Fixes - JWT issuer mismatch on content/studio (flatrender → flatrender-identity) - missing role claim → [Authorize(Roles="Admin")] now works (RBAC) - Secure cookies broke HTTP sessions → gated behind AUTH_COOKIE_SECURE - Radix RTL via DirectionProvider (right-aligned menus in fa) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
234 lines
7.5 KiB
TypeScript
234 lines
7.5 KiB
TypeScript
"use client";
|
|
|
|
import Image from "next/image";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
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 t = useTranslations("auto.componentsStudioTimelineSceneThumbnailBlock");
|
|
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={t("duplicateScene", { name: 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={t("deleteScene", { name: 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={t("resizeSceneDuration", { name: scene.name })}
|
|
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={t("sceneNameLabel")}
|
|
/>
|
|
) : (
|
|
<p
|
|
className="mt-1 truncate text-center text-[10px] text-gray-400"
|
|
onDoubleClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setIsEditing(true);
|
|
}}
|
|
title={t("doubleClickToRename")}
|
|
>
|
|
{scene.name}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|