@@ -5,7 +5,7 @@ import Link from "next/link";
|
|||||||
import "react-quill-new/dist/quill.snow.css";
|
import "react-quill-new/dist/quill.snow.css";
|
||||||
|
|
||||||
import { ApiError } from "@/uhm/api/http";
|
import { ApiError } from "@/uhm/api/http";
|
||||||
import { fetchWikiBySlug, type Wiki } from "@/uhm/api/wikis";
|
import { fetchWikiBySlug, getContentByVersionWikiId, type Wiki } from "@/uhm/api/wikis";
|
||||||
|
|
||||||
type TocItem = {
|
type TocItem = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -141,18 +141,33 @@ function rewriteHtmlAndBuildToc(inputHtml: string, wikiBaseUrl: string): { html:
|
|||||||
return { html: doc.body.innerHTML, toc };
|
return { html: doc.body.innerHTML, toc };
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(value?: string | null): string {
|
function formatDate(value?: string | null, options?: Intl.DateTimeFormatOptions): string {
|
||||||
const raw = String(value || "").trim();
|
const raw = String(value || "").trim();
|
||||||
if (!raw) return "-";
|
if (!raw) return "-";
|
||||||
const d = new Date(raw);
|
const d = new Date(raw);
|
||||||
if (Number.isNaN(d.getTime())) return raw;
|
if (Number.isNaN(d.getTime())) return raw;
|
||||||
return d.toLocaleString();
|
return d.toLocaleString(
|
||||||
|
"vi-VN",
|
||||||
|
options || {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WikiBySlugClient({ slug }: { slug: string }) {
|
export default function WikiBySlugClient({ slug }: { slug: string }) {
|
||||||
const [wiki, setWiki] = useState<Wiki | null>(null);
|
const [wiki, setWiki] = useState<Wiki | null>(null);
|
||||||
const [status, setStatus] = useState<"idle" | "loading" | "error" | "ready">("idle");
|
const [status, setStatus] = useState<"idle" | "loading" | "error" | "ready">("idle");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [viewMode, setViewMode] = useState<"read" | "history" | "compare">("read");
|
||||||
|
const [selectedVersionsForCompare, setSelectedVersionsForCompare] = useState<Set<string>>(new Set());
|
||||||
|
const [comparisonData, setComparisonData] = useState<{ id: string; content: string; createdAt: string; title: string }[]>([]);
|
||||||
|
const [isComparing, setIsComparing] = useState(false);
|
||||||
|
|
||||||
const [renderHtml, setRenderHtml] = useState<string>("");
|
const [renderHtml, setRenderHtml] = useState<string>("");
|
||||||
const [toc, setToc] = useState<TocItem[]>([]);
|
const [toc, setToc] = useState<TocItem[]>([]);
|
||||||
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
|
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
|
||||||
@@ -176,6 +191,21 @@ export default function WikiBySlugClient({ slug }: { slug: string }) {
|
|||||||
const hidePreviewTimerRef = useRef<number | null>(null);
|
const hidePreviewTimerRef = useRef<number | null>(null);
|
||||||
const previewCacheRef = useRef<Map<string, { title: string; quote: string | null }>>(new Map());
|
const previewCacheRef = useRef<Map<string, { title: string; quote: string | null }>>(new Map());
|
||||||
|
|
||||||
|
const allVersions = useMemo(() => {
|
||||||
|
if (!wiki) return [];
|
||||||
|
const current = {
|
||||||
|
id: wiki.id,
|
||||||
|
created_at: wiki.updated_at,
|
||||||
|
content: wiki.content,
|
||||||
|
isCurrent: true,
|
||||||
|
};
|
||||||
|
const history = (wiki.content_sample || []).map(s => ({ ...s, isCurrent: false }));
|
||||||
|
const uniqueHistory = history.filter(h => h.id !== current.id);
|
||||||
|
const combined = [current, ...uniqueHistory];
|
||||||
|
return combined
|
||||||
|
.filter(v => v.id && v.created_at)
|
||||||
|
.sort((a, b) => new Date(b.created_at!).getTime() - new Date(a.created_at!).getTime());
|
||||||
|
}, [wiki]);
|
||||||
// Load wiki data by slug.
|
// Load wiki data by slug.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const value = String(normalizedSlug || "").trim();
|
const value = String(normalizedSlug || "").trim();
|
||||||
@@ -192,6 +222,18 @@ export default function WikiBySlugClient({ slug }: { slug: string }) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetchWikiBySlug(value);
|
const res = await fetchWikiBySlug(value);
|
||||||
|
let versionContent = res?.content;
|
||||||
|
try {
|
||||||
|
if (res?.content_sample?.[0]?.id) {
|
||||||
|
const contentResp = await getContentByVersionWikiId(res.content_sample[0].id);
|
||||||
|
if (contentResp?.data?.content) {
|
||||||
|
versionContent = contentResp.data.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch version content:", err);
|
||||||
|
}
|
||||||
|
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
if (!res) {
|
if (!res) {
|
||||||
setWiki(null);
|
setWiki(null);
|
||||||
@@ -200,7 +242,7 @@ export default function WikiBySlugClient({ slug }: { slug: string }) {
|
|||||||
setToc([]);
|
setToc([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setWiki(res);
|
setWiki({ ...res, content: versionContent });
|
||||||
setStatus("ready");
|
setStatus("ready");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
@@ -424,107 +466,210 @@ export default function WikiBySlugClient({ slug }: { slug: string }) {
|
|||||||
};
|
};
|
||||||
}, [renderHtml]);
|
}, [renderHtml]);
|
||||||
|
|
||||||
|
const handleToggleVersionForCompare = (versionId: string) => {
|
||||||
|
setSelectedVersionsForCompare(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(versionId)) {
|
||||||
|
next.delete(versionId);
|
||||||
|
} else {
|
||||||
|
if (next.size >= 3) {
|
||||||
|
return prev; // Do not allow selecting more than 3
|
||||||
|
}
|
||||||
|
next.add(versionId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCompareVersions = async () => {
|
||||||
|
if (selectedVersionsForCompare.size < 1) {
|
||||||
|
alert("Vui lòng chọn ít nhất 1 phiên bản để so sánh.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsComparing(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const versionsToFetch = Array.from(selectedVersionsForCompare);
|
||||||
|
const promises = versionsToFetch.map(async (versionId) => {
|
||||||
|
const sample = allVersions.find(s => s.id === versionId);
|
||||||
|
const versionInfo = {
|
||||||
|
id: versionId,
|
||||||
|
createdAt: sample?.created_at || 'Unknown date',
|
||||||
|
title: `Phiên bản lúc ${formatDate(sample?.created_at)}`
|
||||||
|
};
|
||||||
|
if (sample?.isCurrent) {
|
||||||
|
return { ...versionInfo, content: sample.content || '' };
|
||||||
|
}
|
||||||
|
const contentResp = await getContentByVersionWikiId(versionId);
|
||||||
|
return { ...versionInfo, content: contentResp?.data?.content || "" };
|
||||||
|
});
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
const processedResults = results.map(r => {
|
||||||
|
const { html } = rewriteHtmlAndBuildToc(normalizeWikiContentToHtml(r.content), `${window.location.origin}/wiki/`);
|
||||||
|
return { ...r, content: html };
|
||||||
|
});
|
||||||
|
setComparisonData(processedResults.sort((a,b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()));
|
||||||
|
setViewMode("compare");
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof ApiError ? err.message : err instanceof Error ? err.message : "Lỗi khi tải phiên bản để so sánh.";
|
||||||
|
setError(msg);
|
||||||
|
setViewMode("read");
|
||||||
|
} finally {
|
||||||
|
setIsComparing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white text-gray-900 dark:bg-gray-950 dark:text-gray-100">
|
<div className="min-h-screen bg-[#f8f9fa] text-[#202122] font-sans">
|
||||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
<header className="bg-white border-b border-gray-300 px-6 py-2 flex justify-between items-center">
|
||||||
<div className="mb-5 flex items-start justify-between gap-4">
|
<div className="text-lg font-bold">GeoHistory Wiki</div>
|
||||||
<div className="min-w-0">
|
<Link href="/" className="text-sm text-blue-600 hover:underline">Trang chủ</Link>
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">Wiki</div>
|
</header>
|
||||||
<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">
|
<div className={viewMode === 'compare' ? '' : 'mx-auto max-w-7xl px-4 sm:px-6 py-6'}>
|
||||||
<Link
|
{status === "loading" && <div className="text-center p-10">Đang tải...</div>}
|
||||||
href="/"
|
{status === "error" && <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">{error}</div>}
|
||||||
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"
|
{status === "ready" && !wiki && <div className="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded relative">Không tìm thấy wiki với slug: <strong>{normalizedSlug}</strong></div>}
|
||||||
>
|
|
||||||
Home
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{status === "loading" ? (
|
{status === "ready" && wiki && (
|
||||||
<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={viewMode === 'compare' ? 'mx-auto max-w-7xl px-4 sm:px-6 py-6' : ''}>
|
||||||
<div className="mt-3 h-4 w-2/3 rounded bg-gray-100 dark:bg-white/[0.06] animate-pulse" />
|
<h1 className="text-3xl pb-2 mb-1">
|
||||||
</div>
|
{wiki.title?.trim() || normalizedSlug}
|
||||||
) : status === "error" ? (
|
</h1>
|
||||||
<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">
|
{viewMode === 'compare' && (
|
||||||
{error || "Failed to load wiki."}
|
<div className="mt-4 p-3 border border-gray-300 bg-white rounded-sm text-xs space-y-1">
|
||||||
</div>
|
<div><span className="font-semibold">Slug:</span> {normalizedSlug || "-"}</div>
|
||||||
) : wiki == null ? (
|
<div><span className="font-semibold">ID:</span> {wiki.id || "-"}</div>
|
||||||
<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">
|
<div><span className="font-semibold">Dự án:</span> {wiki.project_id || "-"}</div>
|
||||||
Không tìm thấy wiki với slug: <span className="font-semibold break-all">{normalizedSlug}</span>
|
<div><span className="font-semibold">Tạo lúc:</span> {formatDate(wiki.created_at)}</div>
|
||||||
</div>
|
<div><span className="font-semibold">Cập nhật:</span> {formatDate(wiki.updated_at)}</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 có 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>
|
||||||
</div>
|
)}
|
||||||
</aside>
|
</div>
|
||||||
|
|
||||||
<main className="min-w-0">
|
<div className={`grid grid-cols-1 ${viewMode === 'compare' ? '' : 'lg:grid-cols-[minmax(0,1fr)_auto] gap-8 items-start'}`}>
|
||||||
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 overflow-hidden">
|
<main className={`min-w-0 bg-white ${viewMode === 'compare' ? 'border-y border-gray-300' : 'border border-gray-300 rounded-sm'}`}>
|
||||||
<div
|
<div className={`flex border-b border-gray-300 text-sm ${viewMode === 'compare' ? 'mx-auto max-w-7xl px-4 sm:px-6' : ''}`}>
|
||||||
ref={contentRootRef}
|
<button onClick={() => setViewMode('read')} className={`px-4 py-2 ${viewMode === 'read' ? 'border-b-2 border-blue-600 text-blue-700' : 'text-gray-600'}`}>Bài viết</button>
|
||||||
className="uhm-wiki-view ql-editor text-sm text-gray-900 dark:text-gray-100"
|
<button onClick={() => setViewMode('history')} className={`px-4 py-2 ${viewMode === 'history' || viewMode === 'compare' ? 'border-b-2 border-blue-600 text-blue-700' : 'text-gray-600'}`}>Xem lịch sử</button>
|
||||||
dangerouslySetInnerHTML={{ __html: renderHtml }}
|
</div>
|
||||||
/>
|
|
||||||
</div>
|
{viewMode === 'read' && (
|
||||||
</main>
|
<div ref={contentRootRef} className="uhm-wiki-view ql-editor wiki-article" dangerouslySetInnerHTML={{ __html: renderHtml }} />
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{viewMode === 'history' && (
|
||||||
|
<div className="p-4">
|
||||||
|
<h2 className="text-xl mb-4 font-normal">Lịch sử phiên bản của "{wiki.title}"</h2>
|
||||||
|
<div className="flex gap-4 items-center mb-4">
|
||||||
|
<button onClick={handleCompareVersions} disabled={isComparing || selectedVersionsForCompare.size === 0} className="px-4 py-2 text-sm bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-300">
|
||||||
|
{isComparing ? 'Đang tải...' : `So sánh ${selectedVersionsForCompare.size} phiên bản đã chọn`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="border rounded-md overflow-hidden">
|
||||||
|
<table className="w-full text-sm text-left">
|
||||||
|
<thead className="bg-gray-100">
|
||||||
|
<tr>
|
||||||
|
<th className="p-2 w-16 text-center">So sánh</th>
|
||||||
|
<th className="p-2">Ngày cập nhật</th>
|
||||||
|
<th className="p-2">Ghi chú</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{allVersions.map((v) => {
|
||||||
|
const isChecked = selectedVersionsForCompare.has(v.id!);
|
||||||
|
const isDisabled = !isChecked && selectedVersionsForCompare.size >= 3;
|
||||||
|
return (
|
||||||
|
<tr key={v.id} className={`border-t ${isDisabled ? "opacity-50" : ""}`}>
|
||||||
|
<td className="p-2 text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
onChange={() => handleToggleVersionForCompare(v.id!)}
|
||||||
|
checked={isChecked}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className="h-4 w-4 disabled:cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-blue-600">{formatDate(v.created_at)}</td>
|
||||||
|
<td className="p-2">{v.isCurrent && <span className="font-bold">(Phiên bản hiện tại)</span>}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewMode === 'compare' && (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="mx-auto max-w-7xl px-4 sm:px-6">
|
||||||
|
<h2 className="text-xl mb-4 font-normal">So sánh các phiên bản</h2>
|
||||||
|
</div>
|
||||||
|
<div className={`grid grid-cols-1 md:grid-cols-2 gap-4 ${comparisonData.length >= 3 ? 'xl:grid-cols-3' : ''} mx-auto px-4 sm:px-6`}>
|
||||||
|
{comparisonData.map(version => (
|
||||||
|
<div key={version.id} className="border rounded-lg overflow-hidden bg-white">
|
||||||
|
<h3 className="p-2 border-b font-semibold bg-gray-50 text-sm">{version.title}</h3>
|
||||||
|
<div className="uhm-wiki-view ql-editor wiki-article h-[70vh] overflow-auto" dangerouslySetInnerHTML={{ __html: version.content }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{viewMode !== 'compare' && (
|
||||||
|
<aside className="hidden lg:block self-start sticky top-6">
|
||||||
|
{viewMode === 'read' && toc.length > 0 && (
|
||||||
|
<div className="border border-gray-300 bg-[#f8f9fa] p-3 rounded-sm text-sm mb-6">
|
||||||
|
<p className="font-bold text-center mb-2">Mục lục</p>
|
||||||
|
<nav>
|
||||||
|
<div className="grid gap-1 w-full overflow-auto">
|
||||||
|
{toc.map((t) => {
|
||||||
|
const pad = Math.max(0, Math.min(5, t.level - 1)) * 12;
|
||||||
|
const isActive = activeHeadingId === t.id;
|
||||||
|
return (
|
||||||
|
<a key={t.id} href={`#${t.id}`} className={`block py-0.5 text-xs leading-5 transition break-words ${isActive ? "font-bold" : "text-blue-600 hover:underline"}`} style={{ paddingLeft: pad }} title={t.text}>
|
||||||
|
<span className="mr-1">{t.level}.</span>{t.text}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="border border-gray-300 bg-white rounded-sm text-xs overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-b border-gray-100 last:border-0">
|
||||||
|
<td className="px-2 py-2 font-normal text-gray-500 w-1/5">Slug</td>
|
||||||
|
<td className="px-2 py-2 text-gray-900 break-all">{normalizedSlug || "-"}</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-gray-100 last:border-0">
|
||||||
|
<td className="px-2 py-2 font-normal text-gray-500">ID</td>
|
||||||
|
<td className="px-2 py-2 text-gray-900">{wiki.id || "-"}</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-gray-100 last:border-0">
|
||||||
|
<td className="px-2 py-2 font-normal text-gray-500">Dự án</td>
|
||||||
|
<td className="px-2 py-2 text-gray-900">{wiki.project_id || "-"}</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-gray-100 last:border-0">
|
||||||
|
<td className="px-2 py-2 font-normal text-gray-500">Tạo lúc</td>
|
||||||
|
<td className="px-2 py-2 text-gray-900">{formatDate(wiki.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="pr-1 pl-2 py-2 font-normal text-gray-500">Cập nhật</td>
|
||||||
|
<td className="px-2 py-2 text-gray-900">{formatDate(wiki.updated_at)}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -583,107 +728,94 @@ export default function WikiBySlugClient({ slug }: { slug: string }) {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<style jsx global>{`
|
<style jsx global>{`
|
||||||
/* Quill view container tweaks: allow page-level scrolling instead of inner scroll. */
|
.wiki-article {
|
||||||
|
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 1em;
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
.uhm-wiki-view.ql-editor {
|
.uhm-wiki-view.ql-editor {
|
||||||
height: auto;
|
height: auto;
|
||||||
overflow-y: visible;
|
overflow-y: visible;
|
||||||
padding: 18px 20px;
|
|
||||||
}
|
}
|
||||||
/* Improve readability for view mode (Quill resets block margins to 0). */
|
.wiki-article p {
|
||||||
.uhm-wiki-view.ql-editor p {
|
|
||||||
margin: 0 0 0.75em;
|
margin: 0 0 0.75em;
|
||||||
}
|
}
|
||||||
.uhm-wiki-view.ql-editor h1 {
|
.wiki-article h1,
|
||||||
margin: 1.25em 0 0.6em;
|
.wiki-article h2,
|
||||||
font-size: 1.9em;
|
.wiki-article h3,
|
||||||
font-weight: 800;
|
.wiki-article h4,
|
||||||
|
.wiki-article h5,
|
||||||
|
.wiki-article h6 {
|
||||||
|
|
||||||
|
font-weight: normal;
|
||||||
|
margin: 0.8em 0 0.3em;
|
||||||
|
padding-bottom: 0.1em;
|
||||||
|
border-bottom: 1px solid #a2a9b1;
|
||||||
|
scroll-margin-top: 16px;
|
||||||
|
}
|
||||||
|
.wiki-article h1 {
|
||||||
|
font-size: 1.8em;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
.uhm-wiki-view.ql-editor h2 {
|
.wiki-article h2 {
|
||||||
margin: 1.15em 0 0.55em;
|
font-size: 1.5em;
|
||||||
font-size: 1.55em;
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
|
margin-top: 1.4em;
|
||||||
}
|
}
|
||||||
.uhm-wiki-view.ql-editor h3 {
|
.wiki-article h3 {
|
||||||
margin: 1.05em 0 0.5em;
|
|
||||||
font-size: 1.25em;
|
font-size: 1.25em;
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
.uhm-wiki-view.ql-editor h4,
|
.wiki-article h4,
|
||||||
.uhm-wiki-view.ql-editor h5,
|
.wiki-article h5,
|
||||||
.uhm-wiki-view.ql-editor h6 {
|
.wiki-article h6 {
|
||||||
margin: 0.95em 0 0.45em;
|
|
||||||
font-size: 1.05em;
|
font-size: 1.05em;
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
.uhm-wiki-view.ql-editor ul,
|
.wiki-article ul,
|
||||||
.uhm-wiki-view.ql-editor ol {
|
.wiki-article ol {
|
||||||
margin: 0 0 0.75em;
|
margin: 0 0 0.75em;
|
||||||
padding-left: 1.5em;
|
padding-left: 1.5em;
|
||||||
}
|
}
|
||||||
.uhm-wiki-view.ql-editor blockquote {
|
.wiki-article blockquote {
|
||||||
margin: 0 0 0.75em;
|
margin: 0 0 0.75em;
|
||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
border-left: 3px solid rgba(148, 163, 184, 0.6);
|
border-left: 3px solid #a2a9b1;
|
||||||
color: rgba(71, 85, 105, 1);
|
color: #202122;
|
||||||
}
|
}
|
||||||
:is(.dark *) .uhm-wiki-view.ql-editor blockquote {
|
.wiki-article pre {
|
||||||
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;
|
margin: 0 0 0.75em;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
border: 1px solid rgba(226, 232, 240, 1);
|
border: 1px solid #a2a9b1;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: rgba(248, 250, 252, 1);
|
background: #f8f9fa;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
:is(.dark *) .uhm-wiki-view.ql-editor pre {
|
.wiki-article img {
|
||||||
border-color: rgba(51, 65, 85, 1);
|
|
||||||
background: rgba(2, 6, 23, 0.4);
|
|
||||||
}
|
|
||||||
.uhm-wiki-view.ql-editor img {
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
.uhm-wiki-view.ql-editor h1,
|
.wiki-article a {
|
||||||
.uhm-wiki-view.ql-editor h2,
|
text-decoration: none;
|
||||||
.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 {
|
.wiki-article a[href]:not([href=""]):not([href="__missing__"]) {
|
||||||
|
color: #3366cc;
|
||||||
|
}
|
||||||
|
.wiki-article a[href]:not([href=""]):not([href="__missing__"]):hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-decoration-thickness: from-font;
|
|
||||||
text-underline-offset: 2px;
|
|
||||||
}
|
}
|
||||||
.uhm-wiki-view.ql-editor a[href]:not([href=""]):not([href="__missing__"]) {
|
.wiki-article a[href="__missing__"] {
|
||||||
color: #2563eb;
|
|
||||||
}
|
|
||||||
:is(.dark *) .uhm-wiki-view.ql-editor a[href]:not([href=""]):not([href="__missing__"]) {
|
|
||||||
color: #60a5fa;
|
|
||||||
}
|
|
||||||
.uhm-wiki-view.ql-editor a[href="__missing__"] {
|
|
||||||
cursor: default;
|
cursor: default;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.uhm-wiki-view.ql-editor a:not([href]),
|
.wiki-article a:not([href]),
|
||||||
.uhm-wiki-view.ql-editor a[href=""],
|
.wiki-article a[href=""],
|
||||||
.uhm-wiki-view.ql-editor a[href="__missing__"] {
|
.wiki-article a[href="__missing__"] {
|
||||||
color: #dc2626;
|
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>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const API_ENDPOINTS = {
|
|||||||
geometries: `${API_BASE_URL}/geometries`,
|
geometries: `${API_BASE_URL}/geometries`,
|
||||||
entities: `${API_BASE_URL}/entities`,
|
entities: `${API_BASE_URL}/entities`,
|
||||||
wikis: `${API_BASE_URL}/wikis`,
|
wikis: `${API_BASE_URL}/wikis`,
|
||||||
|
wikiContent: (id: string) => `${API_BASE_URL}/wikis/content/${id}`,
|
||||||
// New API uses projects + commits + submissions (JWT-protected).
|
// New API uses projects + commits + submissions (JWT-protected).
|
||||||
authSignin: `${API_BASE_URL}/auth/signin`,
|
authSignin: `${API_BASE_URL}/auth/signin`,
|
||||||
authRefresh: `${API_BASE_URL}/auth/refresh`,
|
authRefresh: `${API_BASE_URL}/auth/refresh`,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import api from "@/config/config";
|
||||||
import { API_ENDPOINTS } from "@/uhm/api/config";
|
import { API_ENDPOINTS } from "@/uhm/api/config";
|
||||||
import { ApiError, requestJson } from "@/uhm/api/http";
|
import { ApiError, requestJson } from "@/uhm/api/http";
|
||||||
|
|
||||||
@@ -10,6 +11,11 @@ export type Wiki = {
|
|||||||
is_deleted?: boolean;
|
is_deleted?: boolean;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
|
content_sample?:{
|
||||||
|
created_at?: string;
|
||||||
|
content?: string;
|
||||||
|
id?: string;
|
||||||
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function searchWikisByTitle(title: string, options?: { limit?: number; cursor?: string; entityId?: string }): Promise<Wiki[]> {
|
export async function searchWikisByTitle(title: string, options?: { limit?: number; cursor?: string; entityId?: string }): Promise<Wiki[]> {
|
||||||
@@ -60,3 +66,8 @@ export async function checkWikiSlugExists(slug: string): Promise<boolean> {
|
|||||||
// Be conservative: unknown payload shape, treat as "exists" to prevent creating conflicting slugs.
|
// Be conservative: unknown payload shape, treat as "exists" to prevent creating conflicting slugs.
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getContentByVersionWikiId = async (id: string) => {
|
||||||
|
const response = await api.get(API_ENDPOINTS.wikiContent(id));
|
||||||
|
return response?.data;
|
||||||
|
};
|
||||||
@@ -28,6 +28,7 @@ type QuillLike = {
|
|||||||
type QuillModule = {
|
type QuillModule = {
|
||||||
Quill?: {
|
Quill?: {
|
||||||
import?: (path: string) => unknown;
|
import?: (path: string) => unknown;
|
||||||
|
register?: (pathOrModule: unknown, moduleOrOverwrite?: unknown, overwrite?: boolean) => void;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
type QuillLinkFormat = {
|
type QuillLinkFormat = {
|
||||||
@@ -114,11 +115,38 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
|
|||||||
const BlotFormatterModule = await import("quill-blot-formatter");
|
const BlotFormatterModule = await import("quill-blot-formatter");
|
||||||
const BlotFormatter = BlotFormatterModule.default;
|
const BlotFormatter = BlotFormatterModule.default;
|
||||||
// Only register if not already registered to avoid errors in dev/HMR
|
// Only register if not already registered to avoid errors in dev/HMR
|
||||||
Quill.register("modules/blotFormatter", BlotFormatter, true);
|
Quill.register?.("modules/blotFormatter", BlotFormatter, true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load quill-blot-formatter", err);
|
console.error("Failed to load quill-blot-formatter", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ImageFormat = Quill.import?.("formats/image") as any;
|
||||||
|
if (ImageFormat) {
|
||||||
|
class CustomImage extends ImageFormat {
|
||||||
|
static formats(domNode: Element) {
|
||||||
|
const formats = ImageFormat.formats(domNode) || {};
|
||||||
|
if (domNode.hasAttribute("style")) formats.style = domNode.getAttribute("style");
|
||||||
|
if (domNode.hasAttribute("width")) formats.width = domNode.getAttribute("width");
|
||||||
|
if (domNode.hasAttribute("height")) formats.height = domNode.getAttribute("height");
|
||||||
|
if (domNode.hasAttribute("class")) formats.class = domNode.getAttribute("class");
|
||||||
|
return formats;
|
||||||
|
}
|
||||||
|
|
||||||
|
format(name: string, value: string) {
|
||||||
|
if (["style", "width", "height", "class"].includes(name)) {
|
||||||
|
if (value) {
|
||||||
|
this.domNode.setAttribute(name, value);
|
||||||
|
} else {
|
||||||
|
this.domNode.removeAttribute(name);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
super.format(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Quill.register?.(CustomImage, true);
|
||||||
|
}
|
||||||
|
|
||||||
const Link = Quill.import?.("formats/link");
|
const Link = Quill.import?.("formats/link");
|
||||||
if (!Link) return;
|
if (!Link) return;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user