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:
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user