diff --git a/src/app/wiki/[slug]/wiki-by-slug-client.tsx b/src/app/wiki/[slug]/wiki-by-slug-client.tsx index e39c2a5..3b59091 100644 --- a/src/app/wiki/[slug]/wiki-by-slug-client.tsx +++ b/src/app/wiki/[slug]/wiki-by-slug-client.tsx @@ -156,9 +156,25 @@ export default function WikiBySlugClient({ slug }: { slug: string }) { const [renderHtml, setRenderHtml] = useState(""); const [toc, setToc] = useState([]); const [activeHeadingId, setActiveHeadingId] = useState(null); + const [linkPreview, setLinkPreview] = useState<{ + slug: string; + top: number; + left: number; + width: number; + height: number; + visible: boolean; + } | null>(null); + const [linkPreviewData, setLinkPreviewData] = useState<{ + slug: string; + title: string; + quote: string | null; + status: "idle" | "loading" | "ready" | "error"; + } | null>(null); const normalizedSlug = useMemo(() => String(slug || "").trim(), [slug]); const contentRootRef = useRef(null); + const hidePreviewTimerRef = useRef(null); + const previewCacheRef = useRef>(new Map()); // Load wiki data by slug. useEffect(() => { @@ -256,6 +272,158 @@ export default function WikiBySlugClient({ slug }: { slug: string }) { return () => obs.disconnect(); }, [toc]); + // Hover preview for internal wiki links (title + first blockquote). + useEffect(() => { + const root = contentRootRef.current; + if (!root) return; + if (typeof window === "undefined") return; + + const clearHideTimer = () => { + if (hidePreviewTimerRef.current != null) { + window.clearTimeout(hidePreviewTimerRef.current); + hidePreviewTimerRef.current = null; + } + }; + + const hideSoon = () => { + clearHideTimer(); + hidePreviewTimerRef.current = window.setTimeout(() => { + setLinkPreview((prev) => (prev ? { ...prev, visible: false } : prev)); + }, 140); + }; + + const resolveInternalWikiSlug = (href: string): string | null => { + const h = href.trim(); + if (!h.length) return null; + if (h === "__missing__") return null; + if (h.startsWith("#")) return null; + + const stripQueryHash = (s: string) => { + const m = s.match(/^([^?#]+)([?#].*)?$/); + return String(m?.[1] || ""); + }; + + if (h.startsWith("/wiki/")) { + const path = stripQueryHash(h); + const slugPart = path.slice("/wiki/".length).trim(); + return slugPart ? decodeURIComponent(slugPart) : null; + } + + const originPrefix = window.location.origin + "/wiki/"; + if (h.startsWith(originPrefix)) { + const rest = stripQueryHash(h.slice(originPrefix.length)); + const slugPart = rest.trim(); + return slugPart ? decodeURIComponent(slugPart) : null; + } + + return null; + }; + + const fetchPreview = async (targetSlug: string) => { + const key = targetSlug.trim(); + if (!key.length) return; + + const cached = previewCacheRef.current.get(key); + if (cached) { + setLinkPreviewData({ slug: key, title: cached.title, quote: cached.quote, status: "ready" }); + return; + } + + setLinkPreviewData((prev) => ({ slug: key, title: prev?.title || key, quote: null, status: "loading" })); + try { + const row = await fetchWikiBySlug(key); + if (!row) { + setLinkPreviewData({ slug: key, title: key, quote: null, status: "error" }); + return; + } + + const html = normalizeWikiContentToHtml(row.content ?? ""); + let quote: string | null = null; + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + const bq = doc.body.querySelector("blockquote"); + const txt = String(bq?.textContent || "").trim(); + quote = txt.length ? txt : null; + } catch { + quote = null; + } + + const title = String(row.title || "").trim() || key; + previewCacheRef.current.set(key, { title, quote }); + setLinkPreviewData({ slug: key, title, quote, status: "ready" }); + } catch { + setLinkPreviewData({ slug: key, title: key, quote: null, status: "error" }); + } + }; + + const showForAnchor = (a: HTMLAnchorElement) => { + const href = String(a.getAttribute("href") || "").trim(); + const targetSlug = resolveInternalWikiSlug(href); + if (!targetSlug) return; + + // Avoid previews on touch devices. + if (window.matchMedia && window.matchMedia("(hover: none)").matches) return; + + const rect = a.getBoundingClientRect(); + const width = 420; + const height = 320; + const margin = 12; + + const preferredLeft = rect.right + margin; + const maxLeft = Math.max(margin, window.innerWidth - width - margin); + const left = Math.min(preferredLeft, maxLeft); + + const preferredTop = rect.top; + const maxTop = Math.max(margin, window.innerHeight - height - margin); + const top = Math.max(margin, Math.min(preferredTop, maxTop)); + + clearHideTimer(); + setLinkPreview({ slug: targetSlug, top, left, width, height, visible: true }); + void fetchPreview(targetSlug); + }; + + const onMouseOver = (evt: MouseEvent) => { + const target = evt.target as HTMLElement | null; + const a = target?.closest?.("a") as HTMLAnchorElement | null; + if (!a) return; + showForAnchor(a); + }; + + const onMouseOut = (evt: MouseEvent) => { + const target = evt.target as HTMLElement | null; + const related = evt.relatedTarget as HTMLElement | null; + const fromA = target?.closest?.("a"); + if (!fromA) return; + if (related && related.closest?.(".uhm-wiki-link-preview")) return; + hideSoon(); + }; + + const onKeyDown = (evt: KeyboardEvent) => { + if (evt.key === "Escape") { + clearHideTimer(); + setLinkPreview((prev) => (prev ? { ...prev, visible: false } : prev)); + } + }; + + const onScroll = () => { + setLinkPreview((prev) => (prev ? { ...prev, visible: false } : prev)); + }; + + root.addEventListener("mouseover", onMouseOver); + root.addEventListener("mouseout", onMouseOut); + window.addEventListener("keydown", onKeyDown); + window.addEventListener("scroll", onScroll, { passive: true }); + + return () => { + root.removeEventListener("mouseover", onMouseOver); + root.removeEventListener("mouseout", onMouseOut); + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("scroll", onScroll); + clearHideTimer(); + }; + }, [renderHtml]); + return (
@@ -360,6 +528,60 @@ export default function WikiBySlugClient({ slug }: { slug: string }) { )}
+ {linkPreview && linkPreview.visible ? ( +
{ + if (hidePreviewTimerRef.current != null) { + window.clearTimeout(hidePreviewTimerRef.current); + hidePreviewTimerRef.current = null; + } + }} + onMouseLeave={() => { + setLinkPreview((prev) => (prev ? { ...prev, visible: false } : prev)); + }} + > +
+
+
+
+ /wiki/{linkPreview.slug} +
+
+ {linkPreviewData?.slug === linkPreview.slug + ? linkPreviewData.status === "loading" + ? "Loading..." + : linkPreviewData.status === "error" + ? "Not found" + : linkPreviewData.title + : "Loading..."} +
+
+ +
+ {linkPreviewData?.slug === linkPreview.slug && linkPreviewData.status === "ready" ? ( + linkPreviewData.quote ? ( +
+ {linkPreviewData.quote} +
+ ) : ( +
No resume.
+ ) + ) : ( +
Loading preview...
+ )} +
+
+
+
+ ) : null} +
+ {wikiLinkIntentRef.current?.existingHref ? (