refactor: decouple map effects from dispatcher and consolidate replay actions into a unified catalog

This commit is contained in:
taDuc
2026-05-25 02:36:18 +07:00
parent 395eb3de47
commit 9aa61dce27
18 changed files with 2527 additions and 1344 deletions
+128 -323
View File
@@ -62,12 +62,12 @@ type ActionDefinition<T extends string> = {
};
type NarrativeActionDefinitionMap = Record<NarrativeFunctionName, ActionDefinition<NarrativeFunctionName>>;
type UiVisibleOptionName = "timeline" | "layer_panel" | "zoom_panel";
type UiEffectsDraftState = {
selected: Record<UIOptionName, boolean>;
visible: Record<UiVisibleOptionName, boolean>;
wiki_id: string;
message: string;
header_id: string;
speed: string;
};
type MapCameraOptionName = "center" | "zoom" | "bearing" | "pitch";
type MapCameraDraftState = {
@@ -84,28 +84,20 @@ type CurrentMapViewState = {
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 }> = [
@@ -148,107 +140,51 @@ const buttonStyle = {
};
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: {
set_dialog: {
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" },
{ 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: "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: () => [],
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];
},
},
};
@@ -306,11 +242,6 @@ export default function ReplayEffectsSidebar({
})
.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) => {
@@ -362,7 +293,6 @@ export default function ReplayEffectsSidebar({
<>
<ActionGroupEditor
title="Narrative"
groupKey="use_narrow_function"
groupLabel={`Replay: cập nhật narrative step ${selectedStepIndex + 1} của stage #${selectedStage.id}`}
actions={selectedStep.use_narrow_function}
definitions={narrativeActionDefinitions}
@@ -433,7 +363,7 @@ function MapFunctionShortcutPanel({
tone="blue"
onClick={() =>
onAppendActions(
[{ function_name: "show_labels", params: [] }],
[{ function_name: "set_labels_visible", params: [true] }],
"Map: show labels"
)
}
@@ -443,7 +373,7 @@ function MapFunctionShortcutPanel({
tone="slate"
onClick={() =>
onAppendActions(
[{ function_name: "hide_labels", params: [] }],
[{ function_name: "set_labels_visible", params: [false] }],
"Map: hide labels"
)
}
@@ -453,7 +383,7 @@ function MapFunctionShortcutPanel({
tone="green"
onClick={() =>
onAppendActions(
[{ function_name: "enable_timeline_filter", params: [] }],
[{ function_name: "set_timeline_filter", params: [true] }],
"Map: enable timeline filter"
)
}
@@ -463,41 +393,11 @@ function MapFunctionShortcutPanel({
tone="slate"
onClick={() =>
onAppendActions(
[{ function_name: "disable_timeline_filter", params: [] }],
[{ function_name: "set_timeline_filter", params: [false] }],
"Map: disable timeline filter"
)
}
/>
<ShortcutButton
label="Lấy Timeline"
tone="teal"
onClick={() =>
onAppendActions(
[{ function_name: "set_time_filter", params: [safeYear] }],
`Map: set timeline ${safeYear}`
)
}
/>
<ShortcutButton
label="North Up"
tone="amber"
onClick={() =>
onAppendActions(
[{ function_name: "reset_camera_north", params: [] }],
"Map: reset camera north"
)
}
/>
<ShortcutButton
label="Show All Geo"
tone="green"
onClick={() =>
onAppendActions(
[{ function_name: "show_all_geometries", params: [] }],
"Map: show all geometries"
)
}
/>
</div>
</div>
</Panel>
@@ -553,7 +453,7 @@ function GeoFunctionShortcutPanel({
disabled={!hasSelection}
onClick={() =>
onAppendActions(
[{ function_name: "show_geometries", params: [selectedIds] }],
[{ function_name: "set_geometry_visibility", params: [selectedIds, true] }],
`Geo: show ${selectedCount} geo`
)
}
@@ -564,89 +464,22 @@ function GeoFunctionShortcutPanel({
disabled={!hasSelection}
onClick={() =>
onAppendActions(
[{ function_name: "hide_geometries", params: [selectedIds] }],
[{ function_name: "set_geometry_visibility", params: [selectedIds, false] }],
`Geo: hide ${selectedCount} geo`
)
}
/>
<ShortcutButton
label="Pulse"
tone="amber"
disabled={!hasSelection}
onClick={() =>
onAppendActions(
selectedIds.map((id) => ({
function_name: "pulse_geometry",
params: [id, "#f59e0b", 2, 1800],
})),
`Geo: pulse ${selectedCount} geo`
)
}
/>
<ShortcutButton
label="Dash Border"
tone="blue"
disabled={!hasSelection}
onClick={() =>
onAppendActions(
selectedIds.map((id) => ({
function_name: "animate_dashed_border",
params: [id, "#38bdf8", 2, 1, 3000],
})),
`Geo: dashed border ${selectedCount} geo`
)
}
/>
<ShortcutButton
label="Orbit"
tone="teal"
disabled={!hasSelection}
onClick={() =>
onAppendActions(
[{ function_name: "orbit_camera_around_geometry", params: [firstId, 8, 45, 1, 5000] }],
`Geo: orbit ${firstId || "main"}`
)
}
/>
<ShortcutButton
label="Label Geo"
tone="green"
disabled={!hasSelection}
onClick={() =>
onAppendActions(
selectedIds.map((id) => ({
function_name: "show_geometry_label",
params: [id, "", "#ffffff", 14],
})),
`Geo: label ${selectedCount} geo`
)
}
/>
<ShortcutButton
label="Hide Others"
tone="slate"
disabled={!hasSelection}
onClick={() =>
onAppendActions(
[{ function_name: "dim_other_geometries", params: [selectedIds] }],
[{ function_name: "hide_others_geometries", params: [selectedIds] }],
`Geo: hide others ngoài ${selectedCount} geo`
)
}
/>
<ShortcutButton
label="Style Geo"
tone="amber"
disabled={!hasSelection}
onClick={() =>
onAppendActions(
[{
function_name: "set_geometry_style",
params: [selectedIds, "#f97316", 0.35, "#fdba74", 2],
}],
`Geo: style ${selectedCount} geo`
)
}
/>
</div>
</div>
</Panel>
@@ -849,36 +682,6 @@ function UiInputEffectsPanel({
/>
) : null}
{draft.selected.wiki_header ? (
<FieldInput
field={{
name: "header_id",
label: "Header ID",
kind: "text",
placeholder: "heading-id",
}}
value={draft.header_id}
geometryChoices={[]}
wikiChoices={wikiChoices}
onChange={(nextValue) => onChangeDraft({ header_id: asString(nextValue) })}
/>
) : null}
{draft.selected.playback_speed ? (
<FieldInput
field={{
name: "speed",
label: "Speed",
kind: "number",
placeholder: "1",
}}
value={draft.speed}
geometryChoices={[]}
wikiChoices={wikiChoices}
onChange={(nextValue) => onChangeDraft({ speed: asString(nextValue) })}
/>
) : null}
<button
type="button"
onClick={onApply}
@@ -916,6 +719,8 @@ function UiOptionToggleRow({
);
}
// UiVisibilityOptions removed since toggles are evaluated directly
function SimpleOptionToggleRow<T extends string>({
options,
onToggleOption,
@@ -1022,7 +827,6 @@ function UiEffectsEditor({
function ActionGroupEditor<T extends string>({
title,
groupKey,
groupLabel,
actions,
definitions,
@@ -1033,7 +837,6 @@ function ActionGroupEditor<T extends string>({
onUpdateActions,
}: {
title: string;
groupKey: ActionGroupKey;
groupLabel: string;
actions: ReplayAction<T>[];
definitions: Record<T, ActionDefinition<T>>;
@@ -1045,10 +848,13 @@ function ActionGroupEditor<T extends string>({
}) {
const functionNames = useMemo(() => Object.keys(definitions) as T[], [definitions]);
const [composerFunctionName, setComposerFunctionName] = useState<T | "">(
createOnSelect ? "" : (functionNames[0] as T)
createOnSelect && functionNames.length > 1 ? "" : (functionNames[0] as T)
);
const [composerDraftValues, setComposerDraftValues] = useState<ActionFormValues>(() =>
buildActionComposerDraft(definitions, createOnSelect ? "" : (functionNames[0] as T))
buildActionComposerDraft(
definitions,
createOnSelect && functionNames.length > 1 ? "" : (functionNames[0] as T)
)
);
const composerDefinition = composerFunctionName
@@ -1076,7 +882,7 @@ function ActionGroupEditor<T extends string>({
`${groupLabel}: thêm ${definition.label}`
);
if (createOnSelect) {
if (createOnSelect && functionNames.length > 1) {
setComposerFunctionName("");
setComposerDraftValues(buildActionComposerDraft(definitions, ""));
return;
@@ -1088,32 +894,34 @@ function ActionGroupEditor<T extends string>({
return (
<Panel title={title} badge={`${actions.length}`} defaultOpen>
<div style={{ display: "grid", gap: 10 }}>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr",
gap: 8,
alignItems: "center",
}}
>
<select
value={composerFunctionName}
onChange={(event) => {
const nextValue = event.target.value as T | "";
handleComposerFunctionChange(nextValue);
{functionNames.length > 1 ? (
<div
style={{
display: "grid",
gridTemplateColumns: "1fr",
gap: 8,
alignItems: "center",
}}
style={inputStyle}
>
{createOnSelect ? (
<option value="">{emptyOptionLabel || "Chọn option"}</option>
) : null}
{functionNames.map((functionName) => (
<option key={functionName} value={functionName}>
{definitions[functionName].label}
</option>
))}
</select>
</div>
<select
value={composerFunctionName}
onChange={(event) => {
const nextValue = event.target.value as T | "";
handleComposerFunctionChange(nextValue);
}}
style={inputStyle}
>
{createOnSelect ? (
<option value="">{emptyOptionLabel || "Chọn option"}</option>
) : null}
{functionNames.map((functionName) => (
<option key={functionName} value={functionName}>
{definitions[functionName].label}
</option>
))}
</select>
</div>
) : null}
{composerDefinition ? (
<div
@@ -1396,40 +1204,35 @@ function compactTrailingUndefined(values: unknown[]) {
return next;
}
function normalizeColorValue(value: unknown, fallback: string) {
const raw = asString(value).trim();
return raw.length > 0 ? raw : fallback;
}
function normalizeSelectValue(value: string, fallback: string) {
return value.trim().length ? value : fallback;
}
function buildUiEffectsDraftState(actions: ReplayAction<UIOptionName>[]): UiEffectsDraftState {
const selected = buildEmptyUiOptionSelection();
const visible = buildDefaultUiVisibilityState();
let wiki_id = "";
let message = "";
let header_id = "";
let speed = "1";
for (const action of actions) {
const descriptor = getUiActionDescriptor(action);
if (!descriptor) continue;
selected[descriptor.option] = true;
switch (descriptor.option) {
case "timeline":
case "layer_panel":
case "zoom_panel":
selected[descriptor.option] = Boolean(descriptor.payload[0] ?? false);
visible[descriptor.option] = Boolean(descriptor.payload[0] ?? false);
break;
case "wiki":
selected[descriptor.option] = true;
wiki_id = asString(descriptor.payload[0]);
break;
case "toast":
selected[descriptor.option] = true;
message = asString(descriptor.payload[0]);
break;
case "wiki_header":
header_id = asString(descriptor.payload[0]);
break;
case "playback_speed":
speed = toInputNumber(descriptor.payload[0], "1");
break;
default:
break;
}
@@ -1437,10 +1240,9 @@ function buildUiEffectsDraftState(actions: ReplayAction<UIOptionName>[]): UiEffe
return {
selected,
visible,
wiki_id,
message,
header_id,
speed,
};
}
@@ -1448,13 +1250,17 @@ function buildEmptyUiOptionSelection(): Record<UIOptionName, boolean> {
return {
timeline: false,
layer_panel: false,
wiki_panel: false,
close_wiki_panel: false,
zoom_panel: false,
wiki: false,
toast: false,
wiki_header: false,
playback_speed: false,
};
}
function buildDefaultUiVisibilityState(): Record<UiVisibleOptionName, boolean> {
return {
timeline: false,
layer_panel: false,
zoom_panel: false,
};
}
@@ -1538,7 +1344,12 @@ function replaceUiActionsByGroup(
});
const nextGroupActions = groupOptions
.filter((option) => draft.selected[option])
.filter((option) => {
if (option === "timeline" || option === "layer_panel" || option === "zoom_panel") {
return true;
}
return draft.selected[option];
})
.map((option) => buildUiOptionAction(option, draft));
return [...preserved, ...nextGroupActions];
@@ -1550,11 +1361,22 @@ function buildUiEffectsApplyLabel(
groupOptions: UIOptionName[]
) {
const activeLabels = groupOptions
.filter((option) => draft.selected[option])
.map((option) => uiOptionChoices.find((choice) => choice.value === option)?.label || option);
.filter((option) => {
if (option === "timeline" || option === "layer_panel" || option === "zoom_panel") {
return true;
}
return draft.selected[option];
})
.map((option) => {
const label = uiOptionChoices.find((choice) => choice.value === option)?.label || option;
if (option === "timeline" || option === "layer_panel" || option === "zoom_panel") {
return draft.selected[option] ? `Show ${label}` : `Hide ${label}`;
}
return label;
});
return activeLabels.length > 0
? `${prefix}: apply ${activeLabels.join(", ")}`
? `${prefix}: ${activeLabels.join(", ")}`
: `${prefix}: clear`;
}
@@ -1565,37 +1387,21 @@ function buildUiOptionAction(
switch (option) {
case "timeline":
case "layer_panel":
case "wiki_panel":
case "zoom_panel":
return {
function_name: option,
params: [false],
};
case "close_wiki_panel":
return {
function_name: option,
params: [],
params: [draft.selected[option]],
};
case "wiki":
return {
function_name: option,
params: [draft.wiki_id],
params: [draft.wiki_id || null],
};
case "toast":
return {
function_name: option,
params: [draft.message],
};
case "wiki_header":
return {
function_name: option,
params: [draft.header_id],
};
case "playback_speed":
return {
function_name: option,
params: [toNumberOr(draft.speed, 1)],
};
}
}
@@ -1626,14 +1432,13 @@ function normalizeUiOptionValue(value: unknown): UIOptionName | null {
switch (value) {
case "timeline":
case "layer_panel":
case "wiki_panel":
case "close_wiki_panel":
case "zoom_panel":
case "wiki":
case "toast":
case "wiki_header":
case "playback_speed":
return value;
case "wiki_panel":
case "close_wiki_panel":
return "wiki";
default:
return null;
}
@@ -0,0 +1,365 @@
"use client";
import type { BackgroundLayerId } from "@/uhm/lib/map/styles/backgroundLayers";
import { BACKGROUND_LAYER_OPTIONS } from "@/uhm/lib/map/styles/backgroundLayers";
import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap";
type Props = {
backgroundVisibility: Record<string, boolean>;
geometryVisibility: Record<string, boolean>;
onToggleBackground: (id: BackgroundLayerId) => void;
onToggleGeometry: (typeKey: string) => void;
};
// Map each layer ID/geometry type to a premium inline SVG icon
const LAYER_ICONS: Record<string, React.ReactNode> = {
// Background layers
"raster-base-layer": (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
),
"bg-country-borders-line": (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12h18M3 6h18M3 18h18" strokeDasharray="2 2" />
<rect x="2" y="2" width="20" height="20" rx="4" />
</svg>
),
"bg-province-borders-line": (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 3v18M15 3v18M3 9h18M3 15h18" strokeDasharray="3 3" />
<rect x="2" y="2" width="20" height="20" rx="3" />
</svg>
),
"bg-district-borders-line": (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 3v18M12 3v18M18 3v18M3 6h18M3 12h18M3 18h18" strokeDasharray="1 3" />
<rect x="2" y="2" width="20" height="20" rx="2" />
</svg>
),
"country-labels": (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 7V4h16v3M9 20h6M12 4v16" />
</svg>
),
"rivers-line": (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12c3-3 6-3 9 0s6 3 9 0" />
<path d="M3 16c3-3 6-3 9 0s6 3 9 0" opacity="0.6" />
</svg>
),
// Polygon Geometries
country: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 22 8.5 22 15.5 12 22 2 15.5 2 8.5" />
</svg>
),
state: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 4 20 9 20 15 12 20 4 15 4 9" />
</svg>
),
faction: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1zM4 22v-7" />
</svg>
),
rebellion_zone: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z" />
</svg>
),
// Line Geometries
defense_line: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="10" rx="2" />
<path d="M12 2v9M8 5v3M16 5v3" />
</svg>
),
military_route: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 17.5L3 6M17.5 14.5L6 3" />
<path d="M12 12l9 9M18 15h3v3" />
</svg>
),
retreat_route: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
),
migration_route: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
),
trade_route: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8M12 6v12" />
</svg>
),
// Point Geometries
battle: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 17.5L3 6M17.5 14.5L6 3" />
<path d="M8.5 19.5L19.5 8.5" />
</svg>
),
person_event: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
),
temple: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2L2 7h20L12 2zM4 7v10h16V7H4zm2 10v4h2v-4H6zm10 0v4h2v-4h-2z" />
</svg>
),
capital: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polygon points="12 7 13.9 10.8 18.1 11.4 15.1 14.4 15.8 18.6 12 16.6 8.2 18.6 8.9 14.4 5.9 11.4 10.1 10.8" fill="currentColor" />
</svg>
),
city: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="4" y="2" width="16" height="20" rx="2" ry="2" />
<line x1="9" y1="22" x2="9" y2="16" />
<line x1="15" y1="22" x2="15" y2="16" />
<line x1="9" y1="16" x2="15" y2="16" />
<path d="M8 6h2M14 6h2M8 10h2M14 10h2" strokeWidth="1.5" />
</svg>
),
fortification: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 22V8l3-3h10l3 3v14H4z" />
<path d="M9 22v-6h6v6H9z" />
<path d="M8 8h8M12 5v3" />
</svg>
),
ruin: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="6" y1="20" x2="6" y2="4" />
<line x1="18" y1="20" x2="18" y2="4" />
<line x1="3" y1="4" x2="9" y2="4" />
<line x1="15" y1="4" x2="21" y2="4" />
<line x1="3" y1="20" x2="21" y2="20" />
<line x1="6" y1="12" x2="18" y2="12" strokeDasharray="3 3" />
</svg>
),
port: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="5" r="3" />
<line x1="12" y1="22" x2="12" y2="8" />
<path d="M5 12h14M12 12c-4 0-6 4-6 6a6 6 0 0 0 12 0c0-2-2-6-6-6z" />
</svg>
),
};
// Class name helper for tooltips using CSS
const buttonClassName = "preview-layer-btn";
export default function ReplayPreviewLayerPanel({
backgroundVisibility,
geometryVisibility,
onToggleBackground,
onToggleGeometry,
}: Props) {
// Categorize geometry types for logical grouping
const polygonKeys = ["country", "state", "faction", "rebellion_zone"];
const lineKeys = ["defense_line", "military_route", "retreat_route", "migration_route", "trade_route"];
const pointKeys = ["battle", "person_event", "temple", "capital", "city", "fortification", "ruin", "port"];
const getButtonStyles = (isActive: boolean, activeColor: string): React.CSSProperties => ({
border: "none",
background: isActive ? `rgba(${activeColor}, 0.18)` : "rgba(30, 41, 59, 0.4)",
color: isActive ? `rgb(${activeColor})` : "#64748b",
width: 36,
height: 36,
borderRadius: 10,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
boxShadow: isActive ? `inset 0 0 0 1px rgba(${activeColor}, 0.3), 0 0 12px rgba(${activeColor}, 0.2)` : "inset 0 0 0 1px rgba(148, 163, 184, 0.1)",
outline: "none",
});
const renderTooltipStyles = () => (
<style dangerouslySetInnerHTML={{ __html: `
.${buttonClassName} {
position: relative;
}
.${buttonClassName}::after {
content: attr(data-tooltip);
position: absolute;
left: 100%;
top: 50%;
transform: translateY(-50%) scale(0.9);
margin-left: 10px;
padding: 6px 10px;
background: rgba(15, 23, 42, 0.95);
border: 1px solid rgba(148, 163, 184, 0.25);
color: #f8fafc;
font-size: 11px;
font-weight: 800;
white-space: nowrap;
border-radius: 8px;
box-shadow: 0 10px 25px rgba(2, 6, 23, 0.5);
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease, transform 0.15s ease;
z-index: 999;
}
.${buttonClassName}:hover::after {
opacity: 1;
transform: translateY(-50%) scale(1);
}
`}} />
);
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 12,
background: "linear-gradient(145deg, rgba(15, 23, 42, 0.9), rgba(30, 41, 59, 0.8))",
border: "1px solid rgba(148, 163, 184, 0.22)",
borderRadius: 20,
padding: "14px 10px",
width: 100,
alignItems: "center",
boxShadow: "0 20px 48px rgba(2, 6, 23, 0.45)",
backdropFilter: "blur(12px)",
maxHeight: "calc(100vh - 180px)",
overflowY: "auto",
}}
>
{renderTooltipStyles()}
{/* Background layers */}
<div style={groupHeaderStyle}>Map</div>
<div style={gridStyle}>
{BACKGROUND_LAYER_OPTIONS.map((layer) => {
const active = Boolean(backgroundVisibility[layer.id]);
return (
<button
key={layer.id}
type="button"
className={buttonClassName}
data-tooltip={layer.label}
onClick={() => onToggleBackground(layer.id)}
style={getButtonStyles(active, "56, 189, 248")} // sky-400
>
{LAYER_ICONS[layer.id] || "?"}
</button>
);
})}
</div>
<div style={dividerStyle} />
{/* Territories / Polygons */}
<div style={groupHeaderStyle}>Areas</div>
<div style={gridStyle}>
{polygonKeys.map((typeKey) => {
const active = geometryVisibility[typeKey] !== false;
const label = typeKey.replace("_", " ").toUpperCase();
return (
<button
key={typeKey}
type="button"
className={buttonClassName}
data-tooltip={label}
onClick={() => onToggleGeometry(typeKey)}
style={getButtonStyles(active, "249, 115, 22")} // orange-500
>
{LAYER_ICONS[typeKey] || "?"}
</button>
);
})}
</div>
<div style={dividerStyle} />
{/* Routes / Lines */}
<div style={groupHeaderStyle}>Routes</div>
<div style={gridStyle}>
{lineKeys.map((typeKey) => {
const active = geometryVisibility[typeKey] !== false;
const label = typeKey.replace("_", " ").toUpperCase();
return (
<button
key={typeKey}
type="button"
className={buttonClassName}
data-tooltip={label}
onClick={() => onToggleGeometry(typeKey)}
style={getButtonStyles(active, "192, 132, 252")} // purple-400
>
{LAYER_ICONS[typeKey] || "?"}
</button>
);
})}
</div>
<div style={dividerStyle} />
{/* Places & Events / Points */}
<div style={groupHeaderStyle}>Points</div>
<div style={gridStyle}>
{pointKeys.map((typeKey) => {
const active = geometryVisibility[typeKey] !== false;
const label = typeKey.replace("_", " ").toUpperCase();
return (
<button
key={typeKey}
type="button"
className={buttonClassName}
data-tooltip={label}
onClick={() => onToggleGeometry(typeKey)}
style={getButtonStyles(active, "245, 158, 11")} // amber-500
>
{LAYER_ICONS[typeKey] || "?"}
</button>
);
})}
</div>
</div>
);
}
const groupHeaderStyle: React.CSSProperties = {
fontSize: 9,
fontWeight: 900,
color: "#94a3b8",
letterSpacing: 1,
textTransform: "uppercase",
width: "100%",
textAlign: "center",
marginBottom: 4,
};
const gridStyle: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 8,
width: "100%",
};
const dividerStyle: React.CSSProperties = {
height: 1,
background: "rgba(148, 163, 184, 0.15)",
width: "80%",
margin: "6px 0",
};
@@ -1,20 +1,13 @@
"use client";
import type { CSSProperties } from "react";
import type {
ReplayPreviewDialog,
ReplayPreviewImage,
ReplayPreviewToast,
} from "@/uhm/lib/replay/useReplayPreview";
import type { DialogState } from "@/uhm/types/projects";
import type { ReplayPreviewToast } from "@/uhm/lib/replay/useReplayPreview";
type Props = {
isPreviewMode: boolean;
isPlaying: boolean;
title: string;
descriptions: string;
subtitle: string | null;
dialog: ReplayPreviewDialog | null;
image: ReplayPreviewImage | null;
dialog: DialogState | null;
toasts: ReplayPreviewToast[];
sidebarOpen: boolean;
playbackSpeed: number;
@@ -30,11 +23,7 @@ type Props = {
export default function ReplayPreviewOverlay({
isPreviewMode,
isPlaying,
title,
descriptions,
subtitle,
dialog,
image,
toasts,
sidebarOpen,
playbackSpeed,
@@ -46,15 +35,11 @@ export default function ReplayPreviewOverlay({
onResetPreview,
onExitPreview,
}: Props) {
const hasNarrativeCard = title.trim().length > 0 || descriptions.trim().length > 0;
const hasWikiPreview = sidebarOpen;
const shouldRender =
isPreviewMode ||
isPlaying ||
hasNarrativeCard ||
Boolean(subtitle) ||
Boolean(dialog) ||
Boolean(image) ||
Boolean(toasts.length);
if (!shouldRender) {
@@ -70,49 +55,6 @@ export default function ReplayPreviewOverlay({
pointerEvents: "none",
}}
>
{hasNarrativeCard ? (
<div
style={{
position: "absolute",
top: 72,
left: 18,
maxWidth: 460,
borderRadius: 18,
border: "1px solid rgba(148, 163, 184, 0.26)",
background: "linear-gradient(145deg, rgba(15, 23, 42, 0.94), rgba(30, 41, 59, 0.88))",
boxShadow: "0 14px 42px rgba(2, 6, 23, 0.42)",
padding: "18px 20px",
}}
>
{title.trim().length ? (
<div
style={{
fontSize: 26,
lineHeight: 1.1,
fontWeight: 900,
color: "#f8fafc",
overflowWrap: "anywhere",
}}
>
{title}
</div>
) : null}
{descriptions.trim().length ? (
<div
style={{
marginTop: title.trim().length ? 12 : 0,
fontSize: 14,
lineHeight: 1.55,
color: "#dbeafe",
whiteSpace: "pre-wrap",
}}
>
{descriptions}
</div>
) : null}
</div>
) : null}
{toasts.length ? (
<div
style={{
@@ -144,7 +86,7 @@ export default function ReplayPreviewOverlay({
</div>
) : null}
{image ? (
{dialog?.image_url ? (
<div
style={{
position: "absolute",
@@ -159,8 +101,8 @@ export default function ReplayPreviewOverlay({
}}
>
<img
src={image.url}
alt={image.caption || "Historical image"}
src={dialog.image_url}
alt={dialog.image_caption || "Historical image"}
style={{
width: "100%",
display: "block",
@@ -169,7 +111,7 @@ export default function ReplayPreviewOverlay({
background: "#020617",
}}
/>
{image.caption?.trim() ? (
{dialog.image_caption?.trim() ? (
<div
style={{
padding: "10px 12px",
@@ -178,30 +120,29 @@ export default function ReplayPreviewOverlay({
color: "#cbd5e1",
}}
>
{image.caption}
{dialog.image_caption}
</div>
) : null}
</div>
) : null}
{dialog ? (
<div
style={{
position: "absolute",
left: dialog.side === "right" ? "auto" : 18,
right: dialog.side === "right" ? 18 : "auto",
bottom: subtitle ? 138 : 96,
maxWidth: 420,
display: "grid",
gap: 10,
gridTemplateColumns: dialog.avatar.trim().length ? "56px 1fr" : "1fr",
alignItems: "start",
}}
>
{dialog.avatar.trim().length ? (
{dialog && dialog.text?.trim() ? (
dialog.avatar?.trim() ? (
<div
style={{
position: "absolute",
left: 18,
bottom: 96,
maxWidth: 420,
display: "grid",
gap: 10,
gridTemplateColumns: "56px 1fr",
alignItems: "start",
}}
>
<img
src={dialog.avatar}
alt={dialog.speaker || "speaker"}
alt="speaker"
style={{
width: 56,
height: 56,
@@ -211,65 +152,50 @@ export default function ReplayPreviewOverlay({
background: "#0f172a",
}}
/>
) : null}
<div
style={{
borderRadius: 18,
border: "1px solid rgba(148, 163, 184, 0.24)",
background: "rgba(15, 23, 42, 0.92)",
padding: "14px 16px",
color: "#f8fafc",
boxShadow: "0 14px 36px rgba(2, 6, 23, 0.38)",
}}
>
{dialog.speaker?.trim() ? (
<div
style={{
marginBottom: 6,
fontSize: 11,
color: "#7dd3fc",
fontWeight: 900,
letterSpacing: 0.4,
}}
>
{dialog.speaker}
</div>
) : null}
<div
style={{
fontSize: 15,
lineHeight: 1.5,
whiteSpace: "pre-wrap",
borderRadius: 18,
border: "1px solid rgba(148, 163, 184, 0.24)",
background: "rgba(15, 23, 42, 0.92)",
padding: "14px 16px",
color: "#f8fafc",
boxShadow: "0 14px 36px rgba(2, 6, 23, 0.38)",
}}
>
{dialog.text}
<div
style={{
fontSize: 15,
lineHeight: 1.5,
whiteSpace: "pre-wrap",
}}
>
{dialog.text}
</div>
</div>
</div>
</div>
) : null}
{subtitle?.trim() ? (
<div
style={{
position: "absolute",
left: "50%",
bottom: 90,
transform: "translateX(-50%)",
maxWidth: 720,
borderRadius: 999,
border: "1px solid rgba(148, 163, 184, 0.24)",
background: "rgba(2, 6, 23, 0.84)",
color: "#f8fafc",
padding: "10px 18px",
fontSize: 14,
fontWeight: 700,
lineHeight: 1.45,
textAlign: "center",
boxShadow: "0 12px 32px rgba(2, 6, 23, 0.28)",
}}
>
{subtitle}
</div>
) : (
<div
style={{
position: "absolute",
left: "50%",
bottom: 90,
transform: "translateX(-50%)",
maxWidth: 720,
borderRadius: 18,
border: "1px solid rgba(148, 163, 184, 0.24)",
background: "rgba(2, 6, 23, 0.84)",
color: "#f8fafc",
padding: "10px 18px",
fontSize: 14,
fontWeight: 700,
lineHeight: 1.45,
textAlign: "center",
boxShadow: "0 12px 32px rgba(2, 6, 23, 0.28)",
}}
>
{dialog.text}
</div>
)
) : null}
{isPreviewMode ? (
@@ -10,6 +10,7 @@ import type {
ReplayStage,
ReplayStep,
UIOptionName,
DialogState,
} from "@/uhm/types/projects";
import type { UndoAction } from "@/uhm/lib/editor/state/useEditorState";
import { Panel } from "./Panel";
@@ -36,6 +37,51 @@ type Props = {
};
type ActionGroupKey = "use_UI_function" | "use_map_function" | "use_geo_function" | "use_narrow_function";
type AnyStepAction =
| ReplayAction<UIOptionName>
| ReplayAction<MapFunctionName>
| ReplayAction<GeoFunctionName>
| ReplayAction<NarrativeFunctionName>;
function validateReplayTimeFormat(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) return false;
// 1. Check DD/MM/YYYY
const dmyRegex = /^(\d{2})\/(\d{2})\/(-?\d{4})$/;
const dmyMatch = trimmed.match(dmyRegex);
if (dmyMatch) {
const day = parseInt(dmyMatch[1], 10);
const month = parseInt(dmyMatch[2], 10);
const year = parseInt(dmyMatch[3], 10);
if (month < 1 || month > 12) return false;
if (day < 1 || day > 31) return false;
if (month === 2) {
const isLeap = (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0);
if (day > (isLeap ? 29 : 28)) return false;
} else if ([4, 6, 9, 11].includes(month)) {
if (day > 30) return false;
}
return true;
}
// 2. Check MM/YYYY
const myRegex = /^(\d{2})\/(-?\d{4})$/;
const myMatch = trimmed.match(myRegex);
if (myMatch) {
const month = parseInt(myMatch[1], 10);
if (month < 1 || month > 12) return false;
return true;
}
// 3. Check YYYY
const yRegex = /^(-?\d{4})$/;
if (yRegex.test(trimmed)) {
return true;
}
return false;
}
type StageFormState = {
title: string;
@@ -97,6 +143,13 @@ export default function ReplayTimelineSidebar({
detail_time_stop: "",
});
const [createStagePanelKey, setCreateStagePanelKey] = useState(0);
const isStartValid = validateReplayTimeFormat(createStageForm.detail_time_start);
const isStopValid = validateReplayTimeFormat(createStageForm.detail_time_stop);
const isCreateFormValid = createStageForm.title.trim() !== "" && isStartValid && isStopValid;
const showStartError = createStageForm.detail_time_start.length > 0 && !isStartValid;
const showStopError = createStageForm.detail_time_stop.length > 0 && !isStopValid;
const [openWeightEditorKey, setOpenWeightEditorKey] = useState<string | null>(null);
const [openActionDetailKey, setOpenActionDetailKey] = useState<string | null>(null);
@@ -153,6 +206,10 @@ export default function ReplayTimelineSidebar({
const handleCreateStage = () => {
if (!replay) return;
if (!validateReplayTimeFormat(createStageForm.detail_time_start) ||
!validateReplayTimeFormat(createStageForm.detail_time_stop)) {
return;
}
const nextId =
stages.length > 0
? Math.max(...stages.map((stage) => stage.id)) + 1
@@ -164,7 +221,7 @@ export default function ReplayTimelineSidebar({
detail_time_stop: createStageForm.detail_time_stop.trim(),
steps: [
{
duration: 1000,
duration: 5000,
use_UI_function: [],
use_map_function: [],
use_geo_function: [],
@@ -211,6 +268,26 @@ export default function ReplayTimelineSidebar({
}
};
const handleDuplicateStage = (stageId: number) => {
let nextStageId: number | null = null;
const changed = onMutateReplay(`Replay: nhân bản stage #${stageId}`, (draftReplay) => {
const index = draftReplay.detail.findIndex((item) => item.id === stageId);
if (index === -1) return;
nextStageId = draftReplay.detail.length > 0
? Math.max(...draftReplay.detail.map((stage) => stage.id)) + 1
: 0;
const source = draftReplay.detail[index];
draftReplay.detail.splice(index + 1, 0, {
...source,
id: nextStageId,
title: source.title ? `${source.title} copy` : undefined,
steps: source.steps.map(cloneReplayStep),
});
});
if (!changed || nextStageId == null) return;
onSelectStep(nextStageId, 0);
};
const handleAddStep = (stageId: number) => {
let nextStepIndex: number | null = null;
const changed = onMutateReplay(`Replay: tạo step cho stage #${stageId}`, (draftReplay) => {
@@ -220,7 +297,7 @@ export default function ReplayTimelineSidebar({
stage.steps = [
...stage.steps,
{
duration: 1000,
duration: 5000,
use_UI_function: [],
use_map_function: [],
use_geo_function: [],
@@ -272,6 +349,21 @@ export default function ReplayTimelineSidebar({
}
};
const handleDuplicateStep = (stageId: number, stepIndex: number) => {
let nextSelectedIndex = stepIndex + 1;
const changed = onMutateReplay(
`Replay: nhân bản step ${stepIndex + 1} của stage #${stageId}`,
(draftReplay) => {
const stage = draftReplay.detail.find((item) => item.id === stageId);
if (!stage || stepIndex < 0 || stepIndex >= stage.steps.length) return;
stage.steps.splice(stepIndex + 1, 0, cloneReplayStep(stage.steps[stepIndex]));
nextSelectedIndex = stepIndex + 1;
}
);
if (!changed) return;
onSelectStep(stageId, nextSelectedIndex);
};
const handleDeleteAction = (
stageId: number,
stepIndex: number,
@@ -309,6 +401,52 @@ export default function ReplayTimelineSidebar({
);
};
const handleDuplicateAction = (
stageId: number,
stepIndex: number,
groupKey: ActionGroupKey,
actionIndex: number,
actionTitle: string
) => {
onMutateReplay(
`Replay: nhân bản ${actionTitle} ở step ${stepIndex + 1} của stage #${stageId}`,
(draftReplay) => {
const stage = draftReplay.detail.find((item) => item.id === stageId);
if (!stage || stepIndex < 0 || stepIndex >= stage.steps.length) return;
const step = stage.steps[stepIndex];
const actions = [...getStepActionGroup(step, groupKey)];
if (actionIndex < 0 || actionIndex >= actions.length) return;
actions.splice(actionIndex + 1, 0, cloneReplayAction(actions[actionIndex]) as AnyStepAction);
setStepActionGroup(step, groupKey, actions);
}
);
};
const handleUpdateActionParams = (
stageId: number,
stepIndex: number,
groupKey: ActionGroupKey,
actionIndex: number,
actionTitle: string,
nextParams: unknown[]
) => {
onMutateReplay(
`Replay: cập nhật params ${actionTitle} ở step ${stepIndex + 1} của stage #${stageId}`,
(draftReplay) => {
const stage = draftReplay.detail.find((item) => item.id === stageId);
if (!stage || stepIndex < 0 || stepIndex >= stage.steps.length) return;
const step = stage.steps[stepIndex];
const actions = [...getStepActionGroup(step, groupKey)];
if (actionIndex < 0 || actionIndex >= actions.length) return;
actions[actionIndex] = {
...actions[actionIndex],
params: nextParams.map(cloneReplayParam),
} as AnyStepAction;
setStepActionGroup(step, groupKey, actions);
}
);
};
return (
<aside
style={{
@@ -452,7 +590,7 @@ export default function ReplayTimelineSidebar({
) : null}
</div>
<div style={{ fontSize: 11, color: "#94a3b8" }}>
Preview sẽ mở trong mode riêng với snapshot replay tại thời điểm bấm play.
Preview sẽ mở trong mode riêng với snapshot replay tại thời điểm bấm play. Speed {previewPlaybackSpeed}x.
</div>
</div>
</Panel>
@@ -465,37 +603,63 @@ export default function ReplayTimelineSidebar({
onChange={(event) =>
setCreateStageForm((prev) => ({ ...prev, title: event.target.value }))
}
placeholder="Title"
style={inputStyle}
/>
<input
value={createStageForm.detail_time_start}
onChange={(event) =>
setCreateStageForm((prev) => ({
...prev,
detail_time_start: event.target.value,
}))
}
placeholder="detail_time_start"
style={inputStyle}
/>
<input
value={createStageForm.detail_time_stop}
onChange={(event) =>
setCreateStageForm((prev) => ({
...prev,
detail_time_stop: event.target.value,
}))
}
placeholder="detail_time_stop"
placeholder="Title (bắt buộc)"
style={inputStyle}
/>
<div>
<input
value={createStageForm.detail_time_start}
onChange={(event) =>
setCreateStageForm((prev) => ({
...prev,
detail_time_start: event.target.value,
}))
}
placeholder="Thời gian bắt đầu (detail_time_start)"
style={{
...inputStyle,
borderColor: showStartError ? "#ef4444" : "#334155",
}}
/>
{showStartError ? (
<div style={{ color: "#ef4444", fontSize: 10, marginTop: 3 }}>
Đnh dạng không hợp lệ (DD/MM/YYYY, MM/YYYY, hoặc YYYY)
</div>
) : null}
</div>
<div>
<input
value={createStageForm.detail_time_stop}
onChange={(event) =>
setCreateStageForm((prev) => ({
...prev,
detail_time_stop: event.target.value,
}))
}
placeholder="Thời gian kết thúc (detail_time_stop)"
style={{
...inputStyle,
borderColor: showStopError ? "#ef4444" : "#334155",
}}
/>
{showStopError ? (
<div style={{ color: "#ef4444", fontSize: 10, marginTop: 3 }}>
Đnh dạng không hợp lệ (DD/MM/YYYY, MM/YYYY, hoặc YYYY)
</div>
) : null}
</div>
<div style={{ fontSize: 10, color: "#94a3b8", lineHeight: "1.4" }}>
Cấu trúc bắt buộc: <strong>DD/MM/YYYY</strong>, <strong>MM/YYYY</strong>, hoặc <strong>YYYY</strong>.
</div>
<button
type="button"
disabled={!isCreateFormValid}
onClick={handleCreateStage}
style={{
...buttonStyle,
background: "#1d4ed8",
background: isCreateFormValid ? "#1d4ed8" : "#1e293b",
color: isCreateFormValid ? "white" : "#64748b",
cursor: isCreateFormValid ? "pointer" : "not-allowed",
border: "none",
}}
>
@@ -555,7 +719,7 @@ export default function ReplayTimelineSidebar({
{stage.detail_time_start || "?"} {stage.detail_time_stop || "?"}
</div>
</button>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, minmax(0, 1fr))", gap: 5 }}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, minmax(0, 1fr))", gap: 5 }}>
<button
type="button"
onClick={() => handleMoveStage(stage.id, -1)}
@@ -583,6 +747,17 @@ export default function ReplayTimelineSidebar({
>
Thêm step
</button>
<button
type="button"
onClick={() => handleDuplicateStage(stage.id)}
style={{
...smallButtonStyle(false),
background: "#334155",
border: "none",
}}
>
Copy
</button>
<button
type="button"
onClick={() => handleDeleteStage(stage.id)}
@@ -753,32 +928,38 @@ export default function ReplayTimelineSidebar({
</span>
</div>
</button>
<button
type="button"
onClick={() =>
handleDeleteAction(
stage.id,
stepIndex,
entry.groupKey,
entry.actionIndex,
entry.title
)
}
style={{
padding: "3px 6px",
borderRadius: 6,
border: "none",
background: "#7f1d1d",
color: "white",
cursor: "pointer",
fontSize: 10,
fontWeight: 800,
flex: "0 0 auto",
alignSelf: "start",
}}
>
Xóa
</button>
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, auto)", gap: 4 }}>
<button
type="button"
onClick={() =>
handleDuplicateAction(
stage.id,
stepIndex,
entry.groupKey,
entry.actionIndex,
entry.title
)
}
style={actionButtonStyle(false, "#0f766e")}
>
Copy
</button>
<button
type="button"
onClick={() =>
handleDeleteAction(
stage.id,
stepIndex,
entry.groupKey,
entry.actionIndex,
entry.title
)
}
style={actionButtonStyle(false, "#7f1d1d")}
>
Xóa
</button>
</div>
</div>
{isActionOpen ? (
<div
@@ -790,6 +971,20 @@ export default function ReplayTimelineSidebar({
}}
>
{entry.summary}
<InlineActionParamsEditor
key={actionKey}
action={entry.action}
onApply={(nextParams) =>
handleUpdateActionParams(
stage.id,
stepIndex,
entry.groupKey,
entry.actionIndex,
entry.title,
nextParams
)
}
/>
</div>
) : null}
</div>
@@ -801,8 +996,8 @@ export default function ReplayTimelineSidebar({
style={{
display: "grid",
gridTemplateColumns: isSelectedStep
? "repeat(4, minmax(0, 1fr))"
: "repeat(3, minmax(0, 1fr))",
? "repeat(5, minmax(0, 1fr))"
: "repeat(4, minmax(0, 1fr))",
gap: 5,
}}
>
@@ -841,6 +1036,17 @@ export default function ReplayTimelineSidebar({
Weight
</button>
) : null}
<button
type="button"
onClick={() => handleDuplicateStep(stage.id, stepIndex)}
style={{
...smallButtonStyle(false),
background: "#334155",
border: "none",
}}
>
Copy
</button>
<button
type="button"
onClick={() => handleDeleteStep(stage.id, stepIndex)}
@@ -922,7 +1128,15 @@ function StageMetadataEditor({
});
}, [stage]);
const isStartValid = validateReplayTimeFormat(form.detail_time_start);
const isStopValid = validateReplayTimeFormat(form.detail_time_stop);
const isEditFormValid = form.title.trim() !== "" && isStartValid && isStopValid;
const showStartError = form.detail_time_start.length > 0 && !isStartValid;
const showStopError = form.detail_time_stop.length > 0 && !isStopValid;
const handleApplyStageMetadata = () => {
if (!isEditFormValid) return;
onMutateReplay(`Replay: cập nhật stage #${stage.id}`, (draftReplay) => {
const targetStage = draftReplay.detail.find((item) => item.id === stage.id);
if (!targetStage) return;
@@ -940,37 +1154,63 @@ function StageMetadataEditor({
onChange={(event) =>
setForm((prev) => ({ ...prev, title: event.target.value }))
}
placeholder="Title"
style={inputStyle}
/>
<input
value={form.detail_time_start}
onChange={(event) =>
setForm((prev) => ({
...prev,
detail_time_start: event.target.value,
}))
}
placeholder="detail_time_start"
style={inputStyle}
/>
<input
value={form.detail_time_stop}
onChange={(event) =>
setForm((prev) => ({
...prev,
detail_time_stop: event.target.value,
}))
}
placeholder="detail_time_stop"
placeholder="Title (bắt buộc)"
style={inputStyle}
/>
<div>
<input
value={form.detail_time_start}
onChange={(event) =>
setForm((prev) => ({
...prev,
detail_time_start: event.target.value,
}))
}
placeholder="Thời gian bắt đầu (detail_time_start)"
style={{
...inputStyle,
borderColor: showStartError ? "#ef4444" : "#334155",
}}
/>
{showStartError ? (
<div style={{ color: "#ef4444", fontSize: 10, marginTop: 3 }}>
Đnh dạng không hợp lệ (DD/MM/YYYY, MM/YYYY, hoặc YYYY)
</div>
) : null}
</div>
<div>
<input
value={form.detail_time_stop}
onChange={(event) =>
setForm((prev) => ({
...prev,
detail_time_stop: event.target.value,
}))
}
placeholder="Thời gian kết thúc (detail_time_stop)"
style={{
...inputStyle,
borderColor: showStopError ? "#ef4444" : "#334155",
}}
/>
{showStopError ? (
<div style={{ color: "#ef4444", fontSize: 10, marginTop: 3 }}>
Đnh dạng không hợp lệ (DD/MM/YYYY, MM/YYYY, hoặc YYYY)
</div>
) : null}
</div>
<div style={{ fontSize: 10, color: "#94a3b8", lineHeight: "1.4" }}>
Cấu trúc bắt buộc: <strong>DD/MM/YYYY</strong>, <strong>MM/YYYY</strong>, hoặc <strong>YYYY</strong>.
</div>
<button
type="button"
disabled={!isEditFormValid}
onClick={handleApplyStageMetadata}
style={{
...buttonStyle,
background: "#0f766e",
background: isEditFormValid ? "#0f766e" : "#1e293b",
color: isEditFormValid ? "white" : "#64748b",
cursor: isEditFormValid ? "pointer" : "not-allowed",
border: "none",
}}
>
@@ -1017,6 +1257,22 @@ function smallButtonStyle(disabled: boolean) {
} as const;
}
function actionButtonStyle(disabled: boolean, background: string) {
return {
padding: "3px 6px",
borderRadius: 6,
border: "none",
background: disabled ? "#1e293b" : background,
color: "white",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.55 : 1,
fontSize: 10,
fontWeight: 800,
flex: "0 0 auto",
alignSelf: "start",
} as const;
}
function InlineStepDurationEditor({
stageId,
stepIndex,
@@ -1070,10 +1326,76 @@ function InlineStepDurationEditor({
);
}
function InlineActionParamsEditor({
action,
onApply,
}: {
action: AnyStepAction;
onApply: (params: unknown[]) => void;
}) {
const [paramsText, setParamsText] = useState(() => JSON.stringify(action.params, null, 2));
const [error, setError] = useState<string | null>(null);
const handleApply = () => {
try {
const parsed = JSON.parse(paramsText);
if (!Array.isArray(parsed)) {
setError("Params phải là JSON array.");
return;
}
setError(null);
onApply(parsed);
} catch (err) {
setError(err instanceof Error ? err.message : "JSON không hợp lệ.");
}
};
return (
<div style={{ display: "grid", gap: 5, marginTop: 6 }}>
<textarea
value={paramsText}
onChange={(event) => {
setParamsText(event.target.value);
setError(null);
}}
rows={Math.min(8, Math.max(3, paramsText.split("\n").length))}
spellCheck={false}
style={{
...inputStyle,
minHeight: 76,
resize: "vertical",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
fontSize: 11,
lineHeight: 1.35,
}}
/>
{error ? (
<div style={{ color: "#fca5a5", fontSize: 10, lineHeight: 1.3 }}>
{error}
</div>
) : null}
<button
type="button"
onClick={handleApply}
style={{
...smallButtonStyle(false),
background: "#0f766e",
border: "none",
justifySelf: "start",
padding: "5px 10px",
}}
>
Apply params
</button>
</div>
);
}
type StepActionEntry = {
group: "Narrative" | "Map" | "Geo" | "UI";
groupKey: ActionGroupKey;
actionIndex: number;
action: AnyStepAction;
functionName: string;
title: string;
summary: string;
@@ -1084,55 +1406,30 @@ type StepActionEntry = {
const uiOptionLabels: Record<UIOptionName, string> = {
timeline: "Timeline",
layer_panel: "Layer Panel",
wiki_panel: "Wiki Panel",
close_wiki_panel: "Đóng Wiki Panel",
zoom_panel: "Zoom Panel",
wiki: "Wiki",
toast: "Toast",
wiki_header: "Wiki Header",
playback_speed: "Playback Speed",
};
const narrativeFunctionLabels: Record<NarrativeFunctionName, string> = {
set_title: "Tiêu đề step",
clear_title: "Xóa tiêu đề",
set_descriptions: "Mô tả",
clear_descriptions: "Xóa mô tả",
show_dialog_box: "Dialog box",
clear_dialog_box: "Đóng dialog box",
display_historical_image: "Ảnh lịch sử",
clear_historical_image: "Xóa ảnh lịch sử",
set_step_subtitle: "Phụ đề",
clear_step_subtitle: "Xóa phụ đề",
set_dialog: "Dialog box",
};
const mapFunctionLabels: Record<MapFunctionName, string> = {
set_camera_view: "Camera view",
set_time_filter: "Lọc năm",
enable_timeline_filter: "Bật timeline filter",
disable_timeline_filter: "Tắt timeline filter",
toggle_labels: "Bật/tắt labels",
show_labels: "Hiện labels",
hide_labels: "Ẩn labels",
show_all_geometries: "Hiện tất cả geo",
reset_camera_north: "North up",
set_timeline_filter: "Lọc timeline",
set_labels_visible: "Hiện nhãn map",
};
const geoFunctionLabels: Record<GeoFunctionName, string> = {
fly_to_geometry: "Fly tới geo",
fly_to_geometries: "Fly tới geo",
set_geometry_visibility: "Ẩn/hiện geometry",
show_geometries: "Hiện geo",
hide_geometries: "Ẩn geo",
fit_to_geometries: "Fit nhiều geo",
orbit_camera_around_geometry: "Orbit quanh geo",
follow_geometries_path: "Follow path",
hide_others_geometries: "Ẩn geo khác",
pulse_geometry: "Pulse geometry",
animate_dashed_border: "Border nét đứt",
set_geometry_style: "Style geometry",
show_geometry_label: "Label geometry",
follow_geometry_path: "Follow path",
follow_geometries_path: "Follow path",
dim_other_geometries: "Ẩn geo khác",
orbit_camera_around_geometry: "Orbit quanh geo",
};
function buildStepActionEntries(step: ReplayStep): StepActionEntry[] {
@@ -1159,50 +1456,30 @@ function buildNarrativeActionEntry(
const params = Array.isArray(action.params) ? action.params : [];
let summary = "Không có tham số.";
switch (action.function_name) {
case "set_title":
summary = summarizeValue(params[0], "Tiêu đề trống");
break;
case "clear_title":
summary = "title=null";
break;
case "set_descriptions":
summary = summarizeValue(params[0], "Mô tả trống");
break;
case "clear_descriptions":
summary = "descriptions=null";
break;
case "show_dialog_box":
summary = [
`speaker=${summarizeValue(params[3], "ẩn danh")}`,
`side=${summarizeValue(params[2], "left")}`,
`text=${summarizeValue(params[1], "trống")}`,
].join(" | ");
break;
case "clear_dialog_box":
if (action.function_name === "set_dialog") {
const dialog = params[0] as DialogState | null;
if (dialog === null) {
summary = "dialog=null";
break;
case "display_historical_image":
summary = [
`url=${summarizeValue(params[0], "trống")}`,
`caption=${summarizeValue(params[1], "trống")}`,
].join(" | ");
break;
case "clear_historical_image":
summary = "image=null";
break;
case "set_step_subtitle":
summary = summarizeValue(params[0], "Ẩn subtitle");
break;
case "clear_step_subtitle":
summary = "subtitle=null";
break;
} else {
const parts: string[] = [];
if (dialog.text) {
parts.push(`text=${summarizeValue(dialog.text, "")}`);
}
if (dialog.avatar) {
parts.push(`avatar=${summarizeValue(dialog.avatar, "")}`);
}
if (dialog.image_url) {
parts.push(`image=${summarizeValue(dialog.image_url, "")}`);
}
summary = parts.join(" | ") || "trống";
}
}
return {
group: "Narrative",
groupKey: "use_narrow_function",
actionIndex,
action,
functionName: action.function_name,
title: narrativeFunctionLabels[action.function_name],
summary,
@@ -1219,39 +1496,22 @@ function buildMapActionEntry(
let summary = "Không có tham số.";
switch (action.function_name) {
case "set_time_filter":
summary = `year=${summarizeValue(params[0], "trống")}`;
case "set_timeline_filter":
summary = `enabled=${Boolean(params[0] ?? true) ? "true" : "false"}`;
break;
case "enable_timeline_filter":
summary = "enabled=true";
break;
case "disable_timeline_filter":
summary = "enabled=false";
break;
case "toggle_labels":
case "set_labels_visible":
summary = `visible=${Boolean(params[0] ?? true) ? "true" : "false"}`;
break;
case "show_labels":
summary = "visible=true";
break;
case "hide_labels":
summary = "visible=false";
break;
case "show_all_geometries":
summary = "hidden_ids=[]";
break;
case "set_camera_view":
summary = summarizeCameraViewValue(params[0]);
break;
case "reset_camera_north":
summary = "bearing=0";
break;
}
return {
group: "Map",
groupKey: "use_map_function",
actionIndex,
action,
functionName: action.function_name,
title: mapFunctionLabels[action.function_name],
summary,
@@ -1268,14 +1528,6 @@ function buildGeoActionEntry(
let summary = "Không có tham số.";
switch (action.function_name) {
case "fly_to_geometry":
summary = [
`geometry=${summarizeValue(params[0], "trống")}`,
`zoom=${summarizeValue(params[1], "mặc định")}`,
`padding=${summarizeValue(params[2], "mặc định")}`,
`duration=${summarizeValue(params[3], "mặc định")}`,
].join(" | ");
break;
case "fly_to_geometries":
summary = `geometry=${summarizeGeometryIdsValue(params[0])}`;
break;
@@ -1285,19 +1537,6 @@ function buildGeoActionEntry(
`visible=${Boolean(params[1] ?? true) ? "true" : "false"}`,
].join(" | ");
break;
case "show_geometries":
summary = `geometry=${summarizeGeometryIdsValue(params[0])} | visible=true`;
break;
case "hide_geometries":
summary = `geometry=${summarizeGeometryIdsValue(params[0])} | visible=false`;
break;
case "fit_to_geometries":
summary = [
`geometry=${summarizeGeometryIdsValue(params[0])}`,
`padding=${summarizeValue(params[1], "mặc định")}`,
`duration=${summarizeValue(params[2], "mặc định")}`,
].join(" | ");
break;
case "orbit_camera_around_geometry":
summary = [
`geometry=${summarizeValue(params[0], "trống")}`,
@@ -1333,22 +1572,6 @@ function buildGeoActionEntry(
`line_width=${summarizeValue(params[4], "mặc định")}`,
].join(" | ");
break;
case "show_geometry_label":
summary = [
`geometry=${summarizeValue(params[0], "trống")}`,
`text=${summarizeValue(params[1], "trống")}`,
`color=${summarizeValue(params[2], "#ffffff")}`,
`size=${summarizeValue(params[3], "mặc định")}`,
].join(" | ");
break;
case "follow_geometry_path":
summary = [
`geometry=${summarizeValue(params[0], "trống")}`,
`duration=${summarizeValue(params[1], "mặc định")}`,
`zoom=${summarizeValue(params[2], "mặc định")}`,
`pitch=${summarizeValue(params[3], "mặc định")}`,
].join(" | ");
break;
case "follow_geometries_path":
summary = [
`geometry=${summarizeGeometryIdsValue(params[0])}`,
@@ -1357,7 +1580,7 @@ function buildGeoActionEntry(
`pitch=${summarizeValue(params[3], "mặc định")}`,
].join(" | ");
break;
case "dim_other_geometries":
case "hide_others_geometries":
summary = [
`keep=${summarizeGeometryIdsValue(params[0])}`,
].join(" | ");
@@ -1368,6 +1591,7 @@ function buildGeoActionEntry(
group: "Geo",
groupKey: "use_geo_function",
actionIndex,
action,
functionName: action.function_name,
title: geoFunctionLabels[action.function_name],
summary,
@@ -1386,18 +1610,12 @@ function buildUiActionEntry(
const optionLabel = option ? uiOptionLabels[option] : summarizeValue(action.function_name, "Unknown option");
let summary = "Không có tham số.";
if (option === "timeline" || option === "layer_panel" || option === "wiki_panel" || option === "zoom_panel") {
if (option === "timeline" || option === "layer_panel" || option === "zoom_panel") {
summary = `visible=${Boolean(params[0]) ? "true" : "false"}`;
} else if (option === "close_wiki_panel") {
summary = "visible=false | active_wiki=null";
} else if (option === "wiki") {
summary = `wiki_id=${summarizeValue(params[0], "trống")}`;
} else if (option === "toast") {
summary = `message=${summarizeValue(params[0], "trống")}`;
} else if (option === "wiki_header") {
summary = `header_id=${summarizeValue(params[0], "trống")}`;
} else if (option === "playback_speed") {
summary = `speed=${summarizeValue(params[0], "1")}`;
} else if (params.length > 0) {
summary = summarizeValue(params, "Không có tham số");
}
@@ -1406,6 +1624,7 @@ function buildUiActionEntry(
group: "UI",
groupKey: "use_UI_function",
actionIndex,
action,
functionName: action.function_name,
title: optionLabel,
summary,
@@ -1453,14 +1672,13 @@ function normalizeUiOptionValue(value: unknown): UIOptionName | null {
switch (value) {
case "timeline":
case "layer_panel":
case "wiki_panel":
case "close_wiki_panel":
case "zoom_panel":
case "wiki":
case "toast":
case "wiki_header":
case "playback_speed":
return value;
case "wiki_panel":
case "close_wiki_panel":
return "wiki";
default:
return null;
}
@@ -1489,6 +1707,66 @@ function getUiActionDescriptor(action: {
};
}
function getStepActionGroup(step: ReplayStep, groupKey: ActionGroupKey): AnyStepAction[] {
switch (groupKey) {
case "use_UI_function":
return step.use_UI_function;
case "use_map_function":
return step.use_map_function;
case "use_geo_function":
return step.use_geo_function;
case "use_narrow_function":
return step.use_narrow_function;
}
}
function setStepActionGroup(
step: ReplayStep,
groupKey: ActionGroupKey,
actions: AnyStepAction[]
) {
switch (groupKey) {
case "use_UI_function":
step.use_UI_function = actions as ReplayStep["use_UI_function"];
return;
case "use_map_function":
step.use_map_function = actions as ReplayStep["use_map_function"];
return;
case "use_geo_function":
step.use_geo_function = actions as ReplayStep["use_geo_function"];
return;
case "use_narrow_function":
step.use_narrow_function = actions as ReplayStep["use_narrow_function"];
return;
}
}
function cloneReplayStep(step: ReplayStep): ReplayStep {
return {
duration: step.duration,
use_UI_function: step.use_UI_function.map(cloneReplayAction) as ReplayStep["use_UI_function"],
use_map_function: step.use_map_function.map(cloneReplayAction) as ReplayStep["use_map_function"],
use_geo_function: step.use_geo_function.map(cloneReplayAction) as ReplayStep["use_geo_function"],
use_narrow_function: step.use_narrow_function.map(cloneReplayAction) as ReplayStep["use_narrow_function"],
};
}
function cloneReplayAction<T>(action: ReplayAction<T>): ReplayAction<T> {
return {
function_name: action.function_name,
params: action.params.map(cloneReplayParam),
};
}
function cloneReplayParam(value: unknown): unknown {
if (value == null || typeof value !== "object") return value;
try {
return JSON.parse(JSON.stringify(value));
} catch {
return value;
}
}
function summarizeValue(value: unknown, fallback = "trống") {
if (value == null) return fallback;
if (typeof value === "string") {