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",
|
"replaceImage": "Replace image",
|
||||||
"uploadImage": "Upload 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": {
|
"componentsStudioSidebarTransitionsSidebarContent": {
|
||||||
"heading": "Transitions",
|
"heading": "Transitions",
|
||||||
"randomTransition": "Random Transition",
|
"randomTransition": "Random Transition",
|
||||||
|
|||||||
@@ -1189,6 +1189,14 @@
|
|||||||
"replaceImage": "جایگزینی تصویر",
|
"replaceImage": "جایگزینی تصویر",
|
||||||
"uploadImage": "بارگذاری تصویر"
|
"uploadImage": "بارگذاری تصویر"
|
||||||
},
|
},
|
||||||
|
"componentsStudioSidebarBlockFieldForm": {
|
||||||
|
"panelTitle": "ویرایش صحنه",
|
||||||
|
"emptyState": "این صحنه فیلد قابلویرایشی ندارد.",
|
||||||
|
"fieldFallback": "فیلد {index}",
|
||||||
|
"textPlaceholder": "اینجا بنویسید…",
|
||||||
|
"replaceImage": "جایگزینی تصویر",
|
||||||
|
"uploadImage": "بارگذاری تصویر"
|
||||||
|
},
|
||||||
"componentsStudioSidebarTransitionsSidebarContent": {
|
"componentsStudioSidebarTransitionsSidebarContent": {
|
||||||
"heading": "ترانزیشنها",
|
"heading": "ترانزیشنها",
|
||||||
"randomTransition": "ترانزیشن تصادفی",
|
"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";
|
"use client";
|
||||||
|
|
||||||
import { AudioSidebarContent } from "@/components/studio/sidebar/AudioSidebarContent";
|
import { AudioSidebarContent } from "@/components/studio/sidebar/AudioSidebarContent";
|
||||||
|
import { BlockFieldForm } from "@/components/studio/sidebar/BlockFieldForm";
|
||||||
import { ColorsSidebarContent } from "@/components/studio/sidebar/ColorsSidebarContent";
|
import { ColorsSidebarContent } from "@/components/studio/sidebar/ColorsSidebarContent";
|
||||||
import { FontSidebarContent } from "@/components/studio/sidebar/FontSidebarContent";
|
import { FontSidebarContent } from "@/components/studio/sidebar/FontSidebarContent";
|
||||||
import { SceneEditSidebarContent } from "@/components/studio/sidebar/SceneEditSidebarContent";
|
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 { TtsSidebarContent } from "@/components/studio/sidebar/TtsSidebarContent";
|
||||||
import { WatermarkSidebarContent } from "@/components/studio/sidebar/WatermarkSidebarContent";
|
import { WatermarkSidebarContent } from "@/components/studio/sidebar/WatermarkSidebarContent";
|
||||||
import type { StudioSidebarTool } from "@/components/studio/video/StudioSidebarDock";
|
import type { StudioSidebarTool } from "@/components/studio/video/StudioSidebarDock";
|
||||||
|
import { isFlexStoryProject, useStudioStore } from "@/lib/studio-store";
|
||||||
|
|
||||||
interface StudioSidebarContentProps {
|
interface StudioSidebarContentProps {
|
||||||
activeTool: StudioSidebarTool;
|
activeTool: StudioSidebarTool;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StudioSidebarContent({ activeTool }: StudioSidebarContentProps) {
|
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 (
|
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">
|
<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 === "audio" ? <AudioSidebarContent /> : null}
|
||||||
{activeTool === "tts" ? <TtsSidebarContent /> : null}
|
{activeTool === "tts" ? <TtsSidebarContent /> : null}
|
||||||
{activeTool === "colors" ? <ColorsSidebarContent /> : null}
|
{activeTool === "colors" ? <ColorsSidebarContent /> : null}
|
||||||
|
|||||||
@@ -859,6 +859,12 @@ export function isFixedSceneMode(chooseMode: string | null | undefined): boolean
|
|||||||
return FIXED_SCENE_MODES.has((chooseMode ?? "").toLowerCase());
|
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(
|
export function getActiveScene(
|
||||||
state: Pick<StudioState, "scenes" | "activeSceneId">
|
state: Pick<StudioState, "scenes" | "activeSceneId">
|
||||||
): Scene | undefined {
|
): Scene | undefined {
|
||||||
|
|||||||
Reference in New Issue
Block a user