preview mode

This commit is contained in:
taDuc
2026-05-18 13:45:35 +07:00
parent c09928a2b2
commit 97d505dcc7
14 changed files with 1657 additions and 208 deletions
+7
View File
@@ -43,5 +43,12 @@ export function ModeHint({ mode }: { mode: EditorMode }) {
</div>
)
}
if (mode === "replay_preview") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
Đang xem preview replay trên session tách biệt.
</div>
)
}
return null;
}
@@ -85,6 +85,7 @@ 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" },
@@ -96,6 +97,7 @@ const uiSimpleOptionValues: UIOptionName[] = [
"timeline",
"layer_panel",
"wiki_panel",
"close_wiki_panel",
"zoom_panel",
];
@@ -153,6 +155,13 @@ const narrativeActionDefinitions: NarrativeActionDefinitionMap = {
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" }],
@@ -160,6 +169,13 @@ const narrativeActionDefinitions: NarrativeActionDefinitionMap = {
deserialize: (params) => ({ text: asString(params[0]) }),
serialize: (values) => [asString(values.text)],
},
clear_descriptions: {
label: "Xóa mô tả",
fields: [],
create: () => ({ function_name: "clear_descriptions", params: [] }),
deserialize: () => ({}),
serialize: () => [],
},
show_dialog_box: {
label: "Dialog box",
fields: [
@@ -190,6 +206,13 @@ const narrativeActionDefinitions: NarrativeActionDefinitionMap = {
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: [
@@ -206,6 +229,13 @@ const narrativeActionDefinitions: NarrativeActionDefinitionMap = {
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" }],
@@ -213,6 +243,13 @@ const narrativeActionDefinitions: NarrativeActionDefinitionMap = {
deserialize: (params) => ({ subtitle: params[0] == null ? "" : asString(params[0]) }),
serialize: (values) => [emptyToNull(asString(values.subtitle))],
},
clear_step_subtitle: {
label: "Xóa phụ đề",
fields: [],
create: () => ({ function_name: "clear_step_subtitle", params: [] }),
deserialize: () => ({}),
serialize: () => [],
},
};
export default function ReplayEffectsSidebar({
@@ -451,6 +488,16 @@ function MapFunctionShortcutPanel({
)
}
/>
<ShortcutButton
label="Show All Geo"
tone="green"
onClick={() =>
onAppendActions(
[{ function_name: "show_all_geometries", params: [] }],
"Map: show all geometries"
)
}
/>
</div>
</div>
</Panel>
@@ -576,13 +623,13 @@ function GeoFunctionShortcutPanel({
}
/>
<ShortcutButton
label="Dim Others"
label="Hide Others"
tone="slate"
disabled={!hasSelection}
onClick={() =>
onAppendActions(
[{ function_name: "dim_other_geometries", params: [selectedIds, 0.18] }],
`Geo: dim others ngoài ${selectedCount} geo`
[{ function_name: "dim_other_geometries", params: [selectedIds] }],
`Geo: hide others ngoài ${selectedCount} geo`
)
}
/>
@@ -1402,6 +1449,7 @@ function buildEmptyUiOptionSelection(): Record<UIOptionName, boolean> {
timeline: false,
layer_panel: false,
wiki_panel: false,
close_wiki_panel: false,
zoom_panel: false,
wiki: false,
toast: false,
@@ -1523,6 +1571,11 @@ function buildUiOptionAction(
function_name: option,
params: [false],
};
case "close_wiki_panel":
return {
function_name: option,
params: [],
};
case "wiki":
return {
function_name: option,
@@ -1574,6 +1627,7 @@ function normalizeUiOptionValue(value: unknown): UIOptionName | null {
case "timeline":
case "layer_panel":
case "wiki_panel":
case "close_wiki_panel":
case "zoom_panel":
case "wiki":
case "toast":
@@ -0,0 +1,415 @@
"use client";
import type { CSSProperties } from "react";
import type {
ReplayPreviewDialog,
ReplayPreviewImage,
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;
toasts: ReplayPreviewToast[];
sidebarOpen: boolean;
playbackSpeed: number;
activeStepLabel: string | null;
activeStepNumber: number | null;
totalSteps: number;
onPlayPreview: () => void;
onStopPreview: () => void;
onResetPreview: () => void;
onExitPreview: () => void;
};
export default function ReplayPreviewOverlay({
isPreviewMode,
isPlaying,
title,
descriptions,
subtitle,
dialog,
image,
toasts,
sidebarOpen,
playbackSpeed,
activeStepLabel,
activeStepNumber,
totalSteps,
onPlayPreview,
onStopPreview,
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) {
return null;
}
return (
<div
style={{
position: "absolute",
inset: 0,
zIndex: 15,
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={{
position: "absolute",
top: 72,
right: hasWikiPreview ? 454 : 18,
display: "grid",
gap: 8,
width: 280,
}}
>
{toasts.map((toast) => (
<div
key={toast.id}
style={{
borderRadius: 14,
border: "1px solid rgba(56, 189, 248, 0.28)",
background: "rgba(8, 47, 73, 0.9)",
color: "#e0f2fe",
padding: "12px 14px",
fontSize: 13,
lineHeight: 1.4,
boxShadow: "0 10px 26px rgba(2, 6, 23, 0.32)",
}}
>
{toast.message}
</div>
))}
</div>
) : null}
{image ? (
<div
style={{
position: "absolute",
right: 18,
bottom: 96,
width: 320,
borderRadius: 18,
overflow: "hidden",
border: "1px solid rgba(148, 163, 184, 0.22)",
background: "rgba(15, 23, 42, 0.9)",
boxShadow: "0 16px 44px rgba(2, 6, 23, 0.42)",
}}
>
<img
src={image.url}
alt={image.caption || "Historical image"}
style={{
width: "100%",
display: "block",
maxHeight: 240,
objectFit: "cover",
background: "#020617",
}}
/>
{image.caption?.trim() ? (
<div
style={{
padding: "10px 12px",
fontSize: 12,
lineHeight: 1.45,
color: "#cbd5e1",
}}
>
{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 ? (
<img
src={dialog.avatar}
alt={dialog.speaker || "speaker"}
style={{
width: 56,
height: 56,
borderRadius: "50%",
objectFit: "cover",
border: "2px solid rgba(125, 211, 252, 0.55)",
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",
}}
>
{dialog.text}
</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>
) : null}
{isPreviewMode ? (
<div
style={{
position: "absolute",
top: 64,
left: "50%",
transform: "translateX(-50%)",
width: "min(520px, calc(100% - 72px))",
borderRadius: 18,
border: "1px solid rgba(148, 163, 184, 0.24)",
background: "rgba(15, 23, 42, 0.9)",
color: "#e2e8f0",
padding: "12px 14px",
boxShadow: "0 12px 32px rgba(2, 6, 23, 0.3)",
pointerEvents: "auto",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
}}
>
<div style={{ display: "grid", gap: 6, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<span
style={{
display: "inline-flex",
alignItems: "center",
padding: "3px 8px",
borderRadius: 999,
background: "rgba(34, 197, 94, 0.2)",
color: "#86efac",
fontWeight: 900,
fontSize: 11,
letterSpacing: 0.3,
textTransform: "uppercase",
}}
>
Preview
</span>
{activeStepLabel ? (
<span
style={{
fontSize: 13,
fontWeight: 800,
color: "#f8fafc",
overflowWrap: "anywhere",
}}
>
{activeStepLabel}
</span>
) : null}
<span style={{ fontSize: 12, color: "#94a3b8" }}>
x{playbackSpeed.toFixed(2)}
</span>
</div>
{totalSteps > 0 ? (
<div style={{ display: "grid", gap: 6 }}>
<div
style={{
width: "100%",
height: 6,
borderRadius: 999,
background: "rgba(51, 65, 85, 0.8)",
overflow: "hidden",
}}
>
<div
style={{
width: `${Math.max(0, Math.min(100, ((activeStepNumber || 0) / totalSteps) * 100))}%`,
height: "100%",
borderRadius: 999,
background: "linear-gradient(90deg, #22c55e, #38bdf8)",
transition: "width 180ms ease",
}}
/>
</div>
<div style={{ fontSize: 11, color: "#94a3b8" }}>
Step {activeStepNumber || 0}/{totalSteps}
</div>
</div>
) : null}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, flex: "0 0 auto" }}>
{isPlaying ? (
<>
<button
type="button"
onClick={onStopPreview}
style={previewButtonStyle("#7f1d1d")}
>
Dừng
</button>
<button
type="button"
onClick={onResetPreview}
style={previewButtonStyle("#1e3a8a")}
>
Reset
</button>
</>
) : (
<button
type="button"
onClick={onPlayPreview}
style={previewButtonStyle("#166534")}
>
Phát lại
</button>
)}
<button
type="button"
onClick={onExitPreview}
style={previewButtonStyle("#334155")}
>
Thoát preview
</button>
</div>
</div>
</div>
) : null}
</div>
);
}
function previewButtonStyle(background: string): CSSProperties {
return {
border: "none",
background,
color: "white",
borderRadius: 10,
padding: "8px 12px",
cursor: "pointer",
fontSize: 12,
fontWeight: 800,
boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.08)",
};
}
@@ -27,6 +27,12 @@ type Props = {
onMutateReplay: (label: string, mutator: (draftReplay: BattleReplay) => void) => boolean;
onUndoReplay: () => void;
onExitReplay: () => void;
isPreviewPlaying: boolean;
previewPlaybackSpeed: number;
onPlayPreviewFromStart: () => void;
onPlayPreviewFromSelection: () => void;
onStopPreview: () => void;
onResetPreview: () => void;
};
type ActionGroupKey = "use_UI_function" | "use_map_function" | "use_geo_function" | "use_narrow_function";
@@ -72,6 +78,12 @@ export default function ReplayTimelineSidebar({
onMutateReplay,
onUndoReplay,
onExitReplay,
isPreviewPlaying,
previewPlaybackSpeed,
onPlayPreviewFromStart,
onPlayPreviewFromSelection,
onStopPreview,
onResetPreview,
}: Props) {
const stages = useMemo(() => replay?.detail || [], [replay?.detail]);
const selectedStage =
@@ -368,6 +380,80 @@ export default function ReplayTimelineSidebar({
Thoát replay
</button>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: isPreviewPlaying ? "1fr 1fr" : "1fr 1fr",
gap: 8,
}}
>
<button
type="button"
onClick={onPlayPreviewFromStart}
disabled={!replay || totalSteps === 0}
style={{
...buttonStyle,
background: !replay || totalSteps === 0 ? "#1e293b" : "#166534",
border: "none",
cursor: !replay || totalSteps === 0 ? "not-allowed" : "pointer",
opacity: !replay || totalSteps === 0 ? 0.7 : 1,
}}
>
Play từ đu
</button>
<button
type="button"
onClick={onPlayPreviewFromSelection}
disabled={!replay || selectedStage == null || selectedStepIndex == null}
style={{
...buttonStyle,
background:
!replay || selectedStage == null || selectedStepIndex == null
? "#1e293b"
: "#0f766e",
border: "none",
cursor:
!replay || selectedStage == null || selectedStepIndex == null
? "not-allowed"
: "pointer",
opacity:
!replay || selectedStage == null || selectedStepIndex == null
? 0.7
: 1,
}}
>
Play từ step
</button>
{isPreviewPlaying ? (
<button
type="button"
onClick={onStopPreview}
style={{
...buttonStyle,
background: "#7f1d1d",
border: "none",
}}
>
Dừng
</button>
) : null}
{isPreviewPlaying ? (
<button
type="button"
onClick={onResetPreview}
style={{
...buttonStyle,
background: "#1e3a8a",
border: "none",
}}
>
Reset preview
</button>
) : 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.
</div>
</div>
</Panel>
</div>
@@ -999,6 +1085,7 @@ 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",
@@ -1008,10 +1095,15 @@ const uiOptionLabels: Record<UIOptionName, string> = {
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ụ đề",
};
const mapFunctionLabels: Record<MapFunctionName, string> = {
@@ -1022,6 +1114,7 @@ const mapFunctionLabels: Record<MapFunctionName, string> = {
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",
};
@@ -1039,7 +1132,7 @@ const geoFunctionLabels: Record<GeoFunctionName, string> = {
show_geometry_label: "Label geometry",
follow_geometry_path: "Follow path",
follow_geometries_path: "Follow path",
dim_other_geometries: "Làm mờ geo khác",
dim_other_geometries: "Ẩn geo khác",
};
function buildStepActionEntries(step: ReplayStep): StepActionEntry[] {
@@ -1070,9 +1163,15 @@ function buildNarrativeActionEntry(
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")}`,
@@ -1080,15 +1179,24 @@ function buildNarrativeActionEntry(
`text=${summarizeValue(params[1], "trống")}`,
].join(" | ");
break;
case "clear_dialog_box":
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;
}
return {
@@ -1129,6 +1237,9 @@ function buildMapActionEntry(
case "hide_labels":
summary = "visible=false";
break;
case "show_all_geometries":
summary = "hidden_ids=[]";
break;
case "set_camera_view":
summary = summarizeCameraViewValue(params[0]);
break;
@@ -1248,8 +1359,7 @@ function buildGeoActionEntry(
break;
case "dim_other_geometries":
summary = [
`focus=${summarizeGeometryIdsValue(params[0])}`,
`other_opacity=${summarizeValue(params[1], "mặc định")}`,
`keep=${summarizeGeometryIdsValue(params[0])}`,
].join(" | ");
break;
}
@@ -1278,6 +1388,8 @@ function buildUiActionEntry(
if (option === "timeline" || option === "layer_panel" || option === "wiki_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") {
@@ -1342,6 +1454,7 @@ function normalizeUiOptionValue(value: unknown): UIOptionName | null {
case "timeline":
case "layer_panel":
case "wiki_panel":
case "close_wiki_panel":
case "zoom_panel":
case "wiki":
case "toast":