diff --git a/src/app/wiki/[slug]/wiki-by-slug-client.tsx b/src/app/wiki/[slug]/wiki-by-slug-client.tsx index 3b59091..a551f6e 100644 --- a/src/app/wiki/[slug]/wiki-by-slug-client.tsx +++ b/src/app/wiki/[slug]/wiki-by-slug-client.tsx @@ -5,7 +5,7 @@ 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"; +import { fetchWikiBySlug, getContentByVersionWikiId, type Wiki } from "@/uhm/api/wikis"; type TocItem = { id: string; @@ -141,18 +141,33 @@ function rewriteHtmlAndBuildToc(inputHtml: string, wikiBaseUrl: string): { html: 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(); if (!raw) return "-"; const d = new Date(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 }) { const [wiki, setWiki] = useState(null); const [status, setStatus] = useState<"idle" | "loading" | "error" | "ready">("idle"); const [error, setError] = useState(null); + + const [viewMode, setViewMode] = useState<"read" | "history" | "compare">("read"); + const [selectedVersionsForCompare, setSelectedVersionsForCompare] = useState>(new Set()); + const [comparisonData, setComparisonData] = useState<{ id: string; content: string; createdAt: string; title: string }[]>([]); + const [isComparing, setIsComparing] = useState(false); + const [renderHtml, setRenderHtml] = useState(""); const [toc, setToc] = useState([]); const [activeHeadingId, setActiveHeadingId] = useState(null); @@ -176,6 +191,21 @@ export default function WikiBySlugClient({ slug }: { slug: string }) { const hidePreviewTimerRef = useRef(null); const previewCacheRef = useRef>(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. useEffect(() => { const value = String(normalizedSlug || "").trim(); @@ -192,6 +222,18 @@ export default function WikiBySlugClient({ slug }: { slug: string }) { setError(null); try { 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 (!res) { setWiki(null); @@ -200,7 +242,7 @@ export default function WikiBySlugClient({ slug }: { slug: string }) { setToc([]); return; } - setWiki(res); + setWiki({ ...res, content: versionContent }); setStatus("ready"); } catch (err) { if (disposed) return; @@ -424,107 +466,210 @@ export default function WikiBySlugClient({ slug }: { slug: string }) { }; }, [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 ( -
-
-
-
-
Wiki
-

- {wiki?.title?.trim() || normalizedSlug || "Wiki"} -

-
- - Slug: {normalizedSlug || "-"} - - - ID: {wiki?.id || "-"} - - - Project:{" "} - {wiki?.project_id || "-"} - - - Created: {formatDate(wiki?.created_at)} - - - Updated: {formatDate(wiki?.updated_at)} - -
-
+
+
+
GeoHistory Wiki
+ Trang chủ +
-
- - Home - -
-
+
+ {status === "loading" &&
Đang tải...
} + {status === "error" &&
{error}
} + {status === "ready" && !wiki &&
Không tìm thấy wiki với slug: {normalizedSlug}
} - {status === "loading" ? ( -
-
-
-
- ) : status === "error" ? ( -
- {error || "Failed to load wiki."} -
- ) : wiki == null ? ( -
- Không tìm thấy wiki với slug: {normalizedSlug} -
- ) : ( -
- + )} +
-
-
-
-
-
-
+
+
+
+ + +
+ + {viewMode === 'read' && ( +
+ )} + + {viewMode === 'history' && ( +
+

Lịch sử phiên bản của "{wiki.title}"

+
+ +
+
+ + + + + + + + + + {allVersions.map((v) => { + const isChecked = selectedVersionsForCompare.has(v.id!); + const isDisabled = !isChecked && selectedVersionsForCompare.size >= 3; + return ( + + + + + + ); + })} + +
So sánhNgày cập nhậtGhi chú
+ handleToggleVersionForCompare(v.id!)} + checked={isChecked} + disabled={isDisabled} + className="h-4 w-4 disabled:cursor-not-allowed" + /> + {formatDate(v.created_at)}{v.isCurrent && (Phiên bản hiện tại)}
+
+
+ )} + + {viewMode === 'compare' && ( +
+
+

So sánh các phiên bản

+
+
= 3 ? 'xl:grid-cols-3' : ''} mx-auto px-4 sm:px-6`}> + {comparisonData.map(version => ( +
+

{version.title}

+
+
+ ))} +
+
+ )} +
+ + {viewMode !== 'compare' && ( + + )} +
+ )}
@@ -583,107 +728,94 @@ export default function WikiBySlugClient({ slug }: { slug: string }) { ) : null}
); diff --git a/src/uhm/api/config.ts b/src/uhm/api/config.ts index 06e9a29..90cc619 100644 --- a/src/uhm/api/config.ts +++ b/src/uhm/api/config.ts @@ -9,6 +9,7 @@ export const API_ENDPOINTS = { geometries: `${API_BASE_URL}/geometries`, entities: `${API_BASE_URL}/entities`, wikis: `${API_BASE_URL}/wikis`, + wikiContent: (id: string) => `${API_BASE_URL}/wikis/content/${id}`, // New API uses projects + commits + submissions (JWT-protected). authSignin: `${API_BASE_URL}/auth/signin`, authRefresh: `${API_BASE_URL}/auth/refresh`, diff --git a/src/uhm/api/wikis.ts b/src/uhm/api/wikis.ts index a3903dc..fc3f277 100644 --- a/src/uhm/api/wikis.ts +++ b/src/uhm/api/wikis.ts @@ -1,3 +1,4 @@ +import api from "@/config/config"; import { API_ENDPOINTS } from "@/uhm/api/config"; import { ApiError, requestJson } from "@/uhm/api/http"; @@ -10,6 +11,11 @@ export type Wiki = { is_deleted?: boolean; created_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 { @@ -60,3 +66,8 @@ export async function checkWikiSlugExists(slug: string): Promise { // Be conservative: unknown payload shape, treat as "exists" to prevent creating conflicting slugs. return true; } + +export const getContentByVersionWikiId = async (id: string) => { + const response = await api.get(API_ENDPOINTS.wikiContent(id)); + return response?.data; +}; \ No newline at end of file diff --git a/src/uhm/components/wiki/WikiSidebarPanel.tsx b/src/uhm/components/wiki/WikiSidebarPanel.tsx index 64cbf84..ddb7dac 100644 --- a/src/uhm/components/wiki/WikiSidebarPanel.tsx +++ b/src/uhm/components/wiki/WikiSidebarPanel.tsx @@ -28,6 +28,7 @@ type QuillLike = { type QuillModule = { Quill?: { import?: (path: string) => unknown; + register?: (pathOrModule: unknown, moduleOrOverwrite?: unknown, overwrite?: boolean) => void; }; }; type QuillLinkFormat = { @@ -114,11 +115,38 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen, const BlotFormatterModule = await import("quill-blot-formatter"); const BlotFormatter = BlotFormatterModule.default; // 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) { 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"); if (!Link) return;