"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 UiEffectsDraftState = { selected: Record; wiki_id: string; message: string; header_id: string; speed: 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: "Wiki Panel", value: "wiki_panel" }, { label: "Close Wiki Panel", value: "close_wiki_panel" }, { label: "Zoom Panel", value: "zoom_panel" }, { label: "Wiki", value: "wiki" }, { label: "Toast", value: "toast" }, { label: "Wiki Header", value: "wiki_header" }, { label: "Playback Speed", value: "playback_speed" }, ]; const uiSimpleOptionValues: UIOptionName[] = [ "timeline", "layer_panel", "wiki_panel", "close_wiki_panel", "zoom_panel", ]; const uiInputOptionValues: UIOptionName[] = [ "wiki", "toast", "wiki_header", "playback_speed", ]; 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_title: { label: "Tiêu đề step", fields: [{ name: "title", label: "Title", kind: "text", placeholder: "Tiêu đề" }], create: () => ({ function_name: "set_title", params: [""] }), deserialize: (params) => ({ title: asString(params[0]) }), serialize: (values) => [asString(values.title)], }, clear_title: { label: "Xóa tiêu đề", fields: [], create: () => ({ function_name: "clear_title", params: [] }), deserialize: () => ({}), serialize: () => [], }, set_descriptions: { label: "Mô tả", fields: [{ name: "text", label: "Text", kind: "textarea", placeholder: "Nội dung diễn giải" }], create: () => ({ function_name: "set_descriptions", params: [""] }), deserialize: (params) => ({ text: asString(params[0]) }), serialize: (values) => [asString(values.text)], }, clear_descriptions: { label: "Xóa mô tả", fields: [], create: () => ({ function_name: "clear_descriptions", params: [] }), deserialize: () => ({}), serialize: () => [], }, show_dialog_box: { label: "Dialog box", fields: [ { name: "avatar", label: "Avatar", kind: "text", placeholder: "avatar url" }, { name: "text", label: "Text", kind: "textarea", placeholder: "Lời thoại" }, { name: "side", label: "Side", kind: "select", options: [ { label: "Left", value: "left" }, { label: "Right", value: "right" }, ], }, { name: "speaker", label: "Speaker", kind: "text", placeholder: "Tên nhân vật" }, ], create: () => ({ function_name: "show_dialog_box", params: ["", "", "left", ""] }), deserialize: (params) => ({ avatar: asString(params[0]), text: asString(params[1]), side: normalizeSelectValue(asString(params[2]), "left"), speaker: asString(params[3]), }), serialize: (values) => [ asString(values.avatar), asString(values.text), normalizeSelectValue(asString(values.side), "left"), asString(values.speaker), ], }, clear_dialog_box: { label: "Đóng dialog box", fields: [], create: () => ({ function_name: "clear_dialog_box", params: [] }), deserialize: () => ({}), serialize: () => [], }, display_historical_image: { label: "Ảnh lịch sử", fields: [ { name: "url", label: "URL", kind: "text", placeholder: "https://..." }, { name: "caption", label: "Caption", kind: "textarea", placeholder: "Chú thích" }, ], create: () => ({ function_name: "display_historical_image", params: ["", ""] }), deserialize: (params) => ({ url: asString(params[0]), caption: asString(params[1]), }), serialize: (values) => compactTrailingUndefined([ asString(values.url), emptyToUndefined(asString(values.caption)), ]), }, clear_historical_image: { label: "Xóa ảnh lịch sử", fields: [], create: () => ({ function_name: "clear_historical_image", params: [] }), deserialize: () => ({}), serialize: () => [], }, set_step_subtitle: { label: "Phụ đề", fields: [{ name: "subtitle", label: "Subtitle", kind: "textarea", placeholder: "Để trống để ẩn subtitle" }], create: () => ({ function_name: "set_step_subtitle", params: [""] }), deserialize: (params) => ({ subtitle: params[0] == null ? "" : asString(params[0]) }), serialize: (values) => [emptyToNull(asString(values.subtitle))], }, clear_step_subtitle: { label: "Xóa phụ đề", fields: [], create: () => ({ function_name: "clear_step_subtitle", params: [] }), deserialize: () => ({}), serialize: () => [], }, }; 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 selectedGeometryIds = useMemo( () => selectedGeometryItems.map((item) => item.id), [selectedGeometryItems] ); 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: "show_labels", params: [] }], "Map: show labels" ) } /> onAppendActions( [{ function_name: "hide_labels", params: [] }], "Map: hide labels" ) } /> onAppendActions( [{ function_name: "enable_timeline_filter", params: [] }], "Map: enable timeline filter" ) } /> onAppendActions( [{ function_name: "disable_timeline_filter", params: [] }], "Map: disable timeline filter" ) } /> onAppendActions( [{ function_name: "set_time_filter", params: [safeYear] }], `Map: set timeline ${safeYear}` ) } /> onAppendActions( [{ function_name: "reset_camera_north", params: [] }], "Map: reset camera north" ) } /> onAppendActions( [{ function_name: "show_all_geometries", params: [] }], "Map: show all geometries" ) } />
); } 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: "show_geometries", params: [selectedIds] }], `Geo: show ${selectedCount} geo` ) } /> onAppendActions( [{ function_name: "hide_geometries", params: [selectedIds] }], `Geo: hide ${selectedCount} geo` ) } /> onAppendActions( selectedIds.map((id) => ({ function_name: "pulse_geometry", params: [id, "#f59e0b", 2, 1800], })), `Geo: pulse ${selectedCount} geo` ) } /> onAppendActions( selectedIds.map((id) => ({ function_name: "animate_dashed_border", params: [id, "#38bdf8", 2, 1, 3000], })), `Geo: dashed border ${selectedCount} geo` ) } /> onAppendActions( [{ function_name: "orbit_camera_around_geometry", params: [firstId, 8, 45, 1, 5000] }], `Geo: orbit ${firstId || "main"}` ) } /> onAppendActions( selectedIds.map((id) => ({ function_name: "show_geometry_label", params: [id, "", "#ffffff", 14], })), `Geo: label ${selectedCount} geo` ) } /> onAppendActions( [{ function_name: "dim_other_geometries", params: [selectedIds] }], `Geo: hide others ngoài ${selectedCount} geo` ) } /> onAppendActions( [{ function_name: "set_geometry_style", params: [selectedIds, "#f97316", 0.35, "#fdba74", 2], }], `Geo: style ${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} {draft.selected.wiki_header ? ( onChangeDraft({ header_id: asString(nextValue) })} /> ) : null} {draft.selected.playback_speed ? ( onChangeDraft({ speed: 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} /> ); } 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, groupKey, groupLabel, actions, definitions, geometryChoices, wikiChoices, createOnSelect = false, emptyOptionLabel, onUpdateActions, }: { title: string; groupKey: ActionGroupKey; 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[0] as T) ); const [composerDraftValues, setComposerDraftValues] = useState(() => buildActionComposerDraft(definitions, createOnSelect ? "" : (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) { setComposerFunctionName(""); setComposerDraftValues(buildActionComposerDraft(definitions, "")); return; } setComposerDraftValues(buildActionComposerDraft(definitions, composerFunctionName)); }; return (
{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 (