refactor: decouple map effects from dispatcher and consolidate replay actions into a unified catalog
This commit is contained in:
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user