"use client"; import { useEffect, useMemo, useState } from "react"; import type { BattleReplay, GeoFunctionName, MapFunctionName, NarrativeFunctionName, ReplayAction, ReplayStep, UIOptionName, } from "@/uhm/types/projects"; import { Panel } from "./Panel"; type Choice = { id: string; label: string; }; type Props = { width?: number; replay: BattleReplay | null; selectedStageId: number | null; selectedStepIndex: number | null; selectedFeatureIds: string[]; currentTimelineYear: number; geometryChoices: Choice[]; wikiChoices: Choice[]; getCurrentMapViewState: () => CurrentMapViewState | null; onMutateReplay: (label: string, mutator: (draftReplay: BattleReplay) => void) => boolean; }; type ActionGroupKey = "use_UI_function" | "use_map_function" | "use_geo_function" | "use_narrow_function"; type ActionValue = string | boolean | string[]; type ActionFormValues = Record; type AnyReplayAction = ReplayAction; type ActionFieldConfig = { name: string; label: string; kind: | "text" | "textarea" | "number" | "boolean" | "color" | "select" | "geometry" | "geometry-multi" | "wiki"; placeholder?: string; options?: Array<{ label: string; value: string }>; visibleWhen?: (values: ActionFormValues) => boolean; }; type ActionDefinition = { label: string; fields: ActionFieldConfig[]; create: () => ReplayAction; deserialize: (params: unknown[]) => ActionFormValues; serialize: (values: ActionFormValues) => unknown[]; }; type NarrativeActionDefinitionMap = Record>; type UiVisibleOptionName = "timeline" | "layer_panel" | "zoom_panel"; type UiEffectsDraftState = { selected: Record; visible: Record; wiki_id: string; message: string; }; type MapCameraOptionName = "center" | "zoom" | "bearing" | "pitch"; type MapCameraDraftState = { selected: Record; }; type CurrentMapViewState = { center: { lng: number; lat: number }; zoom: number; pitch: number; bearing: number; projection: string; }; const uiOptionChoices: Array<{ label: string; value: UIOptionName }> = [ { label: "Timeline", value: "timeline" }, { label: "Layer Panel", value: "layer_panel" }, { label: "Zoom Panel", value: "zoom_panel" }, { label: "Wiki", value: "wiki" }, { label: "Toast", value: "toast" }, ]; const uiSimpleOptionValues: UIOptionName[] = [ "timeline", "layer_panel", "zoom_panel", ]; const uiInputOptionValues: UIOptionName[] = [ "wiki", "toast", ]; const mapCameraOptionChoices: Array<{ label: string; value: MapCameraOptionName }> = [ { label: "LngLat", value: "center" }, { label: "Zoom", value: "zoom" }, { label: "Bearing", value: "bearing" }, { label: "Pitch", value: "pitch" }, ]; const sidebarStyle = { background: "#111827", color: "#e5e7eb", borderLeft: "1px solid #1f2937", padding: "12px", height: "100vh", overflowY: "auto" as const, }; const inputStyle = { width: "100%", padding: "8px 10px", borderRadius: 6, border: "1px solid #334155", background: "#0b1220", color: "white", boxSizing: "border-box" as const, fontSize: 13, outline: "none", }; const buttonStyle = { padding: "8px 10px", borderRadius: 6, border: "1px solid #334155", background: "#111827", color: "white", cursor: "pointer", fontWeight: 800, fontSize: 12, }; const narrativeActionDefinitions: NarrativeActionDefinitionMap = { set_dialog: { label: "Dialog box", fields: [ { name: "clear", label: "Ẩn dialog (Clear)", kind: "boolean" }, { name: "avatar", label: "Avatar URL", kind: "text", placeholder: "https://... (avatar)" }, { name: "text", label: "Nội dung", kind: "textarea", placeholder: "Lời thoại / Dẫn chuyện" }, { name: "image_url", label: "Ảnh tư liệu", kind: "text", placeholder: "https://... (ảnh đè)" }, { name: "image_caption", label: "Chú thích ảnh", kind: "text", placeholder: "Chú thích ảnh" }, ], create: () => ({ function_name: "set_dialog", params: [{ avatar: "", text: "", image_url: "", image_caption: "" }] }), deserialize: (params) => { const data: any = params[0]; if (data === null) { return { clear: true, avatar: "", text: "", image_url: "", image_caption: "", }; } return { clear: false, avatar: asString(data?.avatar), text: asString(data?.text), image_url: asString(data?.image_url), image_caption: asString(data?.image_caption), }; }, serialize: (values) => { if (values.clear) { return [null]; } const data: any = { avatar: asString(values.avatar), text: asString(values.text), }; if (values.image_url) { data.image_url = asString(values.image_url); } if (values.image_caption) { data.image_caption = asString(values.image_caption); } return [data]; }, }, }; export default function ReplayEffectsSidebar({ width = 420, replay, selectedStageId, selectedStepIndex, selectedFeatureIds, currentTimelineYear, geometryChoices, wikiChoices, getCurrentMapViewState, onMutateReplay, }: Props) { const stages = useMemo(() => replay?.detail || [], [replay?.detail]); const selectedStage = stages.find((stage) => stage.id === selectedStageId) || stages[0] || null; const selectedStep = selectedStage && selectedStepIndex != null && selectedStepIndex >= 0 && selectedStepIndex < selectedStage.steps.length ? selectedStage.steps[selectedStepIndex] : null; const mapCameraActions = useMemo( () => (selectedStep?.use_map_function.filter( (action) => action.function_name === "set_camera_view" ) || []) as ReplayAction<"set_camera_view">[], [selectedStep?.use_map_function] ); const nonCameraMapActions = useMemo( () => (selectedStep?.use_map_function.filter( (action) => action.function_name !== "set_camera_view" ) || []) as ReplayAction[], [selectedStep?.use_map_function] ); const geoActions = useMemo( () => selectedStep?.use_geo_function || [], [selectedStep?.use_geo_function] ); const selectedGeometryItems = useMemo(() => { const seen = new Set(); const byId = new Map(geometryChoices.map((choice) => [String(choice.id), choice])); return selectedFeatureIds .map((id) => String(id).trim()) .filter((id) => { if (!id.length || seen.has(id)) return false; seen.add(id); return true; }) .map((id) => byId.get(id) || { id, label: id }); }, [geometryChoices, selectedFeatureIds]); const updateStep = (label: string, updater: (step: ReplayStep) => void) => { if (!selectedStage || selectedStepIndex == null) return; onMutateReplay(label, (draftReplay) => { const stage = draftReplay.detail.find((item) => item.id === selectedStage.id); if (!stage) return; if (selectedStepIndex < 0 || selectedStepIndex >= stage.steps.length) return; updater(stage.steps[selectedStepIndex]); }); }; const updateActionGroup = (groupKey: ActionGroupKey, nextActions: AnyReplayAction[], actionLabel: string) => { updateStep(actionLabel, (step) => { switch (groupKey) { case "use_UI_function": step.use_UI_function = nextActions as ReplayStep["use_UI_function"]; return; case "use_map_function": step.use_map_function = nextActions as ReplayStep["use_map_function"]; return; case "use_geo_function": step.use_geo_function = nextActions as ReplayStep["use_geo_function"]; return; case "use_narrow_function": step.use_narrow_function = nextActions as ReplayStep["use_narrow_function"]; return; } }); }; const appendMapActions = (nextActions: ReplayAction[], actionLabel: string) => { if (!selectedStep || nextActions.length === 0) return; updateActionGroup( "use_map_function", [...selectedStep.use_map_function, ...nextActions], actionLabel ); }; const appendGeoActions = (nextActions: ReplayAction[], actionLabel: string) => { if (!selectedStep || nextActions.length === 0) return; updateActionGroup( "use_geo_function", [...geoActions, ...nextActions], actionLabel ); }; return ( ); } function MapFunctionShortcutPanel({ currentTimelineYear, onAppendActions, }: { currentTimelineYear: number; onAppendActions: (actions: ReplayAction[], label: string) => void; }) { const safeYear = Math.trunc(currentTimelineYear); return (
onAppendActions( [{ function_name: "set_labels_visible", params: [true] }], "Map: show labels" ) } /> onAppendActions( [{ function_name: "set_labels_visible", params: [false] }], "Map: hide labels" ) } /> onAppendActions( [{ function_name: "set_timeline_filter", params: [true] }], "Map: enable timeline filter" ) } /> onAppendActions( [{ function_name: "set_timeline_filter", params: [false] }], "Map: disable timeline filter" ) } />
); } function GeoFunctionShortcutPanel({ selectedGeometries, onAppendActions, }: { selectedGeometries: Choice[]; onAppendActions: (actions: ReplayAction[], label: string) => void; }) { const selectedIds = selectedGeometries.map((item) => item.id); const selectedCount = selectedIds.length; const firstId = selectedIds[0] || ""; const hasSelection = selectedCount > 0; return (
{!hasSelection ? (
Chọn geo trực tiếp trên map replay rồi bấm action tương ứng.
) : null}
onAppendActions( [{ function_name: "fly_to_geometries", params: [selectedIds] }], `Geo: fly ${selectedCount} geo` ) } /> onAppendActions( [{ function_name: "follow_geometries_path", params: [selectedIds, 5000, 8, 50] }], `Geo: follow path ${selectedCount} geo` ) } /> onAppendActions( [{ function_name: "set_geometry_visibility", params: [selectedIds, true] }], `Geo: show ${selectedCount} geo` ) } /> onAppendActions( [{ function_name: "set_geometry_visibility", params: [selectedIds, false] }], `Geo: hide ${selectedCount} geo` ) } /> onAppendActions( [{ function_name: "hide_others_geometries", params: [selectedIds] }], `Geo: hide others ngoài ${selectedCount} geo` ) } />
); } function ShortcutButton({ label, tone, disabled = false, onClick, }: { label: string; tone: "slate" | "blue" | "teal" | "green" | "amber"; disabled?: boolean; onClick: () => void; }) { const backgrounds: Record = { slate: "#334155", blue: "#1d4ed8", teal: "#0f766e", green: "#166534", amber: "#b45309", }; return ( ); } function MapCameraViewPanel({ actions, getCurrentMapViewState, onApplyAction, }: { actions: ReplayAction<"set_camera_view">[]; getCurrentMapViewState: () => CurrentMapViewState | null; onApplyAction: ( nextAction: ReplayAction<"set_camera_view"> | null, label: string ) => void; }) { const [draft, setDraft] = useState(() => buildMapCameraDraftState(actions) ); const activeCount = mapCameraOptionChoices.filter( (choice) => draft.selected[choice.value] ).length; useEffect(() => { setDraft(buildMapCameraDraftState(actions)); }, [actions]); return (
({ value: choice.value, label: choice.label, selected: draft.selected[choice.value], }))} onToggleOption={(option) => setDraft((prev) => ({ selected: { ...prev.selected, [option]: !prev.selected[option], }, })) } />
); } function UiSimpleEffectsPanel({ draft, onToggleOption, onApply, }: { draft: UiEffectsDraftState; onToggleOption: (option: UIOptionName) => void; onApply: () => void; }) { const activeCount = uiSimpleOptionValues.filter((option) => draft.selected[option]).length; return (
); } function UiInputEffectsPanel({ draft, wikiChoices, onToggleOption, onChangeDraft, onApply, }: { draft: UiEffectsDraftState; wikiChoices: Choice[]; onToggleOption: (option: UIOptionName) => void; onChangeDraft: (patch: Partial) => void; onApply: () => void; }) { const activeCount = uiInputOptionValues.filter((option) => draft.selected[option]).length; return (
{draft.selected.wiki ? ( onChangeDraft({ wiki_id: asString(nextValue) })} /> ) : null} {draft.selected.toast ? ( onChangeDraft({ message: asString(nextValue) })} /> ) : null}
); } function UiOptionToggleRow({ optionValues, draft, onToggleOption, }: { optionValues: UIOptionName[]; draft: UiEffectsDraftState; onToggleOption: (option: UIOptionName) => void; }) { return ( ({ value: option, label: uiOptionChoices.find((item) => item.value === option)?.label || option, selected: draft.selected[option], }))} onToggleOption={onToggleOption} /> ); } // UiVisibilityOptions removed since toggles are evaluated directly function SimpleOptionToggleRow({ options, onToggleOption, }: { options: Array<{ value: T; label: string; selected: boolean }>; onToggleOption: (option: T) => void; }) { return (
{options.map((option) => { const isSelected = option.selected; return ( ); })}
); } function UiEffectsEditor({ actions, wikiChoices, onApplyActions, }: { actions: ReplayAction[]; wikiChoices: Choice[]; onApplyActions: (nextActions: ReplayAction[], label: string) => void; }) { const [draft, setDraft] = useState(() => buildUiEffectsDraftState(actions)); useEffect(() => { setDraft(buildUiEffectsDraftState(actions)); }, [actions]); return ( <> setDraft((prev) => ({ ...prev, selected: { ...prev.selected, [option]: !prev.selected[option], }, })) } onApply={() => onApplyActions( replaceUiActionsByGroup(actions, uiSimpleOptionValues, draft), buildUiEffectsApplyLabel("UI Effects", draft, uiSimpleOptionValues) ) } /> setDraft((prev) => ({ ...prev, selected: { ...prev.selected, [option]: !prev.selected[option], }, })) } onChangeDraft={(patch) => setDraft((prev) => ({ ...prev, ...patch, })) } onApply={() => onApplyActions( replaceUiActionsByGroup(actions, uiInputOptionValues, draft), buildUiEffectsApplyLabel("UI Inputs", draft, uiInputOptionValues) ) } /> ); } function ActionGroupEditor({ title, groupLabel, actions, definitions, geometryChoices, wikiChoices, createOnSelect = false, emptyOptionLabel, onUpdateActions, }: { title: string; groupLabel: string; actions: ReplayAction[]; definitions: Record>; geometryChoices: Choice[]; wikiChoices: Choice[]; createOnSelect?: boolean; emptyOptionLabel?: string; onUpdateActions: (nextActions: ReplayAction[], label: string) => void; }) { const functionNames = useMemo(() => Object.keys(definitions) as T[], [definitions]); const [composerFunctionName, setComposerFunctionName] = useState( createOnSelect && functionNames.length > 1 ? "" : (functionNames[0] as T) ); const [composerDraftValues, setComposerDraftValues] = useState(() => buildActionComposerDraft( definitions, createOnSelect && functionNames.length > 1 ? "" : (functionNames[0] as T) ) ); const composerDefinition = composerFunctionName ? definitions[composerFunctionName] : null; const handleComposerFunctionChange = (nextFunctionName: T | "") => { setComposerFunctionName(nextFunctionName); setComposerDraftValues(buildActionComposerDraft(definitions, nextFunctionName)); }; const handleApplyNewAction = () => { if (!composerFunctionName) return; const definition = definitions[composerFunctionName]; if (!definition) return; onUpdateActions( [ ...actions, { function_name: composerFunctionName, params: definition.serialize(composerDraftValues), }, ], `${groupLabel}: thêm ${definition.label}` ); if (createOnSelect && functionNames.length > 1) { setComposerFunctionName(""); setComposerDraftValues(buildActionComposerDraft(definitions, "")); return; } setComposerDraftValues(buildActionComposerDraft(definitions, composerFunctionName)); }; return (
{functionNames.length > 1 ? (
) : null} {composerDefinition ? (
Tạo action mới: {composerDefinition.label}
{composerDefinition.fields.length === 0 ? (
Action này không cần tham số.
) : (
{composerDefinition.fields .filter((field) => !field.visibleWhen || field.visibleWhen(composerDraftValues) ) .map((field) => ( setComposerDraftValues((prev) => ({ ...prev, [field.name]: nextValue, })) } /> ))}
)}
) : null}
); } function buildActionComposerDraft( definitions: Record>, functionName: T | "" ): ActionFormValues { if (!functionName) return {}; const definition = definitions[functionName]; if (!definition) return {}; return definition.deserialize(definition.create().params); } function FieldInput({ field, value, geometryChoices, wikiChoices, onChange, }: { field: ActionFieldConfig; value: ActionValue | undefined; geometryChoices: Choice[]; wikiChoices: Choice[]; onChange: (nextValue: ActionValue) => void; }) { const baseLabel = (
{field.label}
); if (field.kind === "textarea") { return (