pre view wiki
This commit is contained in:
@@ -8,6 +8,7 @@ export const API_BASE_URL =
|
||||
export const API_ENDPOINTS = {
|
||||
geometries: `${API_BASE_URL}/geometries`,
|
||||
entities: `${API_BASE_URL}/entities`,
|
||||
wikis: `${API_BASE_URL}/wikis`,
|
||||
// New API uses projects + commits + submissions (JWT-protected).
|
||||
authSignin: `${API_BASE_URL}/auth/signin`,
|
||||
authRefresh: `${API_BASE_URL}/auth/refresh`,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { API_ENDPOINTS } from "@/uhm/api/config";
|
||||
import { requestJson } from "@/uhm/api/http";
|
||||
|
||||
export type Wiki = {
|
||||
id: string;
|
||||
title?: string;
|
||||
content?: string;
|
||||
is_deleted?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export async function searchWikisByTitle(title: string, options?: { limit?: number; cursor?: string; entityId?: string }): Promise<Wiki[]> {
|
||||
const keyword = title.trim();
|
||||
if (!keyword.length) return [];
|
||||
|
||||
const params = new URLSearchParams({ title: keyword });
|
||||
if (options?.limit && Number.isFinite(options.limit)) params.set("limit", String(Math.trunc(options.limit)));
|
||||
if (options?.cursor) params.set("cursor", options.cursor);
|
||||
if (options?.entityId) params.set("entity_id", options.entityId);
|
||||
|
||||
return requestJson<Wiki[]>(`${API_ENDPOINTS.wikis}?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function fetchWikiById(id: string): Promise<Wiki> {
|
||||
const wikiId = String(id || "").trim();
|
||||
if (!wikiId) throw new Error("Missing wiki id");
|
||||
return requestJson<Wiki>(`${API_ENDPOINTS.wikis}/${encodeURIComponent(wikiId)}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Entity } from "@/uhm/types/entities";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
||||
|
||||
type EntityChoice = { id: string; name: string };
|
||||
type WikiChoice = { id: string; title: string; operation?: string };
|
||||
|
||||
type Props = {
|
||||
entities: EntityChoice[];
|
||||
wikis: WikiSnapshot[];
|
||||
links: EntityWikiLinkSnapshot[];
|
||||
setLinks: React.Dispatch<React.SetStateAction<EntityWikiLinkSnapshot[]>>;
|
||||
};
|
||||
|
||||
function wikiTitle(w: WikiSnapshot): string {
|
||||
const t = String(w.title || "").trim();
|
||||
return t.length ? t : "Untitled wiki";
|
||||
}
|
||||
|
||||
export default function EntityWikiBindingsPanel({ entities, wikis, links, setLinks }: Props) {
|
||||
const [activeEntityId, setActiveEntityId] = useState<string>("");
|
||||
|
||||
const wikiChoices: WikiChoice[] = useMemo(
|
||||
() =>
|
||||
(wikis || [])
|
||||
.filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0)
|
||||
.map((w) => ({ id: w.id, title: wikiTitle(w), operation: w.operation })),
|
||||
[wikis]
|
||||
);
|
||||
|
||||
const entityChoices = useMemo(() => {
|
||||
const cleaned = (entities || []).filter((e) => e && typeof e.id === "string" && e.id.trim().length > 0);
|
||||
cleaned.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return cleaned;
|
||||
}, [entities]);
|
||||
|
||||
const activeLinks = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const l of links || []) {
|
||||
if (!l || l.entity_id !== activeEntityId) continue;
|
||||
if (l.is_deleted) continue;
|
||||
set.add(l.wiki_id);
|
||||
}
|
||||
return set;
|
||||
}, [activeEntityId, links]);
|
||||
|
||||
const toggle = (wikiId: string) => {
|
||||
if (!activeEntityId) return;
|
||||
const id = String(wikiId || "").trim();
|
||||
if (!id) return;
|
||||
|
||||
setLinks((prev) => {
|
||||
const next = [...prev];
|
||||
const idx = next.findIndex((l) => l.entity_id === activeEntityId && l.wiki_id === id);
|
||||
if (idx >= 0) {
|
||||
const existing = next[idx];
|
||||
const currentlyOn = !existing.is_deleted;
|
||||
next[idx] = {
|
||||
...existing,
|
||||
operation: currentlyOn ? "delete" : "reference",
|
||||
is_deleted: currentlyOn ? 1 : 0,
|
||||
};
|
||||
return next;
|
||||
}
|
||||
next.push({
|
||||
entity_id: activeEntityId,
|
||||
wiki_id: id,
|
||||
operation: "reference",
|
||||
is_deleted: 0,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px",
|
||||
background: "#0b1220",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #1f2937",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
||||
<div style={{ fontWeight: 700, fontSize: "14px" }}>Entity ↔ Wiki</div>
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{links.length}</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "10px", display: "grid", gap: "8px" }}>
|
||||
<div>
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Entity</div>
|
||||
<select
|
||||
value={activeEntityId}
|
||||
onChange={(e) => setActiveEntityId(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
border: "1px solid #1f2937",
|
||||
background: "#0b1220",
|
||||
color: "#e5e7eb",
|
||||
borderRadius: "6px",
|
||||
padding: "8px 10px",
|
||||
fontSize: "12px",
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
<option value="">Select entity…</option>
|
||||
{entityChoices.map((e) => (
|
||||
<option key={e.id} value={e.id}>
|
||||
{e.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Wikis</div>
|
||||
{!wikiChoices.length ? (
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>No wiki in project yet.</div>
|
||||
) : !activeEntityId ? (
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>Pick an entity to bind wikis.</div>
|
||||
) : (
|
||||
<div style={{ display: "grid", gap: "6px" }}>
|
||||
{wikiChoices.slice(0, 12).map((w) => {
|
||||
const checked = activeLinks.has(w.id);
|
||||
const isRefWiki = (wikis.find((x) => x.id === w.id)?.source || "inline") === "ref";
|
||||
return (
|
||||
<label
|
||||
key={w.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #1f2937",
|
||||
cursor: "pointer",
|
||||
background: checked ? "#111827" : "transparent",
|
||||
}}
|
||||
title={w.id}
|
||||
>
|
||||
<input type="checkbox" checked={checked} onChange={() => toggle(w.id)} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ color: "#e5e7eb", fontSize: "12px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{w.title}
|
||||
{isRefWiki ? " (ref)" : ""}
|
||||
</div>
|
||||
<div style={{ color: "#94a3b8", fontSize: "11px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{w.id}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{wikiChoices.length > 12 ? (
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>+{wikiChoices.length - 12} more…</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+120
-37
@@ -260,22 +260,24 @@ export default function Map({
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container,
|
||||
attributionControl: false,
|
||||
minZoom: MAP_MIN_ZOOM,
|
||||
maxZoom: MAP_MAX_ZOOM,
|
||||
style: {
|
||||
version: 8,
|
||||
sources: {
|
||||
base: {
|
||||
type: "vector",
|
||||
tiles: [getVectorTileTemplateUrl()],
|
||||
minzoom: 0,
|
||||
maxzoom: 6,
|
||||
const map = new maplibregl.Map({
|
||||
container,
|
||||
attributionControl: false,
|
||||
minZoom: MAP_MIN_ZOOM,
|
||||
maxZoom: MAP_MAX_ZOOM,
|
||||
style: {
|
||||
version: 8,
|
||||
// Needed for symbol/text layers (country labels).
|
||||
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
|
||||
sources: {
|
||||
base: {
|
||||
type: "vector",
|
||||
tiles: [getVectorTileTemplateUrl()],
|
||||
minzoom: 0,
|
||||
maxzoom: 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
layers: [
|
||||
{
|
||||
id: "background",
|
||||
type: "background",
|
||||
@@ -321,29 +323,101 @@ export default function Map({
|
||||
"fill-opacity": 0.38,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bg-country-borders-line",
|
||||
type: "line",
|
||||
source: "base",
|
||||
"source-layer": "country_borders",
|
||||
paint: {
|
||||
"line-color": "#cbd5e1",
|
||||
"line-width": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
0, 0.2,
|
||||
4, 0.5,
|
||||
6, 1.1,
|
||||
],
|
||||
"line-opacity": 0.85,
|
||||
{
|
||||
id: "bg-country-borders-line",
|
||||
type: "line",
|
||||
source: "base",
|
||||
"source-layer": "country_borders",
|
||||
paint: {
|
||||
"line-color": "#cbd5e1",
|
||||
"line-width": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
0, 0.2,
|
||||
4, 0.5,
|
||||
6, 1.1,
|
||||
],
|
||||
"line-opacity": 0.85,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "regions-line",
|
||||
type: "line",
|
||||
source: "base",
|
||||
"source-layer": "regions",
|
||||
{
|
||||
id: "country-labels",
|
||||
type: "symbol",
|
||||
source: "base",
|
||||
// New tiles build uses NaturalEarth label points layer name.
|
||||
// If your tile pipeline exposes a different name, adjust here.
|
||||
"source-layer": "ne_10m_admin_0_label_points",
|
||||
minzoom: 0,
|
||||
layout: {
|
||||
"text-field": [
|
||||
"coalesce",
|
||||
["get", "sr_subunit"],
|
||||
["get", "NAME_EN"],
|
||||
["get", "NAME"],
|
||||
["get", "ADMIN"],
|
||||
["get", "name"],
|
||||
"",
|
||||
],
|
||||
"text-size": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
0, 10,
|
||||
3, 11,
|
||||
6, 14,
|
||||
],
|
||||
"text-max-width": 10,
|
||||
"text-allow-overlap": false,
|
||||
"symbol-placement": "point",
|
||||
},
|
||||
paint: {
|
||||
"text-color": "#e2e8f0",
|
||||
"text-halo-color": "#0b1220",
|
||||
"text-halo-width": 1.2,
|
||||
"text-halo-blur": 0.5,
|
||||
},
|
||||
},
|
||||
// Fallback for tile pipelines that expose country labels under a generic "labels" layer.
|
||||
// Hidden/shown together with "country-labels" toggle.
|
||||
{
|
||||
id: "country-labels-alt",
|
||||
type: "symbol",
|
||||
source: "base",
|
||||
"source-layer": "labels",
|
||||
minzoom: 0,
|
||||
layout: {
|
||||
"text-field": [
|
||||
"coalesce",
|
||||
["get", "name"],
|
||||
["get", "title"],
|
||||
["get", "NAME"],
|
||||
"",
|
||||
],
|
||||
"text-size": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
0, 10,
|
||||
3, 11,
|
||||
6, 14,
|
||||
],
|
||||
"text-max-width": 10,
|
||||
"text-allow-overlap": false,
|
||||
"symbol-placement": "point",
|
||||
},
|
||||
paint: {
|
||||
"text-color": "#e2e8f0",
|
||||
"text-halo-color": "#0b1220",
|
||||
"text-halo-width": 1.2,
|
||||
"text-halo-blur": 0.5,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "regions-line",
|
||||
type: "line",
|
||||
source: "base",
|
||||
"source-layer": "regions",
|
||||
paint: {
|
||||
"line-color": "#475569",
|
||||
"line-width": [
|
||||
@@ -1112,6 +1186,15 @@ function applyBackgroundLayerVisibility(
|
||||
visibility[layer.id] ? "visible" : "none"
|
||||
);
|
||||
}
|
||||
|
||||
// Keep fallback country label layer in sync with the primary toggle.
|
||||
if (map.getLayer("country-labels-alt")) {
|
||||
map.setLayoutProperty(
|
||||
"country-labels-alt",
|
||||
"visibility",
|
||||
visibility["country-labels"] ? "visible" : "none"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function syncRasterBaseVisibility(map: maplibregl.Map, shouldShow: boolean) {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { Entity } from "@/uhm/types/entities";
|
||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||
import { searchEntitiesByName } from "@/uhm/api/entities";
|
||||
|
||||
type Props = {
|
||||
entityRefs: EntitySnapshot[];
|
||||
setEntityRefs: React.Dispatch<React.SetStateAction<EntitySnapshot[]>>;
|
||||
};
|
||||
|
||||
export default function ProjectEntityRefsPanel({ entityRefs, setEntityRefs }: Props) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<Entity[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const searchRequestRef = useState(() => ({ id: 0 }))[0];
|
||||
|
||||
const existingIds = useMemo(() => new Set(entityRefs.map((e) => String(e.id))), [entityRefs]);
|
||||
|
||||
useEffect(() => {
|
||||
const keyword = query.trim();
|
||||
if (!keyword.length) {
|
||||
setResults([]);
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
const requestId = ++searchRequestRef.id;
|
||||
const t = window.setTimeout(async () => {
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const rows = await searchEntitiesByName(keyword, { limit: 20 });
|
||||
if (disposed || requestId !== searchRequestRef.id) return;
|
||||
setResults(rows);
|
||||
} catch (err) {
|
||||
if (disposed || requestId !== searchRequestRef.id) return;
|
||||
console.error("Search entities failed", err);
|
||||
setResults([]);
|
||||
} finally {
|
||||
if (disposed || requestId !== searchRequestRef.id) return;
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearTimeout(t);
|
||||
};
|
||||
}, [query, searchRequestRef]);
|
||||
|
||||
const addRef = (e: Entity) => {
|
||||
const id = String(e.id || "").trim();
|
||||
if (!id) return;
|
||||
if (existingIds.has(id)) return;
|
||||
setEntityRefs((prev) => [
|
||||
{
|
||||
id,
|
||||
source: "ref",
|
||||
ref: { id },
|
||||
operation: "reference",
|
||||
name: e.name,
|
||||
description: e.description ?? null,
|
||||
is_deleted: 0,
|
||||
},
|
||||
...prev,
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px",
|
||||
background: "#0b1220",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #1f2937",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
||||
<div style={{ fontWeight: 700, fontSize: "14px" }}>Entities</div>
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{entityRefs.length}</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "10px" }}>
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Add existing entity</div>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(ev) => setQuery(ev.target.value)}
|
||||
placeholder="Search by name…"
|
||||
style={{
|
||||
width: "100%",
|
||||
border: "1px solid #1f2937",
|
||||
background: "#0b1220",
|
||||
color: "#e5e7eb",
|
||||
borderRadius: "6px",
|
||||
padding: "8px 10px",
|
||||
fontSize: "12px",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
{isSearching ? (
|
||||
<div style={{ marginTop: "6px", fontSize: "12px", color: "#94a3b8" }}>Searching…</div>
|
||||
) : null}
|
||||
{!isSearching && query.trim().length > 0 ? (
|
||||
<div style={{ marginTop: "6px", display: "grid", gap: "6px" }}>
|
||||
{results.slice(0, 8).map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #1f2937",
|
||||
background: "transparent",
|
||||
opacity: existingIds.has(r.id) ? 0.55 : 1,
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ color: "#e5e7eb", fontSize: "12px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{r.name}
|
||||
</div>
|
||||
<div style={{ color: "#94a3b8", fontSize: "11px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{r.id}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addRef(r)}
|
||||
disabled={existingIds.has(r.id)}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "#111827",
|
||||
color: existingIds.has(r.id) ? "#64748b" : "#93c5fd",
|
||||
cursor: existingIds.has(r.id) ? "not-allowed" : "pointer",
|
||||
borderRadius: "6px",
|
||||
padding: "6px 8px",
|
||||
fontSize: "12px",
|
||||
fontWeight: 700,
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!results.length ? <div style={{ fontSize: "12px", color: "#94a3b8" }}>No results.</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{entityRefs.length ? (
|
||||
<div style={{ marginTop: "10px", display: "grid", gap: "6px" }}>
|
||||
{entityRefs.slice(0, 8).map((e) => (
|
||||
<div
|
||||
key={e.id}
|
||||
style={{
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #1f2937",
|
||||
background: "transparent",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "12px", color: "#e5e7eb", fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{e.name || e.id}
|
||||
</div>
|
||||
<div style={{ fontSize: "11px", color: "#94a3b8", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{e.id}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{entityRefs.length > 8 ? <div style={{ fontSize: "12px", color: "#94a3b8" }}>+{entityRefs.length - 8} more…</div> : null}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>No entity ref yet for this project.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { EditorContent, useEditor, type JSONContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import TiptapLink from "@tiptap/extension-link";
|
||||
import { searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
|
||||
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import Button from "@/components/ui/button/Button";
|
||||
import Badge from "@/components/ui/badge/Badge";
|
||||
import Label from "@/components/form/Label";
|
||||
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
wikis: WikiSnapshot[];
|
||||
setWikis: React.Dispatch<React.SetStateAction<WikiSnapshot[]>>;
|
||||
autoOpen?: boolean;
|
||||
};
|
||||
|
||||
function newId() {
|
||||
try {
|
||||
return crypto.randomUUID();
|
||||
} catch {
|
||||
return `wiki_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function clampTitle(title: string) {
|
||||
const t = title.trim();
|
||||
return t.length ? t.slice(0, 120) : "Untitled wiki";
|
||||
}
|
||||
|
||||
export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const activeWiki = useMemo(() => wikis.find((w) => w.id === activeId) || null, [activeId, wikis]);
|
||||
|
||||
const [wikiTitle, setWikiTitle] = useState("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<Wiki[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const searchRequestRef = useState(() => ({ id: 0 }))[0];
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
TiptapLink.configure({
|
||||
openOnClick: false,
|
||||
autolink: true,
|
||||
linkOnPaste: true,
|
||||
}),
|
||||
],
|
||||
content: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "" }] }] },
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "tiptap-editor focus:outline-none min-h-[320px] px-4 py-3",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoOpen) return;
|
||||
// open once on mount
|
||||
setOpen(true);
|
||||
}, [autoOpen]);
|
||||
|
||||
// keep editor content in sync when switching wiki
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
if (!open) return;
|
||||
|
||||
const doc = (activeWiki?.doc || null) as JSONContent | null;
|
||||
editor.commands.setContent(
|
||||
(doc && typeof doc === "object" ? doc : { type: "doc", content: [{ type: "paragraph" }] }) as any
|
||||
);
|
||||
setWikiTitle(activeWiki?.title || "");
|
||||
}, [activeWiki?.doc, activeWiki?.title, editor, open]);
|
||||
|
||||
const ensureActive = () => {
|
||||
if (activeId && wikis.some((w) => w.id === activeId)) return;
|
||||
setActiveId(wikis[0]?.id || null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
ensureActive();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wikis.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const keyword = searchQuery.trim();
|
||||
if (!keyword.length) {
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
const requestId = ++searchRequestRef.id;
|
||||
const t = window.setTimeout(async () => {
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const rows = await searchWikisByTitle(keyword, { limit: 12 });
|
||||
if (disposed || requestId !== searchRequestRef.id) return;
|
||||
setSearchResults(rows);
|
||||
} catch (err) {
|
||||
if (disposed || requestId !== searchRequestRef.id) return;
|
||||
console.error("Search wikis failed", err);
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
if (disposed || requestId !== searchRequestRef.id) return;
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearTimeout(t);
|
||||
};
|
||||
}, [searchQuery, searchRequestRef]);
|
||||
|
||||
const addWikiRef = (wiki: Wiki) => {
|
||||
const id = String(wiki.id || "").trim();
|
||||
if (!id) return;
|
||||
if (wikis.some((w) => w.id === id)) {
|
||||
setActiveId(id);
|
||||
return;
|
||||
}
|
||||
const title = (wiki.title || "").trim() || "Untitled wiki";
|
||||
setWikis((prev) => [
|
||||
{
|
||||
id,
|
||||
source: "ref",
|
||||
ref: { id },
|
||||
operation: "reference",
|
||||
title,
|
||||
doc: null,
|
||||
updated_at: wiki.updated_at,
|
||||
},
|
||||
...prev,
|
||||
]);
|
||||
setActiveId(id);
|
||||
};
|
||||
|
||||
const openEditor = () => {
|
||||
if (!wikis.length) {
|
||||
const id = newId();
|
||||
const seed: WikiSnapshot = {
|
||||
id,
|
||||
source: "inline",
|
||||
operation: "create",
|
||||
title: "Untitled wiki",
|
||||
doc: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "" }] }] },
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
setWikis((prev) => [seed, ...prev]);
|
||||
setActiveId(id);
|
||||
}
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const createWiki = () => {
|
||||
const id = newId();
|
||||
const next: WikiSnapshot = {
|
||||
id,
|
||||
source: "inline",
|
||||
operation: "create",
|
||||
title: "Untitled wiki",
|
||||
doc: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "" }] }] },
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
setWikis((prev) => [next, ...prev]);
|
||||
setActiveId(id);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const removeWiki = (id: string) => {
|
||||
setWikis((prev) => prev.filter((w) => w.id !== id));
|
||||
if (activeId === id) setActiveId(null);
|
||||
};
|
||||
|
||||
const saveWiki = () => {
|
||||
if (!editor || !activeId) return;
|
||||
const payload = editor.getJSON();
|
||||
const nextTitle = clampTitle(wikiTitle);
|
||||
setWikis((prev) =>
|
||||
prev.map((w) =>
|
||||
w.id !== activeId
|
||||
? w
|
||||
: {
|
||||
...w,
|
||||
source: w.source || "inline",
|
||||
operation: w.operation === "create" ? "create" : "update",
|
||||
title: nextTitle,
|
||||
doc: payload,
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
)
|
||||
);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const setLink = () => {
|
||||
if (!editor) return;
|
||||
const prev = editor.getAttributes("link")?.href as string | undefined;
|
||||
const href = window.prompt("Link URL", prev || "https://");
|
||||
if (href == null) return;
|
||||
const next = href.trim();
|
||||
if (!next.length) {
|
||||
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().extendMarkRange("link").setLink({ href: next }).run();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px",
|
||||
background: "#0b1220",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #1f2937",
|
||||
}}
|
||||
>
|
||||
<style jsx global>{`
|
||||
.tiptap-editor p {
|
||||
margin: 0.5rem 0;
|
||||
line-height: 1.65;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.tiptap-editor h1 {
|
||||
margin: 1rem 0 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.tiptap-editor h2 {
|
||||
margin: 0.9rem 0 0.4rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.tiptap-editor h3 {
|
||||
margin: 0.8rem 0 0.35rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.tiptap-editor ul,
|
||||
.tiptap-editor ol {
|
||||
margin: 0.6rem 0;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
.tiptap-editor li {
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
.tiptap-editor blockquote {
|
||||
margin: 0.75rem 0;
|
||||
padding-left: 0.75rem;
|
||||
border-left: 4px solid rgba(148, 163, 184, 0.55);
|
||||
color: rgba(100, 116, 139, 1);
|
||||
}
|
||||
.dark .tiptap-editor blockquote {
|
||||
border-left-color: rgba(71, 85, 105, 1);
|
||||
color: rgba(148, 163, 184, 1);
|
||||
}
|
||||
.tiptap-editor code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
font-size: 0.85em;
|
||||
padding: 0.1rem 0.25rem;
|
||||
border-radius: 0.35rem;
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
.tiptap-editor pre {
|
||||
margin: 0.8rem 0;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(226, 232, 240, 1);
|
||||
background: rgba(248, 250, 252, 1);
|
||||
overflow: auto;
|
||||
}
|
||||
.dark .tiptap-editor pre {
|
||||
border-color: rgba(30, 41, 59, 1);
|
||||
background: rgba(13, 17, 23, 1);
|
||||
}
|
||||
.tiptap-editor pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
.tiptap-editor a {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
||||
<div style={{ fontWeight: 700, fontSize: "14px" }}>Wiki</div>
|
||||
<Badge size="sm" variant="light" color="info">
|
||||
{wikis.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "8px", marginTop: "10px" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openEditor}
|
||||
style={{
|
||||
flex: 1,
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
padding: "8px",
|
||||
cursor: "pointer",
|
||||
background: "#2563eb",
|
||||
color: "white",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Open wiki editor
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={createWiki}
|
||||
title="New wiki"
|
||||
style={{
|
||||
width: "42px",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
padding: "8px",
|
||||
cursor: "pointer",
|
||||
background: "#1f2937",
|
||||
color: "white",
|
||||
fontWeight: 900,
|
||||
}}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "10px" }}>
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Add existing wiki</div>
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search by title…"
|
||||
style={{
|
||||
width: "100%",
|
||||
border: "1px solid #1f2937",
|
||||
background: "#0b1220",
|
||||
color: "#e5e7eb",
|
||||
borderRadius: "6px",
|
||||
padding: "8px 10px",
|
||||
fontSize: "12px",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
{isSearching ? (
|
||||
<div style={{ marginTop: "6px", fontSize: "12px", color: "#94a3b8" }}>Searching…</div>
|
||||
) : null}
|
||||
{!isSearching && searchQuery.trim().length > 0 ? (
|
||||
<div style={{ marginTop: "6px", display: "grid", gap: "6px" }}>
|
||||
{searchResults.slice(0, 8).map((w) => (
|
||||
<div
|
||||
key={w.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #1f2937",
|
||||
background: "transparent",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ color: "#e5e7eb", fontSize: "12px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{(w.title || "").trim() || "Untitled wiki"}
|
||||
</div>
|
||||
<div style={{ color: "#94a3b8", fontSize: "11px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{w.id}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addWikiRef(w)}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "#111827",
|
||||
color: "#93c5fd",
|
||||
cursor: "pointer",
|
||||
borderRadius: "6px",
|
||||
padding: "6px 8px",
|
||||
fontSize: "12px",
|
||||
fontWeight: 700,
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!searchResults.length ? (
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>No results.</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{wikis.length ? (
|
||||
<div style={{ marginTop: "10px", display: "grid", gap: "8px" }}>
|
||||
{wikis.slice(0, 8).map((w) => (
|
||||
<div
|
||||
key={w.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #1f2937",
|
||||
background: w.id === activeId ? "#111827" : "transparent",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveId(w.id);
|
||||
setOpen(true);
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: "left",
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: "#e5e7eb",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
title={w.title}
|
||||
>
|
||||
{w.title}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeWiki(w.id)}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "#111827",
|
||||
color: "#fca5a5",
|
||||
cursor: "pointer",
|
||||
borderRadius: "6px",
|
||||
padding: "6px 8px",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
title="Remove"
|
||||
>
|
||||
Del
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{wikis.length > 8 ? (
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>+{wikis.length - 8} more…</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>
|
||||
No wiki yet for this project.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
isOpen={open}
|
||||
onClose={() => setOpen(false)}
|
||||
showCloseButton={false}
|
||||
// Defensive: even if Modal defaults change, keep wiki popup free of the "X" close button.
|
||||
className="max-w-[1100px] m-4 [&>button]:hidden"
|
||||
>
|
||||
<div className="p-6 bg-white rounded-3xl dark:bg-gray-900">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">Project</div>
|
||||
<div className="text-sm font-mono break-all text-gray-700 dark:text-gray-200">{projectId}</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" className="bg-brand-500 hover:bg-brand-600 text-white" onClick={saveWiki} disabled={!editor || !activeId}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
<div className="lg:col-span-1">
|
||||
<div className="text-xs font-semibold text-gray-700 dark:text-gray-200 mb-2">Wikis</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{wikis.map((w) => (
|
||||
<button
|
||||
key={w.id}
|
||||
type="button"
|
||||
onClick={() => setActiveId(w.id)}
|
||||
className={`text-left rounded-xl border px-3 py-2 text-sm transition ${
|
||||
w.id === activeId
|
||||
? "border-brand-500 bg-brand-50 dark:bg-brand-500/10"
|
||||
: "border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117]"
|
||||
}`}
|
||||
title={w.title}
|
||||
>
|
||||
<div className="font-medium truncate">{w.title}</div>
|
||||
<div className="text-[11px] text-gray-500 dark:text-gray-400 truncate">{w.id}</div>
|
||||
</button>
|
||||
))}
|
||||
<Button size="sm" variant="outline" onClick={createWiki}>
|
||||
+ New wiki
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-3">
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<Label>Title</Label>
|
||||
<input
|
||||
value={wikiTitle}
|
||||
onChange={(e) => setWikiTitle(e.target.value)}
|
||||
className="h-11 w-full rounded-xl border border-gray-200 bg-transparent px-4 py-2.5 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800"
|
||||
placeholder="Wiki title"
|
||||
disabled={!activeId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleBold().run()} disabled={!editor}>
|
||||
B
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleItalic().run()} disabled={!editor}>
|
||||
I
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleHeading({ level: 1 }).run()} disabled={!editor}>
|
||||
H1
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()} disabled={!editor}>
|
||||
H2
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()} disabled={!editor}>
|
||||
H3
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleBulletList().run()} disabled={!editor}>
|
||||
Bullets
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleOrderedList().run()} disabled={!editor}>
|
||||
Numbers
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleBlockquote().run()} disabled={!editor}>
|
||||
Quote
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleCodeBlock().run()} disabled={!editor}>
|
||||
Code
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={setLink} disabled={!editor}>
|
||||
Link
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117]">
|
||||
{editor ? <EditorContent editor={editor} /> : <div className="p-4 text-sm text-gray-500">Loading editor...</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
Stored in snapshot_json on commit. This page does not write to DB yet.
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export const BACKGROUND_LAYER_OPTIONS = [
|
||||
{ id: "land", label: "Land" },
|
||||
{ id: "bg-countries-fill", label: "Countries" },
|
||||
{ id: "bg-country-borders-line", label: "Country Borders" },
|
||||
{ id: "country-labels", label: "Country Labels" },
|
||||
{ id: "regions-line", label: "Regions" },
|
||||
{ id: "lakes-fill", label: "Lakes" },
|
||||
{ id: "rivers-line", label: "Rivers" },
|
||||
|
||||
@@ -14,7 +14,9 @@ import { buildEditorSnapshot, normalizeEditorSnapshot } from "@/uhm/lib/editor/s
|
||||
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
|
||||
import type { CreatedEntitySummary, PendingEntityCreate } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
import type { Feature, FeatureCollection, FeatureId } from "@/uhm/types/geo";
|
||||
import type { EditorSnapshot, Section, SectionCommit, SectionState } from "@/uhm/types/sections";
|
||||
import type { EditorSnapshot, Section, SectionCommit, SectionState, EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
|
||||
type EditorDraftApi = {
|
||||
draft: FeatureCollection;
|
||||
@@ -33,6 +35,9 @@ type Options = {
|
||||
newSectionTitle: string;
|
||||
pendingSaveCount: number;
|
||||
pendingEntityCreates: PendingEntityCreate[];
|
||||
projectEntityRefs: EntitySnapshot[];
|
||||
wikis: WikiSnapshot[];
|
||||
entityWikiLinks: EntityWikiLinkSnapshot[];
|
||||
lastSectionSnapshot: EditorSnapshot | null;
|
||||
commitTitle: string;
|
||||
commitNote: string;
|
||||
@@ -43,7 +48,10 @@ type Options = {
|
||||
setInitialData: Dispatch<SetStateAction<FeatureCollection>>;
|
||||
setSectionCommits: Dispatch<SetStateAction<SectionCommit[]>>;
|
||||
setPendingEntityCreates: Dispatch<SetStateAction<PendingEntityCreate[]>>;
|
||||
setProjectEntityRefs: Dispatch<SetStateAction<EntitySnapshot[]>>;
|
||||
setCreatedEntities: Dispatch<SetStateAction<CreatedEntitySummary[]>>;
|
||||
setWikis: Dispatch<SetStateAction<WikiSnapshot[]>>;
|
||||
setEntityWikiLinks: Dispatch<SetStateAction<EntityWikiLinkSnapshot[]>>;
|
||||
setSelectedFeatureId: Dispatch<SetStateAction<FeatureId | null>>;
|
||||
setEntityFormStatus: Dispatch<SetStateAction<string | null>>;
|
||||
setEntityStatus: Dispatch<SetStateAction<string | null>>;
|
||||
@@ -71,6 +79,9 @@ export function useSectionCommands(options: Options) {
|
||||
options.setSectionCommits(commits);
|
||||
options.setPendingEntityCreates([]);
|
||||
options.setCreatedEntities([]);
|
||||
options.setProjectEntityRefs((snapshot?.entities || []).filter((e) => e?.operation === "reference"));
|
||||
options.setWikis(snapshot?.wikis || []);
|
||||
options.setEntityWikiLinks(snapshot?.entity_wikis || []);
|
||||
options.setSelectedFeatureId(null);
|
||||
options.setEntityFormStatus(null);
|
||||
}, [options]);
|
||||
@@ -90,6 +101,9 @@ export function useSectionCommands(options: Options) {
|
||||
draft: options.editor.draft,
|
||||
changes: geometryChanges,
|
||||
pendingEntities: options.pendingEntityCreates,
|
||||
projectEntityRefs: options.projectEntityRefs,
|
||||
wikis: options.wikis,
|
||||
entityWikiLinks: options.entityWikiLinks,
|
||||
previousSnapshot: options.lastSectionSnapshot,
|
||||
hasPersistedFeature: options.editor.hasPersistedFeature,
|
||||
});
|
||||
@@ -233,6 +247,7 @@ export function useSectionCommands(options: Options) {
|
||||
if (snapshot?.editor_feature_collection) {
|
||||
options.setInitialData(snapshot.editor_feature_collection);
|
||||
}
|
||||
options.setWikis(snapshot?.wikis || []);
|
||||
options.setSectionCommits(await fetchSectionCommits(options.activeSection.id));
|
||||
options.setEntityFormStatus("Đã restore commit.");
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import type { Entity } from "@/uhm/types/entities";
|
||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||
import type { FeatureId } from "@/uhm/types/geo";
|
||||
import { DEFAULT_ENTITY_TYPE_ID } from "@/uhm/lib/entityTypeOptions";
|
||||
import type {
|
||||
@@ -12,6 +13,8 @@ import type {
|
||||
export function useEntitySessionState() {
|
||||
// Entities đã persisted từ backend (dùng cho search/binding).
|
||||
const [persistedEntities, setPersistedEntities] = useState<Entity[]>([]);
|
||||
// Entities được "pin" vào project dưới dạng reference (không cần chọn geometry).
|
||||
const [projectEntityRefs, setProjectEntityRefs] = useState<EntitySnapshot[]>([]);
|
||||
// Entities tạo mới trong phiên nhưng chưa commit lên backend.
|
||||
const [pendingEntityCreates, setPendingEntityCreates] = useState<PendingEntityCreate[]>([]);
|
||||
// Tóm tắt entities đã tạo (để hiển thị nhanh ở sidebar).
|
||||
@@ -50,6 +53,8 @@ export function useEntitySessionState() {
|
||||
return {
|
||||
persistedEntities,
|
||||
setPersistedEntities,
|
||||
projectEntityRefs,
|
||||
setProjectEntityRefs,
|
||||
pendingEntityCreates,
|
||||
setPendingEntityCreates,
|
||||
createdEntities,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
||||
|
||||
export function useWikiSessionState() {
|
||||
const [wikis, setWikis] = useState<WikiSnapshot[]>([]);
|
||||
const [entityWikiLinks, setEntityWikiLinks] = useState<EntityWikiLinkSnapshot[]>([]);
|
||||
return { wikis, setWikis, entityWikiLinks, setEntityWikiLinks };
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import type { PendingEntityCreate } from "@/uhm/lib/editor/session/sessionTypes"
|
||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||
import type { Feature, FeatureCollection, GeometrySnapshot, LinkScopeSnapshot } from "@/uhm/types/geo";
|
||||
import type { EditorSnapshot, Section } from "@/uhm/types/sections";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
||||
|
||||
export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
@@ -26,6 +28,9 @@ export function buildEditorSnapshot(options: {
|
||||
draft: FeatureCollection;
|
||||
changes: Change[];
|
||||
pendingEntities: PendingEntityCreate[];
|
||||
projectEntityRefs: EntitySnapshot[];
|
||||
wikis: WikiSnapshot[];
|
||||
entityWikiLinks: EntityWikiLinkSnapshot[];
|
||||
previousSnapshot: EditorSnapshot | null;
|
||||
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
|
||||
}): EditorSnapshot {
|
||||
@@ -55,13 +60,10 @@ export function buildEditorSnapshot(options: {
|
||||
|
||||
const pendingEntityIds = new Set(options.pendingEntities.map((entity) => entity.id));
|
||||
const entityRows = new globalThis.Map<string, EntitySnapshot>();
|
||||
for (const item of options.previousSnapshot?.entities || []) {
|
||||
const id = typeof item.id === "string" || typeof item.id === "number" ? String(item.id) : "";
|
||||
if (id) entityRows.set(id, { ...item });
|
||||
}
|
||||
for (const entity of options.pendingEntities) {
|
||||
entityRows.set(entity.id, {
|
||||
id: entity.id,
|
||||
source: "inline",
|
||||
operation: "create",
|
||||
name: entity.name,
|
||||
slug: entity.slug,
|
||||
@@ -72,11 +74,40 @@ export function buildEditorSnapshot(options: {
|
||||
});
|
||||
}
|
||||
|
||||
for (const ref of options.projectEntityRefs || []) {
|
||||
const id = typeof ref?.id === "string" || typeof ref?.id === "number" ? String(ref.id) : "";
|
||||
if (!id || entityRows.has(id)) continue;
|
||||
const cloned = JSON.parse(JSON.stringify(ref)) as EntitySnapshot;
|
||||
entityRows.set(id, {
|
||||
...cloned,
|
||||
id,
|
||||
source: cloned.source || "ref",
|
||||
ref: cloned.ref || { id },
|
||||
operation: "reference",
|
||||
is_deleted: cloned.is_deleted ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Entities referenced by wiki links should be present as "reference" too.
|
||||
for (const link of options.entityWikiLinks || []) {
|
||||
const id = typeof link?.entity_id === "string" ? link.entity_id : "";
|
||||
if (!id || entityRows.has(id)) continue;
|
||||
entityRows.set(id, {
|
||||
id,
|
||||
source: "ref",
|
||||
ref: { id },
|
||||
operation: "reference",
|
||||
is_deleted: 0,
|
||||
});
|
||||
}
|
||||
|
||||
for (const feature of options.draft.features) {
|
||||
for (const entityId of normalizeFeatureEntityIds(feature)) {
|
||||
if (entityRows.has(entityId)) continue;
|
||||
entityRows.set(entityId, {
|
||||
id: entityId,
|
||||
source: "ref",
|
||||
ref: { id: entityId },
|
||||
operation: "reference",
|
||||
name: feature.properties.entity_names?.[0] || feature.properties.entity_name || entityId,
|
||||
slug: null,
|
||||
@@ -95,17 +126,19 @@ export function buildEditorSnapshot(options: {
|
||||
const changedFromPreviousSnapshot = previousFeature
|
||||
? JSON.stringify(previousFeature) !== JSON.stringify(feature)
|
||||
: false;
|
||||
const operation: GeometrySnapshot["operation"] = previousOperation === "create"
|
||||
? "create"
|
||||
: !previousFeature && (options.previousSnapshot || !options.hasPersistedFeature(feature.properties.id))
|
||||
const operation: GeometrySnapshot["operation"] =
|
||||
previousOperation === "create"
|
||||
? "create"
|
||||
: changedIds.has(id) || changedFromPreviousSnapshot
|
||||
? "update"
|
||||
: "reference";
|
||||
: !previousFeature && (options.previousSnapshot || !options.hasPersistedFeature(feature.properties.id))
|
||||
? "create"
|
||||
: changedIds.has(id) || changedFromPreviousSnapshot
|
||||
? "update"
|
||||
: undefined;
|
||||
const bbox = getFeatureBBox(feature);
|
||||
return {
|
||||
id,
|
||||
operation,
|
||||
source: "inline",
|
||||
type: feature.properties.type || getDefaultTypeIdForFeature(feature),
|
||||
draw_geometry: feature.geometry,
|
||||
binding: normalizeFeatureBindingIds(feature),
|
||||
@@ -134,11 +167,63 @@ export function buildEditorSnapshot(options: {
|
||||
const linkScopes: LinkScopeSnapshot[] = options.draft.features
|
||||
.map((feature) => ({
|
||||
geometry_id: String(feature.properties.id),
|
||||
operation: "replace" as const,
|
||||
operation: "reference" as const,
|
||||
entity_ids: normalizeFeatureEntityIds(feature),
|
||||
}))
|
||||
.filter((scope) => scope.entity_ids.length > 0);
|
||||
|
||||
const previousWikis = new globalThis.Map<string, WikiSnapshot>();
|
||||
for (const item of options.previousSnapshot?.wikis || []) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const id = typeof (item as any).id === "string" ? String((item as any).id) : "";
|
||||
if (id) previousWikis.set(id, item as WikiSnapshot);
|
||||
}
|
||||
|
||||
// Wikis in snapshot_json are treated as current state (not a delta-table like geometries[]).
|
||||
// Operation semantics:
|
||||
// - create/update/delete: this commit changes the wiki itself
|
||||
// - reference: this wiki is a ref used for linking (entity<->wiki), not a modification
|
||||
const wikis: WikiSnapshot[] = (options.wikis || [])
|
||||
.filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0)
|
||||
.map((w) => {
|
||||
const prev = previousWikis.get(w.id) || null;
|
||||
const cloned = JSON.parse(JSON.stringify(w)) as WikiSnapshot;
|
||||
|
||||
cloned.source = cloned.source || "inline";
|
||||
|
||||
// Ref wiki: always mark as reference (used for linking, not changed here).
|
||||
if (cloned.source === "ref") {
|
||||
cloned.ref = cloned.ref || { id: cloned.id };
|
||||
cloned.operation = "reference";
|
||||
return cloned;
|
||||
}
|
||||
|
||||
// Inline wiki: if explicitly marked create/update/delete by UI, keep it.
|
||||
if (cloned.operation === "create" || cloned.operation === "update" || cloned.operation === "delete") {
|
||||
return cloned;
|
||||
}
|
||||
|
||||
// Inline wiki with no explicit operation: mark update only if changed; otherwise omit operation.
|
||||
if (!prev) {
|
||||
// New wiki that somehow has no op set: treat as create.
|
||||
cloned.operation = "create";
|
||||
return cloned;
|
||||
}
|
||||
|
||||
const changed = (() => {
|
||||
try {
|
||||
const prevComparable = { title: (prev as any).title, doc: (prev as any).doc };
|
||||
const nextComparable = { title: (cloned as any).title, doc: (cloned as any).doc };
|
||||
return JSON.stringify(prevComparable) !== JSON.stringify(nextComparable);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
})();
|
||||
|
||||
cloned.operation = changed ? "update" : undefined;
|
||||
return cloned;
|
||||
});
|
||||
|
||||
return {
|
||||
schema_version: 1,
|
||||
section: {
|
||||
@@ -153,6 +238,8 @@ export function buildEditorSnapshot(options: {
|
||||
}),
|
||||
geometries,
|
||||
link_scopes: linkScopes,
|
||||
wikis,
|
||||
entity_wikis: JSON.parse(JSON.stringify(options.entityWikiLinks || [])) as EntityWikiLinkSnapshot[],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useBackgroundSessionState } from "@/uhm/lib/editor/session/useBackgroun
|
||||
import { useEntitySessionState } from "@/uhm/lib/editor/session/useEntitySessionState";
|
||||
import { useSectionSessionState } from "@/uhm/lib/editor/session/useSectionSessionState";
|
||||
import { useTimelineState } from "@/uhm/lib/editor/session/useTimelineState";
|
||||
import { useWikiSessionState } from "@/uhm/lib/editor/session/useWikiSessionState";
|
||||
import type { EditorMode, TimelineRange } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
|
||||
export type {
|
||||
@@ -37,6 +38,7 @@ export function useEditorSessionState(options: Options) {
|
||||
fallbackTimelineRange: options.fallbackTimelineRange,
|
||||
});
|
||||
const background = useBackgroundSessionState();
|
||||
const wiki = useWikiSessionState();
|
||||
|
||||
return {
|
||||
mode,
|
||||
@@ -47,5 +49,6 @@ export function useEditorSessionState(options: Options) {
|
||||
...entity,
|
||||
...timeline,
|
||||
...background,
|
||||
...wiki,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,11 +15,19 @@ export type Entity = {
|
||||
geometry_count?: number;
|
||||
};
|
||||
|
||||
export type EntitySnapshotOperation = "create" | "update" | "delete" | "reference" | "replace";
|
||||
export type EntitySnapshotOperation = "create" | "update" | "delete" | "reference";
|
||||
|
||||
export type EntitySnapshot = {
|
||||
id: string;
|
||||
operation: EntitySnapshotOperation;
|
||||
// Where this entity's data comes from.
|
||||
// - inline: data is embedded in snapshot_json
|
||||
// - ref: data should be fetched externally by ref.id (DB/global)
|
||||
source?: "inline" | "ref";
|
||||
ref?: { id: string };
|
||||
// Delta semantics for this commit:
|
||||
// - create/update/delete: this commit modifies the entity record
|
||||
// - reference: this entity is referenced/linked (e.g., geometry<->entity, entity<->wiki) but not modified
|
||||
operation?: EntitySnapshotOperation;
|
||||
name?: string;
|
||||
slug?: string | null;
|
||||
description?: string | null;
|
||||
|
||||
@@ -35,11 +35,13 @@ export type FeatureCollection = {
|
||||
features: Feature[];
|
||||
};
|
||||
|
||||
export type GeometrySnapshotOperation = "create" | "update" | "delete" | "reference" | "replace";
|
||||
export type GeometrySnapshotOperation = "create" | "update" | "delete" | "reference";
|
||||
|
||||
export type GeometrySnapshot = {
|
||||
id: string;
|
||||
operation: GeometrySnapshotOperation;
|
||||
source?: "inline" | "ref";
|
||||
ref?: { id: string };
|
||||
operation?: GeometrySnapshotOperation;
|
||||
type?: string | null;
|
||||
draw_geometry?: Geometry;
|
||||
geometry?: Geometry;
|
||||
@@ -59,7 +61,8 @@ export type GeometrySnapshot = {
|
||||
|
||||
export type LinkScopeSnapshot = {
|
||||
geometry_id: string;
|
||||
operation: "replace" | "reference";
|
||||
// Link deltas should be represented as "reference" operations (no replace in the current flow).
|
||||
operation: "reference";
|
||||
entity_ids: string[];
|
||||
base_links_hash?: string;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||
import type { FeatureCollection, GeometrySnapshot, LinkScopeSnapshot } from "@/uhm/types/geo";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
|
||||
export type EntityWikiLinkSnapshot = {
|
||||
entity_id: string;
|
||||
wiki_id: string;
|
||||
operation?: "reference" | "delete";
|
||||
is_deleted?: number;
|
||||
};
|
||||
|
||||
// API mới (BackEndGo) dùng Projects/Commits/Submissions.
|
||||
// Giữ tên type "Section" để tránh thay đổi lan rộng trong FE hiện tại.
|
||||
@@ -62,6 +70,8 @@ export type EditorSnapshot = {
|
||||
entities?: EntitySnapshot[];
|
||||
geometries?: GeometrySnapshot[];
|
||||
link_scopes?: LinkScopeSnapshot[];
|
||||
wikis?: WikiSnapshot[];
|
||||
entity_wikis?: EntityWikiLinkSnapshot[];
|
||||
};
|
||||
|
||||
export type EditorLoadResponse = {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export type WikiDoc = unknown;
|
||||
|
||||
export type WikiSnapshotOperation = "create" | "update" | "delete" | "reference";
|
||||
|
||||
export type WikiSnapshot = {
|
||||
id: string;
|
||||
source?: "inline" | "ref";
|
||||
ref?: { id: string };
|
||||
// Optional for backwards-compat with older commits. New commits should include it.
|
||||
operation?: WikiSnapshotOperation;
|
||||
title: string;
|
||||
doc: WikiDoc;
|
||||
updated_at?: string;
|
||||
// Optional, used when representing a delete operation row.
|
||||
is_deleted?: number;
|
||||
};
|
||||
Reference in New Issue
Block a user