= {
+ // Background layers
+ "raster-base-layer": (
+
+ ),
+ "bg-country-borders-line": (
+
+ ),
+ "bg-province-borders-line": (
+
+ ),
+ "bg-district-borders-line": (
+
+ ),
+ "country-labels": (
+
+ ),
+ "rivers-line": (
+
+ ),
+
+ // Polygon Geometries
+ country: (
+
+ ),
+ state: (
+
+ ),
+ faction: (
+
+ ),
+ rebellion_zone: (
+
+ ),
+
+ // Line Geometries
+ defense_line: (
+
+ ),
+ military_route: (
+
+ ),
+ retreat_route: (
+
+ ),
+ migration_route: (
+
+ ),
+ trade_route: (
+
+ ),
+
+ // Point Geometries
+ battle: (
+
+ ),
+ person_event: (
+
+ ),
+ temple: (
+
+ ),
+ capital: (
+
+ ),
+ city: (
+
+ ),
+ fortification: (
+
+ ),
+ ruin: (
+
+ ),
+ port: (
+
+ ),
+};
+
+// 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 = () => (
+
+ );
+
+ return (
+
+ {renderTooltipStyles()}
+
+ {/* Background layers */}
+
Map
+
+ {BACKGROUND_LAYER_OPTIONS.map((layer) => {
+ const active = Boolean(backgroundVisibility[layer.id]);
+ return (
+
+ );
+ })}
+
+
+
+
+ {/* Territories / Polygons */}
+
Areas
+
+ {polygonKeys.map((typeKey) => {
+ const active = geometryVisibility[typeKey] !== false;
+ const label = typeKey.replace("_", " ").toUpperCase();
+ return (
+
+ );
+ })}
+
+
+
+
+ {/* Routes / Lines */}
+
Routes
+
+ {lineKeys.map((typeKey) => {
+ const active = geometryVisibility[typeKey] !== false;
+ const label = typeKey.replace("_", " ").toUpperCase();
+ return (
+
+ );
+ })}
+
+
+
+
+ {/* Places & Events / Points */}
+
Points
+
+ {pointKeys.map((typeKey) => {
+ const active = geometryVisibility[typeKey] !== false;
+ const label = typeKey.replace("_", " ").toUpperCase();
+ return (
+
+ );
+ })}
+
+
+ );
+}
+
+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",
+};
diff --git a/src/uhm/components/editor/ReplayPreviewOverlay.tsx b/src/uhm/components/editor/ReplayPreviewOverlay.tsx
index a895554..c29d2d7 100644
--- a/src/uhm/components/editor/ReplayPreviewOverlay.tsx
+++ b/src/uhm/components/editor/ReplayPreviewOverlay.tsx
@@ -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 ? (
-
- {title.trim().length ? (
-
- {title}
-
- ) : null}
- {descriptions.trim().length ? (
-
- {descriptions}
-
- ) : null}
-
- ) : null}
-
{toasts.length ? (
) : null}
- {image ? (
+ {dialog?.image_url ? (

- {image.caption?.trim() ? (
+ {dialog.image_caption?.trim() ? (
- {image.caption}
+ {dialog.image_caption}
) : null}
) : null}
- {dialog ? (
-
- {dialog.avatar.trim().length ? (
+ {dialog && dialog.text?.trim() ? (
+ dialog.avatar?.trim() ? (
+

- ) : null}
-
- {dialog.speaker?.trim() ? (
-
- {dialog.speaker}
-
- ) : null}
- {dialog.text}
+
+ {dialog.text}
+
-
- ) : null}
-
- {subtitle?.trim() ? (
-
- {subtitle}
-
+ ) : (
+
+ {dialog.text}
+
+ )
) : null}
{isPreviewMode ? (
diff --git a/src/uhm/components/editor/ReplayTimelineSidebar.tsx b/src/uhm/components/editor/ReplayTimelineSidebar.tsx
index 713c2db..8704093 100644
--- a/src/uhm/components/editor/ReplayTimelineSidebar.tsx
+++ b/src/uhm/components/editor/ReplayTimelineSidebar.tsx
@@ -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
+ | ReplayAction
+ | ReplayAction
+ | ReplayAction;
+
+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(null);
const [openActionDetailKey, setOpenActionDetailKey] = useState(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 (
@@ -465,37 +603,63 @@ export default function ReplayTimelineSidebar({
onChange={(event) =>
setCreateStageForm((prev) => ({ ...prev, title: event.target.value }))
}
- placeholder="Title"
- style={inputStyle}
- />
-
- setCreateStageForm((prev) => ({
- ...prev,
- detail_time_start: event.target.value,
- }))
- }
- placeholder="detail_time_start"
- style={inputStyle}
- />
-
- setCreateStageForm((prev) => ({
- ...prev,
- detail_time_stop: event.target.value,
- }))
- }
- placeholder="detail_time_stop"
+ placeholder="Title (bắt buộc)"
style={inputStyle}
/>
+
+
+ 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 ? (
+
+ Định dạng không hợp lệ (DD/MM/YYYY, MM/YYYY, hoặc YYYY)
+
+ ) : null}
+
+
+
+ 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 ? (
+
+ Định dạng không hợp lệ (DD/MM/YYYY, MM/YYYY, hoặc YYYY)
+
+ ) : null}
+
+
+ Cấu trúc bắt buộc: DD/MM/YYYY, MM/YYYY, hoặc YYYY.
+
-
+
+
-
+
+
+
+
{isActionOpen ? (
{entry.summary}
+
+ handleUpdateActionParams(
+ stage.id,
+ stepIndex,
+ entry.groupKey,
+ entry.actionIndex,
+ entry.title,
+ nextParams
+ )
+ }
+ />
) : null}