diff --git a/src/uhm/api/http.ts b/src/uhm/api/http.ts index bfeef03..bba5a11 100644 --- a/src/uhm/api/http.ts +++ b/src/uhm/api/http.ts @@ -90,6 +90,22 @@ async function requestJsonInternal( envelope.status === "error"; if (isError) { const message = extractErrorMessage(payload, envelope) || "Request failed"; + + // Some backends return 200 with {status:false,message:"Invalid or expired JWT"} instead of HTTP 401. + // In that case, try refresh + retry once to keep UX smooth. + if ( + !options?.skipRefresh && + !options?.skipAuth && + typeof input === "string" && + !String(input).includes("/auth/") && + isAuthTokenExpiredMessage(message) + ) { + const refreshed = await tryRefreshTokens(); + if (refreshed) { + return requestJsonInternal(input, init, { ...(options || {}), skipRefresh: true }); + } + } + throw new ApiError(message, res.status, stringifyPayload(envelope), normalizeErrors(envelope.errors)); } return (envelope.data ?? null) as T; @@ -131,6 +147,18 @@ function extractErrorMessage(payload: unknown, envelope: ApiEnvelope | return null; } +function isAuthTokenExpiredMessage(message: string): boolean { + const normalized = message.trim().toLowerCase(); + if (!normalized) return false; + return ( + normalized.includes("invalid or expired jwt") || + normalized.includes("jwt expired") || + normalized.includes("token expired") || + normalized.includes("invalid token") || + normalized.includes("expired token") + ); +} + function stringifyPayload(payload: unknown): string { if (typeof payload === "string") return payload; try { diff --git a/src/uhm/api/wikis.ts b/src/uhm/api/wikis.ts index 2cbf31a..ff448d0 100644 --- a/src/uhm/api/wikis.ts +++ b/src/uhm/api/wikis.ts @@ -1,9 +1,10 @@ import { API_ENDPOINTS } from "@/uhm/api/config"; -import { requestJson } from "@/uhm/api/http"; +import { ApiError, requestJson } from "@/uhm/api/http"; export type Wiki = { id: string; title?: string; + slug?: string | null; content?: string; is_deleted?: boolean; created_at?: string; @@ -28,6 +29,18 @@ export async function fetchWikiById(id: string): Promise { return requestJson(`${API_ENDPOINTS.wikis}/${encodeURIComponent(wikiId)}`); } +export async function fetchWikiBySlug(slug: string): Promise { + const value = String(slug || "").trim(); + if (!value.length) return null; + try { + return await requestJson(`${API_ENDPOINTS.wikis}/slug/${encodeURIComponent(value)}`); + } catch (err) { + // Treat "not found" as an empty result for search UX. + if (err instanceof ApiError && err.status === 404) return null; + throw err; + } +} + export async function checkWikiSlugExists(slug: string): Promise { const value = String(slug || "").trim(); if (!value.length) return false; diff --git a/src/uhm/components/WikiSidebarPanel.tsx b/src/uhm/components/WikiSidebarPanel.tsx index fb83a94..7683476 100644 --- a/src/uhm/components/WikiSidebarPanel.tsx +++ b/src/uhm/components/WikiSidebarPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState, type ComponentProps } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react"; import dynamic from "next/dynamic"; import "react-quill-new/dist/quill.snow.css"; @@ -11,7 +11,7 @@ import Label from "@/components/form/Label"; import type { WikiSnapshot } from "@/uhm/types/wiki"; import { newId } from "@/uhm/lib/id"; import type ReactQuill from "react-quill-new"; -import { checkWikiSlugExists } from "@/uhm/api/wikis"; +import { checkWikiSlugExists, fetchWikiBySlug, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis"; type ReactQuillProps = ComponentProps; @@ -50,6 +50,23 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen, const [createError, setCreateError] = useState(null); const [isCheckingCreateSlug, setIsCheckingCreateSlug] = useState(false); + // Quill: custom link UI (link-to-wiki by slug). + const wikiLinkIntentRef = useRef<{ + quill: any; + range: { index: number; length: number } | null; + activeWikiId: string | null; + existingHref: string | null; + } | null>(null); + const wikiLinkHandlerRef = useRef<(quill: any) => void>(() => {}); + const [isWikiLinkOpen, setIsWikiLinkOpen] = useState(false); + const [wikiLinkQuery, setWikiLinkQuery] = useState(""); + const [wikiLinkError, setWikiLinkError] = useState(null); + const [wikiLinkSearchMode, setWikiLinkSearchMode] = useState<"title" | "slug">("title"); + const [globalWikiResults, setGlobalWikiResults] = useState([]); + const [isGlobalWikiSearching, setIsGlobalWikiSearching] = useState(false); + const [globalWikiSearchError, setGlobalWikiSearchError] = useState(null); + const globalWikiSearchRequestRef = useRef(0); + useEffect(() => { if (!autoOpen) return; // open once on mount @@ -193,6 +210,203 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen, setOpen(false); }; + const closeWikiLinkModal = useCallback(() => { + setIsWikiLinkOpen(false); + setWikiLinkQuery(""); + setWikiLinkError(null); + setGlobalWikiResults([]); + setIsGlobalWikiSearching(false); + setGlobalWikiSearchError(null); + wikiLinkIntentRef.current = null; + }, []); + + type WikiLinkOption = { + key: string; + title: string; + slug: string; + source: "local" | "global"; + }; + + const localWikiLinkCandidates = useMemo(() => { + if (!isWikiLinkOpen) return []; + const q = wikiLinkQuery.trim().toLowerCase(); + const active = wikiLinkIntentRef.current?.activeWikiId ?? activeId; + + const base = (wikis || []) + .filter((w) => w && typeof w.id === "string") + .filter((w) => w.id !== active) + // Link value must be slug. + .filter((w) => typeof w.slug === "string" && w.slug.trim().length > 0); + + const filtered = (() => { + if (!q.length) return base; + if (wikiLinkSearchMode === "slug") { + return base.filter((w) => String(w.slug || "").toLowerCase().includes(q)); + } + return base.filter((w) => (w.title || "").toLowerCase().includes(q)); + })(); + + return filtered.slice(0, 20).map((w) => ({ + key: `local:${w.id}`, + title: (w.title || "").trim() || "Untitled wiki", + slug: String(w.slug).trim(), + source: "local", + })); + }, [activeId, isWikiLinkOpen, wikiLinkQuery, wikiLinkSearchMode, wikis]); + + useEffect(() => { + if (!isWikiLinkOpen) return; + + const keyword = wikiLinkQuery.trim(); + if (!keyword.length) { + setGlobalWikiResults([]); + setIsGlobalWikiSearching(false); + setGlobalWikiSearchError(null); + return; + } + + let disposed = false; + const requestId = ++globalWikiSearchRequestRef.current; + const timeoutId = window.setTimeout(async () => { + setIsGlobalWikiSearching(true); + setGlobalWikiSearchError(null); + try { + const rows = + wikiLinkSearchMode === "slug" + ? (() => fetchWikiBySlug(keyword))() + : (() => searchWikisByTitle(keyword, { limit: 12 }))(); + + const resolved = await rows; + if (disposed || requestId !== globalWikiSearchRequestRef.current) return; + + const list = Array.isArray(resolved) ? resolved : resolved ? [resolved] : []; + setGlobalWikiResults(list); + } catch (err) { + if (disposed || requestId !== globalWikiSearchRequestRef.current) return; + console.error("Search global wikis failed", err); + setGlobalWikiResults([]); + setGlobalWikiSearchError("Khong search duoc wiki tren server."); + } finally { + if (!disposed && requestId === globalWikiSearchRequestRef.current) { + setIsGlobalWikiSearching(false); + } + } + }, 260); + + return () => { + disposed = true; + window.clearTimeout(timeoutId); + }; + }, [isWikiLinkOpen, wikiLinkQuery, wikiLinkSearchMode]); + + const globalWikiLinkCandidates = useMemo(() => { + if (!isWikiLinkOpen) return []; + const active = wikiLinkIntentRef.current?.activeWikiId ?? activeId; + const activeSlug = (wikis || []).find((w) => w.id === active)?.slug ?? null; + const normalizedActiveSlug = typeof activeSlug === "string" ? activeSlug.trim() : ""; + + const out: WikiLinkOption[] = []; + for (const row of globalWikiResults || []) { + const slug = typeof row?.slug === "string" ? row.slug.trim() : ""; + if (!slug.length) continue; + if (normalizedActiveSlug && slug === normalizedActiveSlug) continue; + out.push({ + key: `global:${row.id || slug}`, + title: (row.title || "").trim() || "Untitled wiki", + slug, + source: "global", + }); + } + return out.slice(0, 20); + }, [activeId, globalWikiResults, isWikiLinkOpen, wikis]); + + const wikiLinkCandidates = useMemo(() => { + const localSlugs = new Set(localWikiLinkCandidates.map((w) => w.slug)); + const dedupedGlobal = globalWikiLinkCandidates.filter((w) => !localSlugs.has(w.slug)); + return [...localWikiLinkCandidates, ...dedupedGlobal]; + }, [globalWikiLinkCandidates, localWikiLinkCandidates]); + + const applyWikiLink = useCallback((target: WikiLinkOption) => { + const intent = wikiLinkIntentRef.current; + const quill = intent?.quill; + if (!quill) return; + + const slug = target.slug.trim(); + + const range = intent?.range ?? quill.getSelection?.() ?? null; + if (!range) { + setWikiLinkError("Khong lay duoc vi tri selection trong editor."); + return; + } + + // Restore selection to ensure format applies to the expected range. + quill.setSelection?.(range.index, range.length, "silent"); + + if (range.length > 0) { + quill.formatText?.(range.index, range.length, "link", slug, "user"); + closeWikiLinkModal(); + return; + } + + // No selection: insert the wiki title (or slug) and link it. + const label = (target.title || "").trim() || slug; + quill.insertText?.(range.index, label, { link: slug }, "user"); + quill.setSelection?.(range.index + label.length, 0, "silent"); + closeWikiLinkModal(); + }, [closeWikiLinkModal]); + + const removeWikiLink = useCallback(() => { + const intent = wikiLinkIntentRef.current; + const quill = intent?.quill; + if (!quill) return; + const range = intent?.range ?? quill.getSelection?.() ?? null; + if (!range) return; + quill.setSelection?.(range.index, range.length, "silent"); + if (range.length > 0) { + quill.formatText?.(range.index, range.length, "link", false, "user"); + } else { + quill.format?.("link", false, "user"); + } + closeWikiLinkModal(); + }, [closeWikiLinkModal]); + + // Keep handler ref updated while keeping modules object stable. + wikiLinkHandlerRef.current = (quill: any) => { + if (!quill) return; + const range = quill.getSelection?.() ?? null; + // Try to read current link format (if any) from the selection. + const existingHref = + range && (quill.getFormat?.(range)?.link ?? quill.getFormat?.(range.index, range.length)?.link) || null; + + wikiLinkIntentRef.current = { + quill, + range, + activeWikiId: activeId, + existingHref: typeof existingHref === "string" ? existingHref : null, + }; + + // Seed query with selected text (best effort). + const selectedText = + range && range.length > 0 ? String(quill.getText?.(range.index, range.length) || "").trim() : ""; + setWikiLinkQuery(selectedText.slice(0, 80)); + setWikiLinkError(null); + setIsWikiLinkOpen(true); + }; + + const quillModules = useMemo(() => { + return { + toolbar: { + container: QUILL_TOOLBAR, + handlers: { + // NOTE: use function() to preserve Quill toolbar `this` binding. + link: function (this: any) { + wikiLinkHandlerRef.current(this?.quill); + }, + }, + }, + }; + }, []); + return (
setWikiDocHtml(content)} - modules={QUILL_MODULES} + modules={quillModules} className="min-h-[320px]" placeholder="Nhap noi dung wiki..." readOnly={!activeId} @@ -511,6 +725,99 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
+ + +
+
+
Link wiki
+
+ +
+ +
+ setWikiLinkQuery(e.target.value)} + className="h-11 flex-1 min-w-0 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={wikiLinkSearchMode === "slug" ? "Nhap slug..." : "Nhap title wiki..."} + autoFocus + /> + +
+ {wikiLinkError ? ( +
{wikiLinkError}
+ ) : null} + {globalWikiSearchError ? ( +
{globalWikiSearchError}
+ ) : null} +
+ +
+
+ {isGlobalWikiSearching ? ( +
Searching…
+ ) : null} + {wikiLinkCandidates.map((w) => ( + + ))} + {wikiLinkCandidates.length === 0 ? ( +
+ Khong tim thay wiki phu hop (hoac cac wiki khac chua co slug). +
+ ) : null} +
+
+ +
+ {wikiLinkIntentRef.current?.existingHref ? ( + + ) : null} + +
+
+
); } @@ -539,16 +846,14 @@ function CloseIcon() { ); } -const QUILL_MODULES = { - toolbar: [ - [{ header: [1, 2, 3, false] }], - ["bold", "italic", "underline", "strike"], - [{ list: "ordered" }, { list: "bullet" }], - ["blockquote", "code-block"], - ["link", "image"], - ["clean"], - ], -}; +const QUILL_TOOLBAR = [ + [{ header: [1, 2, 3, false] }], + ["bold", "italic", "underline", "strike"], + [{ list: "ordered" }, { list: "bullet" }], + ["blockquote", "code-block"], + ["link", "image"], + ["clean"], +]; function normalizeWikiDocForQuill(doc: string | null): string { const raw = (doc || "").trim();