wiki page

This commit is contained in:
taDuc
2026-05-10 03:25:47 +07:00
parent 78824ed07a
commit 31297c8b59
4 changed files with 673 additions and 7 deletions
+10
View File
@@ -0,0 +1,10 @@
import WikiBySlugClient from "./wiki-by-slug-client";
export default async function WikiBySlugPage({
params,
}: {
params: Promise<{ slug: string }> | { slug: string };
}) {
const resolved = await params;
return <WikiBySlugClient slug={resolved.slug} />;
}
+468
View File
@@ -0,0 +1,468 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import "react-quill-new/dist/quill.snow.css";
import { ApiError } from "@/uhm/api/http";
import { fetchWikiBySlug, type Wiki } from "@/uhm/api/wikis";
type TocItem = {
id: string;
level: number;
text: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function tiptapJsonToPlainText(node: unknown): string {
if (node == null) return "";
if (typeof node === "string") return node;
if (Array.isArray(node)) return node.map(tiptapJsonToPlainText).join("");
if (isRecord(node)) {
if (node.type === "text" && typeof node.text === "string") return node.text;
if (node.type === "hardBreak") return "\n";
if ("content" in node) return tiptapJsonToPlainText(node.content);
}
return "";
}
function escapeHtml(input: string): string {
return input
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#39;");
}
function normalizeWikiContentToHtml(raw: string | null | undefined): string {
const value = String(raw || "").trim();
if (!value.length) return "";
// New format: HTML string.
if (value[0] === "<") return value;
// Legacy format: Tiptap JSON string.
if (value[0] === "{") {
try {
const json: unknown = JSON.parse(value);
const text = tiptapJsonToPlainText(json).trim();
if (!text.length) return "";
return `<p>${escapeHtml(text).replace(/\n/g, "<br/>")}</p>`;
} catch {
// fall through
}
}
// Unknown plaintext: treat as plain text.
return `<p>${escapeHtml(value).replace(/\n/g, "<br/>")}</p>`;
}
function slugifyHeading(raw: string): string {
const input = String(raw || "").trim();
if (!input.length) return "";
return input
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+/, "")
.replace(/-+$/, "")
.slice(0, 80);
}
function isExternalHref(href: string): boolean {
const h = href.trim().toLowerCase();
return (
h.startsWith("http://") ||
h.startsWith("https://") ||
h.startsWith("mailto:") ||
h.startsWith("tel:") ||
h.startsWith("sms:")
);
}
function rewriteHtmlAndBuildToc(inputHtml: string, wikiBaseUrl: string): { html: string; toc: TocItem[] } {
const parser = new DOMParser();
const doc = parser.parseFromString(inputHtml, "text/html");
// Basic hardening: do not render scripts in user content.
for (const el of Array.from(doc.querySelectorAll("script"))) el.remove();
// Rewrite internal wiki links: Quill stores slug as <a href="other-wiki-slug">...</a>
for (const a of Array.from(doc.querySelectorAll("a[href]"))) {
const href = String(a.getAttribute("href") || "").trim();
if (!href.length) continue;
if (href === "__missing__") continue;
if (href.startsWith("#")) continue;
if (href.startsWith("/")) continue;
if (isExternalHref(href)) continue;
const match = href.match(/^([^?#]+)([?#].*)?$/);
const slugPart = String(match?.[1] || "").replace(/^\/+/, "").trim();
const suffix = String(match?.[2] || "");
const normalizedSlug = slugPart;
if (!normalizedSlug.length) continue;
a.setAttribute("href", `${wikiBaseUrl}${encodeURIComponent(normalizedSlug)}${suffix}`);
a.setAttribute("target", "_self");
}
// Build TOC from headings and ensure they have stable IDs.
const toc: TocItem[] = [];
const seen = new Map<string, number>();
const headings = Array.from(doc.body.querySelectorAll("h1,h2,h3,h4,h5,h6"));
for (const h of headings) {
const text = String(h.textContent || "").trim();
if (!text.length) continue;
const level = Number(String(h.tagName || "").replace(/^H/i, "")) || 1;
const existingId = String(h.getAttribute("id") || "").trim();
if (existingId) {
toc.push({ id: existingId, level, text });
continue;
}
const base = slugifyHeading(text) || "heading";
const n = (seen.get(base) || 0) + 1;
seen.set(base, n);
const id = n === 1 ? base : `${base}-${n}`;
h.setAttribute("id", id);
toc.push({ id, level, text });
}
return { html: doc.body.innerHTML, toc };
}
function formatDate(value?: string | null): string {
const raw = String(value || "").trim();
if (!raw) return "-";
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return raw;
return d.toLocaleString();
}
export default function WikiBySlugClient({ slug }: { slug: string }) {
const [wiki, setWiki] = useState<Wiki | null>(null);
const [status, setStatus] = useState<"idle" | "loading" | "error" | "ready">("idle");
const [error, setError] = useState<string | null>(null);
const [renderHtml, setRenderHtml] = useState<string>("");
const [toc, setToc] = useState<TocItem[]>([]);
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
const normalizedSlug = useMemo(() => String(slug || "").trim(), [slug]);
const contentRootRef = useRef<HTMLDivElement | null>(null);
// Load wiki data by slug.
useEffect(() => {
const value = String(normalizedSlug || "").trim();
if (!value.length) {
setWiki(null);
setStatus("error");
setError("Missing wiki slug.");
return;
}
let disposed = false;
(async () => {
setStatus("loading");
setError(null);
try {
const res = await fetchWikiBySlug(value);
if (disposed) return;
if (!res) {
setWiki(null);
setStatus("ready");
setRenderHtml("");
setToc([]);
return;
}
setWiki(res);
setStatus("ready");
} catch (err) {
if (disposed) return;
const msg =
err instanceof ApiError
? err.message
: err instanceof Error
? err.message
: "Failed to load wiki.";
setStatus("error");
setError(msg);
}
})();
return () => {
disposed = true;
};
}, [normalizedSlug]);
// Transform content: normalize -> rewrite internal links -> inject heading ids + toc.
useEffect(() => {
if (!wiki) {
setRenderHtml("");
setToc([]);
return;
}
const raw =
(wiki.content ?? (wiki as unknown as { doc?: string | null }).doc ?? "") || "";
const html = normalizeWikiContentToHtml(raw);
try {
const base = `${window.location.origin}/wiki/`;
const processed = rewriteHtmlAndBuildToc(html, base);
setRenderHtml(processed.html);
setToc(processed.toc);
setActiveHeadingId(processed.toc[0]?.id ?? null);
} catch (err) {
console.error("Failed to process wiki HTML", err);
setRenderHtml(html);
setToc([]);
}
}, [wiki]);
// Track active heading for TOC highlight.
useEffect(() => {
if (!toc.length) return;
const root = contentRootRef.current;
if (!root) return;
const headings = toc
.map((t) => root.querySelector<HTMLElement>(`#${CSS.escape(t.id)}`))
.filter((el): el is HTMLElement => Boolean(el));
if (!headings.length) return;
const obs = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => (a.boundingClientRect.top ?? 0) - (b.boundingClientRect.top ?? 0));
const top = visible[0]?.target as HTMLElement | undefined;
const id = top?.id || null;
if (id) setActiveHeadingId(id);
},
{ root: null, rootMargin: "-20% 0px -70% 0px", threshold: [0, 1] }
);
for (const h of headings) obs.observe(h);
return () => obs.disconnect();
}, [toc]);
return (
<div className="min-h-screen bg-white text-gray-900 dark:bg-gray-950 dark:text-gray-100">
<div className="mx-auto max-w-6xl px-4 py-6">
<div className="mb-5 flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-xs text-gray-500 dark:text-gray-400">Wiki</div>
<h1 className="mt-1 text-2xl font-bold leading-tight break-words">
{wiki?.title?.trim() || normalizedSlug || "Wiki"}
</h1>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-gray-600 dark:text-gray-300">
<span className="break-all">
<span className="font-semibold">Slug:</span> {normalizedSlug || "-"}
</span>
<span className="break-all">
<span className="font-semibold">ID:</span> {wiki?.id || "-"}
</span>
<span className="break-all">
<span className="font-semibold">Project:</span>{" "}
{wiki?.project_id || "-"}
</span>
<span>
<span className="font-semibold">Created:</span> {formatDate(wiki?.created_at)}
</span>
<span>
<span className="font-semibold">Updated:</span> {formatDate(wiki?.updated_at)}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Link
href="/"
className="h-9 inline-flex items-center rounded-lg border border-gray-200 dark:border-gray-800 px-3 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/[0.04] transition"
>
Home
</Link>
</div>
</div>
{status === "loading" ? (
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-6">
<div className="h-5 w-40 rounded bg-gray-100 dark:bg-white/[0.06] animate-pulse" />
<div className="mt-3 h-4 w-2/3 rounded bg-gray-100 dark:bg-white/[0.06] animate-pulse" />
</div>
) : status === "error" ? (
<div className="rounded-xl border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-950/30 p-6 text-sm text-red-700 dark:text-red-200">
{error || "Failed to load wiki."}
</div>
) : wiki == null ? (
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-6 text-sm text-gray-700 dark:text-gray-200">
Không tìm thấy wiki với slug: <span className="font-semibold break-all">{normalizedSlug}</span>
</div>
) : (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[280px_minmax(0,1fr)]">
<aside className="lg:sticky lg:top-6 self-start rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-4">
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100">Mục lục</div>
{!toc.length ? (
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">Không tiêu đ (H1/H2/...).</div>
) : (
<nav className="mt-3 max-h-[70vh] overflow-auto pr-1">
<div className="grid gap-1">
{toc.map((t) => {
const pad = Math.max(0, Math.min(5, t.level - 1)) * 10;
const isActive = activeHeadingId === t.id;
return (
<a
key={t.id}
href={`#${t.id}`}
className={`rounded-md px-2 py-1 text-xs leading-5 transition ${
isActive
? "bg-brand-50 text-brand-700 dark:bg-brand-500/10 dark:text-brand-300"
: "text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-white/[0.04]"
}`}
style={{ paddingLeft: 8 + pad }}
title={t.text}
>
{t.text}
</a>
);
})}
</div>
</nav>
)}
<div className="mt-4 border-t border-gray-200 dark:border-gray-800 pt-3">
<div className="text-[11px] text-gray-500 dark:text-gray-400 break-all">
Link: {`${typeof window !== "undefined" ? window.location.origin : ""}/wiki/${normalizedSlug}`}
</div>
</div>
</aside>
<main className="min-w-0">
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 overflow-hidden">
<div
ref={contentRootRef}
className="uhm-wiki-view ql-editor text-sm text-gray-900 dark:text-gray-100"
dangerouslySetInnerHTML={{ __html: renderHtml }}
/>
</div>
</main>
</div>
)}
</div>
<style jsx global>{`
/* Quill view container tweaks: allow page-level scrolling instead of inner scroll. */
.uhm-wiki-view.ql-editor {
height: auto;
overflow-y: visible;
padding: 18px 20px;
}
/* Improve readability for view mode (Quill resets block margins to 0). */
.uhm-wiki-view.ql-editor p {
margin: 0 0 0.75em;
}
.uhm-wiki-view.ql-editor h1 {
margin: 1.25em 0 0.6em;
font-size: 1.9em;
font-weight: 800;
line-height: 1.2;
}
.uhm-wiki-view.ql-editor h2 {
margin: 1.15em 0 0.55em;
font-size: 1.55em;
font-weight: 800;
line-height: 1.25;
}
.uhm-wiki-view.ql-editor h3 {
margin: 1.05em 0 0.5em;
font-size: 1.25em;
font-weight: 700;
line-height: 1.3;
}
.uhm-wiki-view.ql-editor h4,
.uhm-wiki-view.ql-editor h5,
.uhm-wiki-view.ql-editor h6 {
margin: 0.95em 0 0.45em;
font-size: 1.05em;
font-weight: 700;
line-height: 1.35;
}
.uhm-wiki-view.ql-editor ul,
.uhm-wiki-view.ql-editor ol {
margin: 0 0 0.75em;
padding-left: 1.5em;
}
.uhm-wiki-view.ql-editor blockquote {
margin: 0 0 0.75em;
padding-left: 12px;
border-left: 3px solid rgba(148, 163, 184, 0.6);
color: rgba(71, 85, 105, 1);
}
:is(.dark *) .uhm-wiki-view.ql-editor blockquote {
border-left-color: rgba(100, 116, 139, 0.6);
color: rgba(203, 213, 225, 0.95);
}
.uhm-wiki-view.ql-editor pre {
margin: 0 0 0.75em;
padding: 12px 14px;
border: 1px solid rgba(226, 232, 240, 1);
border-radius: 10px;
background: rgba(248, 250, 252, 1);
overflow: auto;
}
:is(.dark *) .uhm-wiki-view.ql-editor pre {
border-color: rgba(51, 65, 85, 1);
background: rgba(2, 6, 23, 0.4);
}
.uhm-wiki-view.ql-editor img {
max-width: 100%;
height: auto;
border-radius: 8px;
}
.uhm-wiki-view.ql-editor h1,
.uhm-wiki-view.ql-editor h2,
.uhm-wiki-view.ql-editor h3,
.uhm-wiki-view.ql-editor h4,
.uhm-wiki-view.ql-editor h5,
.uhm-wiki-view.ql-editor h6 {
scroll-margin-top: 16px;
}
.uhm-wiki-view.ql-editor a {
text-decoration: underline;
text-decoration-thickness: from-font;
text-underline-offset: 2px;
}
.uhm-wiki-view.ql-editor a[href]:not([href=""]) {
color: #2563eb;
}
:is(.dark *) .uhm-wiki-view.ql-editor a[href]:not([href=""]) {
color: #60a5fa;
}
.uhm-wiki-view.ql-editor a[href="__missing__"] {
cursor: default;
pointer-events: none;
}
.uhm-wiki-view.ql-editor a:not([href]),
.uhm-wiki-view.ql-editor a[href=""],
.uhm-wiki-view.ql-editor a[href="__missing__"] {
color: #dc2626;
}
:is(.dark *) .uhm-wiki-view.ql-editor a:not([href]),
:is(.dark *) .uhm-wiki-view.ql-editor a[href=""],
:is(.dark *) .uhm-wiki-view.ql-editor a[href="__missing__"] {
color: #f87171;
}
`}</style>
</div>
);
}
+1
View File
@@ -3,6 +3,7 @@ import { ApiError, requestJson } from "@/uhm/api/http";
export type Wiki = { export type Wiki = {
id: string; id: string;
project_id?: string;
title?: string; title?: string;
slug?: string | null; slug?: string | null;
content?: string; content?: string;
+194 -7
View File
@@ -20,6 +20,8 @@ const ReactQuillEditor = dynamic<ReactQuillProps>(() => import("react-quill-new"
loading: () => <div className="h-[480px] w-full animate-pulse bg-gray-100 rounded-lg" />, loading: () => <div className="h-[480px] w-full animate-pulse bg-gray-100 rounded-lg" />,
}); });
let quillLinkSanitizePatched = false;
type Props = { type Props = {
projectId: string; projectId: string;
wikis: WikiSnapshot[]; wikis: WikiSnapshot[];
@@ -42,6 +44,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
const [wikiTitle, setWikiTitle] = useState(""); const [wikiTitle, setWikiTitle] = useState("");
const [wikiSlug, setWikiSlug] = useState(""); const [wikiSlug, setWikiSlug] = useState("");
const [wikiDocHtml, setWikiDocHtml] = useState(""); const [wikiDocHtml, setWikiDocHtml] = useState("");
const wikiDocStorageFormat = useMemo(() => detectWikiDocStorageFormat(wikiDocHtml), [wikiDocHtml]);
const [wikiSaveError, setWikiSaveError] = useState<string | null>(null); const [wikiSaveError, setWikiSaveError] = useState<string | null>(null);
const [isCreateOpen, setIsCreateOpen] = useState(false); const [isCreateOpen, setIsCreateOpen] = useState(false);
const [createTitle, setCreateTitle] = useState(""); const [createTitle, setCreateTitle] = useState("");
@@ -66,6 +69,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
const [isGlobalWikiSearching, setIsGlobalWikiSearching] = useState(false); const [isGlobalWikiSearching, setIsGlobalWikiSearching] = useState(false);
const [globalWikiSearchError, setGlobalWikiSearchError] = useState<string | null>(null); const [globalWikiSearchError, setGlobalWikiSearchError] = useState<string | null>(null);
const globalWikiSearchRequestRef = useRef(0); const globalWikiSearchRequestRef = useRef(0);
const importFileInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => { useEffect(() => {
if (!autoOpen) return; if (!autoOpen) return;
@@ -73,6 +77,38 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
setOpen(true); setOpen(true);
}, [autoOpen]); }, [autoOpen]);
// Allow Quill to keep wiki links where href is a slug (no scheme).
useEffect(() => {
if (typeof window === "undefined") return;
if (quillLinkSanitizePatched) return;
quillLinkSanitizePatched = true;
(async () => {
try {
const mod: any = await import("react-quill-new");
const Quill = mod?.Quill;
if (!Quill) return;
const Link = Quill.import?.("formats/link");
if (!Link) return;
const anyLink = Link as any;
if (anyLink.__uhmAllowSlugHref) return;
const original = anyLink.sanitize;
anyLink.sanitize = (url: unknown) => {
const value = String(url ?? "").trim();
const lower = value.toLowerCase();
if (lower.startsWith("javascript:")) return "";
// Keep slug/relative/external as-is; rendering layer will rewrite slug links for navigation.
return value;
};
anyLink.__uhmAllowSlugHref = true;
anyLink.__uhmOriginalSanitize = original;
} catch {
// ignore
}
})();
}, []);
useEffect(() => { useEffect(() => {
if (!requestedActiveId) return; if (!requestedActiveId) return;
if (wikis.some((w) => w.id === requestedActiveId)) { if (wikis.some((w) => w.id === requestedActiveId)) {
@@ -210,6 +246,77 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
setOpen(false); setOpen(false);
}; };
const exportCurrentWikiDoc = useCallback(() => {
if (!activeId) return;
const fmt = detectWikiDocStorageFormat(wikiDocHtml);
const label = fmt === "json" ? "json" : fmt === "text" ? "txt" : "html";
const mime =
fmt === "json"
? "application/json;charset=utf-8"
: fmt === "text"
? "text/plain;charset=utf-8"
: "text/html;charset=utf-8";
const base =
normalizeWikiSlugInput(wikiSlug) ||
slugifyWikiTitle(wikiTitle) ||
String(activeId).slice(0, 8);
const filename = `${base}.${label}`;
downloadTextFile(filename, wikiDocHtml || "", mime);
}, [activeId, wikiDocHtml, wikiSlug, wikiTitle]);
const openImportPicker = useCallback(() => {
if (!activeId) return;
importFileInputRef.current?.click();
}, [activeId]);
const handleImportFile = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] || null;
// Allow selecting the same file again.
e.target.value = "";
if (!file) return;
if (!activeId) return;
try {
// Only accept HTML import to match the current Quill storage format.
const name = (file.name || "").toLowerCase();
const isHtml =
name.endsWith(".html") ||
name.endsWith(".htm") ||
String(file.type || "").toLowerCase().includes("text/html");
if (!isHtml) {
setWikiSaveError("Chi ho tro import file HTML (.html/.htm).");
return;
}
const text = await file.text();
const raw = String(text || "").trim();
if (!raw.length) {
setWikiDocHtml("");
setWikiSaveError(null);
return;
}
if (raw[0] !== "<") {
setWikiSaveError("Noi dung file khong phai HTML hop le.");
return;
}
// Quill drops <a> tags that do not have a valid href.
// Preserve the intent by inserting a placeholder href.
const normalized = ensureAnchorsHaveHref(raw);
setWikiDocHtml(normalized);
setWikiSaveError(null);
} catch (err) {
const msg = err instanceof Error ? err.message : "Khong import duoc file.";
setWikiSaveError(msg);
}
},
[activeId]
);
const closeWikiLinkModal = useCallback(() => { const closeWikiLinkModal = useCallback(() => {
setIsWikiLinkOpen(false); setIsWikiLinkOpen(false);
setWikiLinkQuery(""); setWikiLinkQuery("");
@@ -635,13 +742,38 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
// Defensive: even if Modal defaults change, keep wiki popup free of the "X" close button. // Defensive: even if Modal defaults change, keep wiki popup free of the "X" close button.
className="max-w-[1100px] m-4 [&>button]:hidden" className="max-w-[1100px] m-4 [&>button]:hidden"
> >
<div className="p-6 bg-white rounded-3xl dark:bg-gray-900"> <div className="p-6 bg-white rounded-3xl dark:bg-gray-900 max-h-[calc(100vh-2rem)] overflow-hidden flex flex-col">
<input
ref={importFileInputRef}
type="file"
accept=".html,.htm,text/html"
onChange={handleImportFile}
style={{ display: "none" }}
/>
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-xs text-gray-500 dark:text-gray-400">Project</div> <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 className="text-sm font-mono break-all text-gray-700 dark:text-gray-200">{projectId}</div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={openImportPicker}
disabled={!activeId}
title="Import HTML"
>
Import
</Button>
<Button
size="sm"
variant="outline"
onClick={exportCurrentWikiDoc}
disabled={!activeId}
title={`Export ${wikiDocStorageFormat.toUpperCase()}`}
>
Export {wikiDocStorageFormat.toUpperCase()}
</Button>
<Button size="sm" variant="outline" onClick={() => setOpen(false)}> <Button size="sm" variant="outline" onClick={() => setOpen(false)}>
Cancel Cancel
</Button> </Button>
@@ -651,7 +783,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
</div> </div>
</div> </div>
<div className="mt-5 grid grid-cols-1 lg:grid-cols-4 gap-6"> <div className="mt-5 grid grid-cols-1 lg:grid-cols-4 gap-6 flex-1 min-h-0 overflow-auto">
<div className="lg:col-span-1"> <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="text-xs font-semibold text-gray-700 dark:text-gray-200 mb-2">Wikis</div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -711,7 +843,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
value={wikiDocHtml} value={wikiDocHtml}
onChange={(content: string) => setWikiDocHtml(content)} onChange={(content: string) => setWikiDocHtml(content)}
modules={quillModules} modules={quillModules}
className="min-h-[320px]" className="min-h-[320px] uhm-wiki-quill"
placeholder="Nhap noi dung wiki..." placeholder="Nhap noi dung wiki..."
readOnly={!activeId} readOnly={!activeId}
/> />
@@ -719,13 +851,20 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
</div> </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> </div>
</Modal> </Modal>
<style jsx global>{`
/* Quill editor content is inheriting a light-on-dark color in some themes.
Force paragraph/text to be readable on the wiki editor's (light) background. */
.uhm-wiki-quill .ql-editor {
color: #000 !important;
}
.uhm-wiki-quill .ql-editor p {
color: #000 !important;
}
`}</style>
<Modal <Modal
isOpen={isWikiLinkOpen} isOpen={isWikiLinkOpen}
onClose={closeWikiLinkModal} onClose={closeWikiLinkModal}
@@ -922,3 +1061,51 @@ function escapeHtml(input: string): string {
.replaceAll("\"", "&quot;") .replaceAll("\"", "&quot;")
.replaceAll("'", "&#39;"); .replaceAll("'", "&#39;");
} }
type WikiDocStorageFormat = "html" | "json" | "text";
function detectWikiDocStorageFormat(doc: string): WikiDocStorageFormat {
const raw = String(doc || "").trim();
if (!raw.length) return "html";
const first = raw[0];
if (first === "<") return "html";
if (first === "{" || first === "[") return "json";
return "text";
}
function downloadTextFile(filename: string, contents: string, mime: string): void {
if (typeof window === "undefined") return;
const safeName = String(filename || "export.txt").replace(/[\\/]+/g, "_");
const blob = new Blob([contents], { type: mime || "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = safeName;
a.style.display = "none";
document.body.appendChild(a);
a.click();
a.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}
function ensureAnchorsHaveHref(html: string): string {
const raw = String(html || "").trim();
if (!raw.length) return "";
if (typeof window === "undefined") return raw;
try {
const parser = new DOMParser();
const doc = parser.parseFromString(raw, "text/html");
const anchors = Array.from(doc.querySelectorAll("a"));
for (const a of anchors) {
const href = a.getAttribute("href");
if (href == null || String(href).trim() === "") {
// Placeholder: the viewer will render this as "missing" (red) and will not rewrite it.
a.setAttribute("href", "__missing__");
}
}
return doc.body.innerHTML;
} catch {
return raw;
}
}