diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index 1bcd2da..524827b 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -947,7 +947,7 @@ function EditorPageContent() { .filter((wiki) => wiki && wiki.operation !== "delete") .map((wiki) => ({ id: String(wiki.id || ""), - label: `${(wiki.title || "").trim() || "Untitled wiki"} (${String(wiki.id || "")})`, + label: (wiki.title || "").trim() || "Untitled wiki", })) .filter((wiki) => wiki.id.length > 0); }, [snapshotWikis]); diff --git a/src/uhm/components/Map.tsx b/src/uhm/components/Map.tsx index 2443095..34c4a6e 100644 --- a/src/uhm/components/Map.tsx +++ b/src/uhm/components/Map.tsx @@ -313,7 +313,8 @@ const Map = memo(forwardRef(function Map({ >
(function Map({ padding: "0 6px", userSelect: "none", cursor: "pointer", + flexShrink: 0, }} > (function Map({ {onViewModeChange ? ( -
+
) : null} - + {onEnterPreview || onExitPreview ? ( ) : null} - + {onPlayPreviewReplay ? ( ) : null} - + - + (function Map({ onChange={(event) => handleZoomSliderChange(Number(event.target.value))} style={{ flex: 1, + minWidth: "60px", accentColor: "#38bdf8", cursor: "pointer", }} aria-label="Map zoom" /> - + - +
(function Map({ fontSize: "12px", color: "#cbd5e1", fontVariantNumeric: "tabular-nums", + flexShrink: 0, }} > {zoomLevel.toFixed(1)}x diff --git a/src/uhm/components/editor/Panel.tsx b/src/uhm/components/editor/Panel.tsx index c69bc37..3909789 100644 --- a/src/uhm/components/editor/Panel.tsx +++ b/src/uhm/components/editor/Panel.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import { type ReactNode, useState } from "react"; type PanelProps = { title: string; @@ -13,9 +13,12 @@ export function Panel({ defaultOpen, children, }: PanelProps) { + const [open, setOpen] = useState(Boolean(defaultOpen)); + return (
setOpen(e.currentTarget.open)} style={{ marginTop: 10, padding: 10, @@ -39,23 +42,33 @@ export function Panel({ }} > {title} - {badge ? ( - - {badge} - - ) : null} + + {open ? "−" : "+"} + +
{children}
); diff --git a/src/uhm/components/editor/ReplayEffectsSidebar.tsx b/src/uhm/components/editor/ReplayEffectsSidebar.tsx index 7f6155d..2ee03e2 100644 --- a/src/uhm/components/editor/ReplayEffectsSidebar.tsx +++ b/src/uhm/components/editor/ReplayEffectsSidebar.tsx @@ -1,6 +1,8 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, useCallback, useRef } from "react"; +import dynamic from "next/dynamic"; +import "react-quill-new/dist/quill.snow.css"; import type { BattleReplay, GeoFunctionName, @@ -11,6 +13,16 @@ import type { UIOptionName, } from "@/uhm/types/projects"; import { Panel } from "./Panel"; +import { Modal } from "@/components/ui/modal"; +import Button from "@/components/ui/button/Button"; +import Label from "@/components/form/Label"; +import { fetchWikiBySlug, searchWikisByTitle } from "@/uhm/api/wikis"; +import { useEditorStore } from "@/uhm/store/editorStore"; + +const ReactQuillEditor = dynamic(() => import("react-quill-new"), { + ssr: false, + loading: () =>
, +}); type Choice = { id: string; @@ -41,6 +53,7 @@ type ActionFieldConfig = { kind: | "text" | "textarea" + | "rich-text" | "number" | "boolean" | "color" @@ -141,13 +154,11 @@ const buttonStyle = { const narrativeActionDefinitions: NarrativeActionDefinitionMap = { set_dialog: { - label: "Dialog box", + label: "Narrative Box", fields: [ - { 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" }, + { name: "clear", label: "Ẩn narrative (Clear)", kind: "boolean" }, + { name: "image_url", label: "Ảnh tư liệu", kind: "text", placeholder: "https://... (URL ảnh)" }, + { name: "text", label: "Nội dung", kind: "rich-text", placeholder: "Nội dung dẫn chuyện..." }, ], create: () => ({ function_name: "set_dialog", params: [{ avatar: "", text: "", image_url: "", image_caption: "" }] }), deserialize: (params) => { @@ -155,18 +166,14 @@ const narrativeActionDefinitions: NarrativeActionDefinitionMap = { if (data === null) { return { clear: true, - avatar: "", - text: "", image_url: "", - image_caption: "", + text: "", }; } return { clear: false, - avatar: asString(data?.avatar), - text: asString(data?.text), image_url: asString(data?.image_url), - image_caption: asString(data?.image_caption), + text: asString(data?.text), }; }, serialize: (values) => { @@ -174,15 +181,13 @@ const narrativeActionDefinitions: NarrativeActionDefinitionMap = { return [null]; } const data: any = { - avatar: asString(values.avatar), + avatar: "", text: asString(values.text), + image_caption: "", }; if (values.image_url) { data.image_url = asString(values.image_url); } - if (values.image_caption) { - data.image_caption = asString(values.image_caption); - } return [data]; }, }, @@ -200,6 +205,236 @@ export default function ReplayEffectsSidebar({ getCurrentMapViewState, onMutateReplay, }: Props) { + const wikis = useEditorStore((state) => state.snapshotWikis); + + // Quill: custom link UI (link-to-wiki by slug). + const wikiLinkIntentRef = useRef<{ + quill: any; + range: any; + existingHref: string | null; + } | null>(null); + + const [isWikiLinkOpen, setIsWikiLinkOpen] = useState(false); + const [wikiLinkQuery, setWikiLinkQuery] = useState(""); + const [wikiLinkSearchMode, setWikiLinkSearchMode] = useState<"title" | "slug">("title"); + const [wikiLinkError, setWikiLinkError] = useState(null); + const [globalWikiResults, setGlobalWikiResults] = useState([]); + const [isGlobalWikiSearching, setIsGlobalWikiSearching] = useState(false); + const [globalWikiSearchError, setGlobalWikiSearchError] = useState(null); + const globalWikiSearchRequestRef = useRef(0); + + const handleLinkClick = useCallback((quill: any) => { + if (!quill) return; + const range = quill.getSelection?.() ?? null; + const existingHref = + range && (quill.getFormat?.(range)?.link ?? quill.getFormat?.(range.index, range.length)?.link) || null; + + wikiLinkIntentRef.current = { + quill, + range, + existingHref: typeof existingHref === "string" ? existingHref : null, + }; + + const selectedText = + range && range.length > 0 ? String(quill.getText?.(range.index, range.length) || "").trim() : ""; + setWikiLinkQuery(selectedText.slice(0, 80)); + setWikiLinkError(null); + setIsWikiLinkOpen(true); + }, []); + + const localWikiLinkCandidates = useMemo(() => { + if (!isWikiLinkOpen) return []; + const q = wikiLinkQuery.trim().toLowerCase(); + + const base = (wikis || []) + .filter((w) => w && typeof w.id === "string" && w.operation !== "delete") + .filter((w) => typeof w.slug === "string" && w.slug.trim().length > 0); + + const filtered = (() => { + if (!q.length) return base; + if (wikiLinkSearchMode === "slug") { + return base.filter((w) => String(w.slug || "").toLowerCase().includes(q)); + } + return base.filter((w) => (w.title || "").toLowerCase().includes(q)); + })(); + + return filtered.slice(0, 20).map((w) => ({ + key: `local:${w.id}`, + title: (w.title || "").trim() || "Untitled wiki", + slug: String(w.slug).trim(), + source: "local" as const, + })); + }, [isWikiLinkOpen, wikiLinkQuery, wikiLinkSearchMode, wikis]); + + useEffect(() => { + if (!isWikiLinkOpen) return; + + const keyword = wikiLinkQuery.trim(); + if (!keyword.length) { + setGlobalWikiResults([]); + setIsGlobalWikiSearching(false); + setGlobalWikiSearchError(null); + return; + } + + let disposed = false; + const requestId = ++globalWikiSearchRequestRef.current; + const timeoutId = window.setTimeout(async () => { + setIsGlobalWikiSearching(true); + setGlobalWikiSearchError(null); + try { + const rows = + wikiLinkSearchMode === "slug" + ? (() => fetchWikiBySlug(keyword))() + : (() => searchWikisByTitle(keyword, { limit: 12 }))(); + + const resolved = await rows; + if (disposed || requestId !== globalWikiSearchRequestRef.current) return; + + const list = Array.isArray(resolved) ? resolved : resolved ? [resolved] : []; + setGlobalWikiResults(list); + } catch (err) { + if (disposed || requestId !== globalWikiSearchRequestRef.current) return; + console.error("Search global wikis failed", err); + setGlobalWikiResults([]); + setGlobalWikiSearchError("Không search được wiki trên server."); + } finally { + if (!disposed && requestId === globalWikiSearchRequestRef.current) { + setIsGlobalWikiSearching(false); + } + } + }, 260); + + return () => { + disposed = true; + window.clearTimeout(timeoutId); + }; + }, [isWikiLinkOpen, wikiLinkQuery, wikiLinkSearchMode]); + + const globalWikiLinkCandidates = useMemo(() => { + if (!isWikiLinkOpen) return []; + const out: any[] = []; + for (const row of globalWikiResults || []) { + const slug = typeof row?.slug === "string" ? row.slug.trim() : ""; + if (!slug.length) continue; + out.push({ + key: `global:${row.id || slug}`, + title: (row.title || "").trim() || "Untitled wiki", + slug, + source: "global", + }); + } + return out.slice(0, 20); + }, [globalWikiResults, isWikiLinkOpen]); + + const wikiLinkCandidates = useMemo(() => { + const localSlugs = new Set(localWikiLinkCandidates.map((w) => w.slug)); + const dedupedGlobal = globalWikiLinkCandidates.filter((w) => !localSlugs.has(w.slug)); + return [...localWikiLinkCandidates, ...dedupedGlobal]; + }, [globalWikiLinkCandidates, localWikiLinkCandidates]); + + const closeWikiLinkModal = useCallback(() => { + setIsWikiLinkOpen(false); + }, []); + + const applyWikiLink = useCallback((target: { title: string; slug: string }) => { + const intent = wikiLinkIntentRef.current; + const quill = intent?.quill; + if (!quill) return; + + const slug = target.slug.trim(); + const range = intent?.range ?? quill.getSelection?.() ?? null; + if (!range) { + setWikiLinkError("Không lấy được vị trí selection trong editor."); + return; + } + + quill.setSelection?.(range.index, range.length, "silent"); + + if (range.length > 0) { + quill.formatText?.(range.index, range.length, "link", slug, "user"); + closeWikiLinkModal(); + return; + } + + const label = (target.title || "").trim() || slug; + quill.insertText?.(range.index, label, { link: slug }, "user"); + quill.setSelection?.(range.index + label.length, 0, "silent"); + closeWikiLinkModal(); + }, [closeWikiLinkModal]); + + const applyMissingWikiLink = useCallback(() => { + const intent = wikiLinkIntentRef.current; + const quill = intent?.quill; + if (!quill) return; + + const href = "__missing__"; + const range = intent?.range ?? quill.getSelection?.() ?? null; + if (!range) { + setWikiLinkError("Không lấy được vị trí selection trong editor."); + return; + } + + quill.setSelection?.(range.index, range.length, "silent"); + + if (range.length > 0) { + quill.formatText?.(range.index, range.length, "link", href, "user"); + closeWikiLinkModal(); + return; + } + + const label = wikiLinkQuery.trim().slice(0, 120) || "link"; + quill.insertText?.(range.index, label, { link: href }, "user"); + quill.setSelection?.(range.index + label.length, 0, "silent"); + closeWikiLinkModal(); + }, [closeWikiLinkModal, wikiLinkQuery]); + + const removeWikiLink = useCallback(() => { + const intent = wikiLinkIntentRef.current; + const quill = intent?.quill; + if (!quill) return; + const range = intent?.range ?? quill.getSelection?.() ?? null; + if (!range) return; + quill.setSelection?.(range.index, range.length, "silent"); + if (range.length > 0) { + quill.formatText?.(range.index, range.length, "link", false, "user"); + } else { + quill.format?.("link", false, "user"); + } + closeWikiLinkModal(); + }, [closeWikiLinkModal]); + + const isUrlQuery = useMemo(() => { + const q = wikiLinkQuery.trim(); + return q.startsWith("http://") || q.startsWith("https://") || q.includes("/"); + }, [wikiLinkQuery]); + + const applyExternalLink = useCallback(() => { + const intent = wikiLinkIntentRef.current; + const quill = intent?.quill; + if (!quill) return; + + const href = wikiLinkQuery.trim(); + const range = intent?.range ?? quill.getSelection?.() ?? null; + if (!range) { + setWikiLinkError("Không lấy được vị trí selection trong editor."); + return; + } + + quill.setSelection?.(range.index, range.length, "silent"); + + if (range.length > 0) { + quill.formatText?.(range.index, range.length, "link", href, "user"); + closeWikiLinkModal(); + return; + } + + const label = href; + quill.insertText?.(range.index, label, { link: href }, "user"); + quill.setSelection?.(range.index + label.length, 0, "silent"); + closeWikiLinkModal(); + }, [closeWikiLinkModal, wikiLinkQuery]); + const stages = useMemo(() => replay?.detail || [], [replay?.detail]); const selectedStage = stages.find((stage) => stage.id === selectedStageId) || @@ -303,6 +538,7 @@ export default function ReplayEffectsSidebar({ onUpdateActions={(nextActions, label) => updateActionGroup("use_narrow_function", nextActions, label) } + onLinkClick={handleLinkClick} /> )} + +
+
+
Chèn Link Wiki
+
+ +
+ +
+ setWikiLinkQuery(e.target.value)} + style={{ + height: 44, + flex: 1, + minWidth: 0, + borderRadius: 12, + border: "1px solid #334155", + backgroundColor: "transparent", + paddingLeft: 16, + paddingRight: 16, + fontSize: 14, + color: "#f1f5f9", + outline: "none" + }} + placeholder={wikiLinkSearchMode === "slug" ? "Nhập slug..." : "Nhập tiêu đề hoặc URL..."} + autoFocus + /> + +
+ {wikiLinkError ? ( +
{wikiLinkError}
+ ) : null} + {globalWikiSearchError ? ( +
{globalWikiSearchError}
+ ) : null} +
+ +
+
+ {isGlobalWikiSearching ? ( +
+ Đang tìm kiếm… +
+ ) : null} + {wikiLinkCandidates.map((w) => ( + + ))} + {wikiLinkCandidates.length === 0 ? ( +
+ Không tìm thấy wiki phù hợp (hoặc các wiki khác chưa có slug). +
+ ) : null} +
+
+ +
+ {isUrlQuery ? ( + + ) : null} + + {wikiLinkIntentRef.current?.existingHref ? ( + + ) : null} + +
+
+
+ ); } @@ -598,106 +1004,6 @@ function MapCameraViewPanel({ ); } -function UiSimpleEffectsPanel({ - draft, - onToggleOption, - onApply, -}: { - draft: UiEffectsDraftState; - onToggleOption: (option: UIOptionName) => void; - onApply: () => void; -}) { - const activeCount = uiSimpleOptionValues.filter((option) => draft.selected[option]).length; - - return ( - -
- - -
-
- ); -} - -function UiInputEffectsPanel({ - draft, - wikiChoices, - onToggleOption, - onChangeDraft, - onApply, -}: { - draft: UiEffectsDraftState; - wikiChoices: Choice[]; - onToggleOption: (option: UIOptionName) => void; - onChangeDraft: (patch: Partial) => void; - onApply: () => void; -}) { - const activeCount = uiInputOptionValues.filter((option) => draft.selected[option]).length; - - return ( - -
- - - {draft.selected.wiki ? ( - onChangeDraft({ wiki_id: asString(nextValue) })} - /> - ) : null} - - {draft.selected.toast ? ( - onChangeDraft({ message: asString(nextValue) })} - /> - ) : null} - - -
-
- ); -} - function UiOptionToggleRow({ optionValues, draft, @@ -719,8 +1025,6 @@ function UiOptionToggleRow({ ); } -// UiVisibilityOptions removed since toggles are evaluated directly - function SimpleOptionToggleRow({ options, onToggleOption, @@ -776,52 +1080,66 @@ function UiEffectsEditor({ setDraft(buildUiEffectsDraftState(actions)); }, [actions]); + const handleApply = () => { + const evaluatedOptions: UIOptionName[] = ["timeline", "layer_panel", "zoom_panel", "wiki"]; + const updatedDraft = { + ...draft, + selected: { + ...draft.selected, + wiki: Boolean(draft.wiki_id), + }, + }; + + const nextActions = replaceUiActionsByGroup(actions, evaluatedOptions, updatedDraft); + const label = buildUiEffectsApplyLabel("UI Effects", updatedDraft, evaluatedOptions); + onApplyActions(nextActions, label); + }; + return ( - <> - - setDraft((prev) => ({ - ...prev, - selected: { - ...prev.selected, - [option]: !prev.selected[option], - }, - })) - } - onApply={() => - onApplyActions( - replaceUiActionsByGroup(actions, uiSimpleOptionValues, draft), - buildUiEffectsApplyLabel("UI Effects", draft, uiSimpleOptionValues) - ) - } - /> - - setDraft((prev) => ({ - ...prev, - selected: { - ...prev.selected, - [option]: !prev.selected[option], - }, - })) - } - onChangeDraft={(patch) => - setDraft((prev) => ({ - ...prev, - ...patch, - })) - } - onApply={() => - onApplyActions( - replaceUiActionsByGroup(actions, uiInputOptionValues, draft), - buildUiEffectsApplyLabel("UI Inputs", draft, uiInputOptionValues) - ) - } - /> - + +
+ + setDraft((prev) => ({ + ...prev, + selected: { + ...prev.selected, + [option]: !prev.selected[option], + }, + })) + } + /> + +
+ + + setDraft((prev) => ({ + ...prev, + wiki_id: asString(nextValue), + })) + } + /> + + +
+ ); } @@ -835,6 +1153,7 @@ function ActionGroupEditor({ createOnSelect = false, emptyOptionLabel, onUpdateActions, + onLinkClick, }: { title: string; groupLabel: string; @@ -845,6 +1164,7 @@ function ActionGroupEditor({ createOnSelect?: boolean; emptyOptionLabel?: string; onUpdateActions: (nextActions: ReplayAction[], label: string) => void; + onLinkClick?: (quill: any) => void; }) { const functionNames = useMemo(() => Object.keys(definitions) as T[], [definitions]); const [composerFunctionName, setComposerFunctionName] = useState( @@ -857,6 +1177,28 @@ function ActionGroupEditor({ ) ); + const lastLoadedActionsRef = useRef(null); + + useEffect(() => { + if (JSON.stringify(actions) === JSON.stringify(lastLoadedActionsRef.current)) { + return; + } + lastLoadedActionsRef.current = actions; + + if (actions.length > 0) { + const first = actions[0]; + setComposerFunctionName(first.function_name); + const def = definitions[first.function_name]; + if (def) { + setComposerDraftValues(def.deserialize(first.params)); + } + } else { + const defaultFun = createOnSelect && functionNames.length > 1 ? "" : (functionNames[0] as T); + setComposerFunctionName(defaultFun); + setComposerDraftValues(buildActionComposerDraft(definitions, defaultFun)); + } + }, [actions, definitions, createOnSelect, functionNames]); + const composerDefinition = composerFunctionName ? definitions[composerFunctionName] : null; @@ -955,6 +1297,7 @@ function ActionGroupEditor({ value={composerDraftValues[field.name]} geometryChoices={geometryChoices} wikiChoices={wikiChoices} + onLinkClick={onLinkClick} onChange={(nextValue) => setComposerDraftValues((prev) => ({ ...prev, @@ -1001,12 +1344,14 @@ function FieldInput({ geometryChoices, wikiChoices, onChange, + onLinkClick, }: { field: ActionFieldConfig; value: ActionValue | undefined; geometryChoices: Choice[]; wikiChoices: Choice[]; onChange: (nextValue: ActionValue) => void; + onLinkClick?: (quill: any) => void; }) { const baseLabel = (
@@ -1014,6 +1359,36 @@ function FieldInput({
); + if (field.kind === "rich-text") { + return ( + + ); + } + if (field.kind === "textarea") { return (
) : null} - {dialog?.image_url ? ( + {dialog && (dialog.text?.trim() || dialog.image_url?.trim()) ? (
- {dialog.image_caption - {dialog.image_caption?.trim() ? ( -
- {dialog.image_caption} -
- ) : null} -
- ) : null} - - {dialog && dialog.text?.trim() ? ( - dialog.avatar?.trim() ? ( -
+ {dialog.image_url?.trim() ? ( speaker + ) : null} + {dialog.text?.trim() ? (
-
- {dialog.text} -
-
-
- ) : ( -
- {dialog.text} -
- ) + dangerouslySetInnerHTML={{ __html: dialog.text }} + /> + ) : null} +
) : null} {isPreviewMode ? ( diff --git a/src/uhm/components/editor/ReplayTimelineSidebar.tsx b/src/uhm/components/editor/ReplayTimelineSidebar.tsx index 8d3f20f..64c5705 100644 --- a/src/uhm/components/editor/ReplayTimelineSidebar.tsx +++ b/src/uhm/components/editor/ReplayTimelineSidebar.tsx @@ -1361,7 +1361,8 @@ function buildNarrativeActionEntry( } else { const parts: string[] = []; if (dialog.text) { - parts.push(`text=${summarizeValue(dialog.text, "")}`); + const plainText = dialog.text.replace(/<[^>]*>/g, ""); + parts.push(`text=${summarizeValue(plainText, "")}`); } if (dialog.avatar) { parts.push(`avatar=${summarizeValue(dialog.avatar, "")}`);