feat(studio): Phase 4 v1 — FlexStory block-field editor

Scene-engine (FLEXIBLE) projects now get a clean per-field content editor instead
of the Konva layer panel. The scoping confirmed content VALUES already flow to
saved_scene_contents via the existing `c-`-layer + updateLayer + autosave path —
so this is purely a cleaner presentation over the working save path, no new
persistence.

- isFlexStoryProject(chooseMode) helper (FLEXIBLE → scene engine).
- BlockFieldForm: renders one labelled field per content layer (label from
  layer.name — the field's Persian label, already preserved from the content
  title), text→textarea, image→upload; writes back via the unchanged
  updateLayer(props) call. No Konva geometry/layer chrome.
- StudioSidebarContent: the "scenes" tool branches on chooseMode — FlexStory →
  BlockFieldForm, AE/Konva → SceneEditSidebarContent (zero regression).
- i18n: componentsStudioSidebarBlockFieldForm in fa + en.

Verified `npm run build`. NOTE: preview stays the live Konva canvas for v1 (a true
@remotion/player embed is deferred — 8–12MB Three.js bundle). Remaining: confirm
the FlexStory render binder reads the 4 theme colours from scene_data (already
persisted) vs saved_shared_colors (would need a small colours endpoint).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-23 15:40:32 +03:30
parent 383331e8f1
commit c1747167f3
5 changed files with 197 additions and 1 deletions
+8
View File
@@ -1189,6 +1189,14 @@
"replaceImage": "Replace image",
"uploadImage": "Upload image"
},
"componentsStudioSidebarBlockFieldForm": {
"panelTitle": "Edit Scene",
"emptyState": "This scene has no editable fields.",
"fieldFallback": "Field {index}",
"textPlaceholder": "Type here…",
"replaceImage": "Replace image",
"uploadImage": "Upload image"
},
"componentsStudioSidebarTransitionsSidebarContent": {
"heading": "Transitions",
"randomTransition": "Random Transition",
+8
View File
@@ -1189,6 +1189,14 @@
"replaceImage": "جایگزینی تصویر",
"uploadImage": "بارگذاری تصویر"
},
"componentsStudioSidebarBlockFieldForm": {
"panelTitle": "ویرایش صحنه",
"emptyState": "این صحنه فیلد قابل‌ویرایشی ندارد.",
"fieldFallback": "فیلد {index}",
"textPlaceholder": "اینجا بنویسید…",
"replaceImage": "جایگزینی تصویر",
"uploadImage": "بارگذاری تصویر"
},
"componentsStudioSidebarTransitionsSidebarContent": {
"heading": "ترانزیشن‌ها",
"randomTransition": "ترانزیشن تصادفی",
@@ -0,0 +1,165 @@
"use client";
import { useRef } from "react";
import { ImagePlus, Type } from "lucide-react";
import { useTranslations } from "next-intl";
import { getTextProps, mergeLayerProps } from "@/lib/studio-layer-props";
import { useStudioStore } from "@/lib/studio-store";
/**
* Clean per-field content editor for FlexStory (scene-engine) projects. Each
* scene's editable content is already bridged to `c-`-prefixed text/image layers
* carrying the field's Persian label in `layer.name`; this renders one labelled
* input per field and writes back through the SAME updateLayer path that already
* persists to saved_scene_contents — so it's purely a cleaner presentation of the
* existing, working content path (no Konva geometry, no layer chrome).
*/
export function BlockFieldForm() {
const t = useTranslations("auto.componentsStudioSidebarBlockFieldForm");
const scenes = useStudioStore((s) => s.scenes);
const activeSceneId = useStudioStore((s) => s.activeSceneId);
const updateLayer = useStudioStore((s) => s.updateLayer);
const activeScene = scenes.find((s) => s.id === activeSceneId);
const fields = (activeScene?.layers ?? []).filter(
(l) => l.type === "text" || l.type === "image"
);
return (
<div className="flex h-full flex-col overflow-hidden bg-white">
<div className="shrink-0 border-b border-gray-200 px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
{t("panelTitle")}
</p>
{activeScene && (
<p className="mt-0.5 truncate text-[11px] text-gray-500">{activeScene.name}</p>
)}
</div>
<div className="flex-1 overflow-y-auto">
{fields.length === 0 ? (
<div className="px-4 py-8 text-center">
<Type className="mx-auto mb-3 h-8 w-8 text-gray-300" aria-hidden />
<p className="text-xs text-gray-500">{t("emptyState")}</p>
</div>
) : (
fields.map((layer, idx) =>
layer.type === "image" ? (
<ImageField
key={layer.id}
label={layer.name || t("fieldFallback", { index: idx + 1 })}
src={typeof layer.props.src === "string" ? layer.props.src : null}
replaceLabel={t("replaceImage")}
uploadLabel={t("uploadImage")}
onReplace={(src) =>
updateLayer(layer.id, { props: mergeLayerProps(layer.props, { src }) })
}
/>
) : (
<TextField
key={layer.id}
label={layer.name || t("fieldFallback", { index: idx + 1 })}
value={getTextProps(layer.props).text}
placeholder={t("textPlaceholder")}
onChange={(text) =>
updateLayer(layer.id, { props: mergeLayerProps(layer.props, { text }) })
}
/>
)
)
)}
</div>
</div>
);
}
function TextField({
label,
value,
onChange,
placeholder,
}: {
label: string;
value: string;
onChange: (v: string) => void;
placeholder: string;
}) {
const MAX = 190;
return (
<div className="border-b border-gray-100 px-4 py-3">
<div className="mb-1.5 flex items-center justify-between">
<label className="flex items-center gap-1.5 text-[11px] font-semibold text-gray-600">
<Type className="h-3 w-3 text-gray-400" aria-hidden />
{label}
</label>
<span
className={
value.length > MAX
? "text-[10px] tabular-nums text-red-500"
: "text-[10px] tabular-nums text-gray-400"
}
>
{value.length}/{MAX}
</span>
</div>
<textarea
value={value}
rows={value.length > 60 ? 3 : 2}
maxLength={MAX}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full resize-none rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:border-blue-400 focus:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
/>
</div>
);
}
function ImageField({
label,
src,
onReplace,
replaceLabel,
uploadLabel,
}: {
label: string;
src: string | null;
onReplace: (src: string) => void;
replaceLabel: string;
uploadLabel: string;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") onReplace(reader.result);
};
reader.readAsDataURL(file);
e.target.value = "";
};
return (
<div className="border-b border-gray-100 px-4 py-3">
<label className="mb-1.5 flex items-center gap-1.5 text-[11px] font-semibold text-gray-600">
<ImagePlus className="h-3 w-3 text-gray-400" aria-hidden />
{label}
</label>
<input ref={inputRef} type="file" accept="image/*" className="hidden" onChange={handleFile} />
<button
type="button"
onClick={() => inputRef.current?.click()}
className="flex w-full items-center gap-2 overflow-hidden rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-left text-xs text-gray-600 transition-colors hover:border-blue-300 hover:bg-blue-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
{src ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={src} alt="" className="h-8 w-12 shrink-0 rounded object-cover" />
) : (
<ImagePlus className="h-5 w-5 shrink-0 text-gray-300" aria-hidden />
)}
<span className="min-w-0 truncate text-gray-500">{src ? replaceLabel : uploadLabel}</span>
</button>
</div>
);
}
@@ -1,6 +1,7 @@
"use client";
import { AudioSidebarContent } from "@/components/studio/sidebar/AudioSidebarContent";
import { BlockFieldForm } from "@/components/studio/sidebar/BlockFieldForm";
import { ColorsSidebarContent } from "@/components/studio/sidebar/ColorsSidebarContent";
import { FontSidebarContent } from "@/components/studio/sidebar/FontSidebarContent";
import { SceneEditSidebarContent } from "@/components/studio/sidebar/SceneEditSidebarContent";
@@ -8,15 +9,23 @@ import { TransitionsSidebarContent } from "@/components/studio/sidebar/Transitio
import { TtsSidebarContent } from "@/components/studio/sidebar/TtsSidebarContent";
import { WatermarkSidebarContent } from "@/components/studio/sidebar/WatermarkSidebarContent";
import type { StudioSidebarTool } from "@/components/studio/video/StudioSidebarDock";
import { isFlexStoryProject, useStudioStore } from "@/lib/studio-store";
interface StudioSidebarContentProps {
activeTool: StudioSidebarTool;
}
export function StudioSidebarContent({ activeTool }: StudioSidebarContentProps) {
// FlexStory (scene-engine) projects get the clean block-field editor; AE/Konva
// templates keep the layer editor — zero regression for existing projects.
const flexStory = useStudioStore((s) => isFlexStoryProject(s.chooseMode));
return (
<div className="flex h-full w-full shrink-0 flex-col overflow-hidden border-gray-200 bg-white md:w-[240px] md:border-r">
{activeTool === "scenes" ? <SceneEditSidebarContent /> : null}
{activeTool === "scenes"
? flexStory
? <BlockFieldForm />
: <SceneEditSidebarContent />
: null}
{activeTool === "audio" ? <AudioSidebarContent /> : null}
{activeTool === "tts" ? <TtsSidebarContent /> : null}
{activeTool === "colors" ? <ColorsSidebarContent /> : null}
+6
View File
@@ -859,6 +859,12 @@ export function isFixedSceneMode(chooseMode: string | null | undefined): boolean
return FIXED_SCENE_MODES.has((chooseMode ?? "").toLowerCase());
}
/** FLEXIBLE projects are the FlexStory scene-engine → use the clean block-field
* editor (BlockFieldForm) instead of the Konva layer panel. */
export function isFlexStoryProject(chooseMode: string | null | undefined): boolean {
return (chooseMode ?? "").toLowerCase() === "flexible";
}
export function getActiveScene(
state: Pick<StudioState, "scenes" | "activeSceneId">
): Scene | undefined {