configuration wiki editor link to bind with slug

This commit is contained in:
taDuc
2026-05-10 01:28:41 +07:00
parent 655454d83a
commit 78824ed07a
3 changed files with 360 additions and 14 deletions
+28
View File
@@ -90,6 +90,22 @@ async function requestJsonInternal<T>(
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<T>(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<unknown> |
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 {
+14 -1
View File
@@ -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<Wiki> {
return requestJson<Wiki>(`${API_ENDPOINTS.wikis}/${encodeURIComponent(wikiId)}`);
}
export async function fetchWikiBySlug(slug: string): Promise<Wiki | null> {
const value = String(slug || "").trim();
if (!value.length) return null;
try {
return await requestJson<Wiki>(`${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<boolean> {
const value = String(slug || "").trim();
if (!value.length) return false;
+312 -7
View File
@@ -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<typeof ReactQuill>;
@@ -50,6 +50,23 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
const [createError, setCreateError] = useState<string | null>(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<string | null>(null);
const [wikiLinkSearchMode, setWikiLinkSearchMode] = useState<"title" | "slug">("title");
const [globalWikiResults, setGlobalWikiResults] = useState<Wiki[]>([]);
const [isGlobalWikiSearching, setIsGlobalWikiSearching] = useState(false);
const [globalWikiSearchError, setGlobalWikiSearchError] = useState<string | null>(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<WikiLinkOption[]>(() => {
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<WikiLinkOption[]>(() => {
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<WikiLinkOption[]>(() => {
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 (
<div
style={{
@@ -496,7 +710,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
theme="snow"
value={wikiDocHtml}
onChange={(content: string) => 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,
</div>
</div>
</Modal>
<Modal
isOpen={isWikiLinkOpen}
onClose={closeWikiLinkModal}
className="max-w-[620px] p-6"
>
<div className="grid gap-4">
<div>
<div className="text-base font-semibold text-gray-900 dark:text-gray-100">Link wiki</div>
</div>
<div>
<Label>Search</Label>
<div className="flex items-center gap-2">
<input
value={wikiLinkQuery}
onChange={(e) => 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
/>
<select
value={wikiLinkSearchMode}
onChange={(e) => setWikiLinkSearchMode(e.target.value === "slug" ? "slug" : "title")}
className="h-11 rounded-xl border border-gray-200 bg-transparent px-3 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"
aria-label="Search mode"
>
<option value="title">Title</option>
<option value="slug">Slug</option>
</select>
</div>
{wikiLinkError ? (
<div className="mt-2 text-xs text-red-600 dark:text-red-300">{wikiLinkError}</div>
) : null}
{globalWikiSearchError ? (
<div className="mt-2 text-xs text-red-600 dark:text-red-300">{globalWikiSearchError}</div>
) : null}
</div>
<div className="max-h-[320px] overflow-auto rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117]">
<div className="p-2 grid gap-1">
{isGlobalWikiSearching ? (
<div className="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">Searching</div>
) : null}
{wikiLinkCandidates.map((w) => (
<button
key={w.key}
type="button"
onClick={() => applyWikiLink(w)}
className="w-full text-left rounded-lg border border-transparent hover:border-gray-200 dark:hover:border-gray-800 hover:bg-gray-50 dark:hover:bg-white/[0.03] px-3 py-2 transition"
title={w.slug || undefined}
>
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{(w.title || "").trim() || "Untitled wiki"}
</div>
<div className="text-[11px] text-gray-500 dark:text-gray-400 truncate">
{String(w.slug)}
</div>
</div>
<span
className={`text-[11px] font-semibold px-2 py-0.5 rounded-full border ${
w.source === "local"
? "border-emerald-300/60 text-emerald-600 dark:text-emerald-300"
: "border-blue-300/60 text-blue-600 dark:text-blue-300"
}`}
>
{w.source}
</span>
</div>
</button>
))}
{wikiLinkCandidates.length === 0 ? (
<div className="px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
Khong tim thay wiki phu hop (hoac cac wiki khac chua co slug).
</div>
) : null}
</div>
</div>
<div className="flex items-center justify-end gap-2">
{wikiLinkIntentRef.current?.existingHref ? (
<Button size="sm" variant="outline" onClick={removeWikiLink}>
Remove link
</Button>
) : null}
<Button size="sm" variant="outline" onClick={closeWikiLinkModal}>
Cancel
</Button>
</div>
</div>
</Modal>
</div>
);
}
@@ -539,16 +846,14 @@ function CloseIcon() {
);
}
const QUILL_MODULES = {
toolbar: [
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();