pre view wiki

This commit is contained in:
taDuc
2026-05-02 21:13:29 +07:00
parent a74047fd09
commit 12c351c68a
23 changed files with 3052 additions and 117 deletions
@@ -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
View File
@@ -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>
);
}
+587
View File
@@ -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>
);
}