e621 polish: cached results, search context, DText descriptions
- e621 list/post queries use a 5 minute staleTime and 30 minute gcTime, so opening a post and coming back shows the same results with no refetch; global gcTime raised to 30 minutes - post card links carry their originating search in route state; the detail back link returns to it and related-post links keep it - new navigations scroll to top while history back/forward keeps the previous position - DText renderer for e621 descriptions (b/i/u/s, sup/sub, code, spoiler, quote, color, url/wiki/thumb, headings, lists, sections, expand blocks, named and bare links) built as React elements, no raw HTML - Spoiler moved to its own component; trailing punctuation no longer swallowed into links
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { LogOut, Menu } from "lucide-react";
|
import { LogOut, Menu } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
|
import { Link, NavLink, Outlet, useLocation, useNavigationType } from "react-router-dom";
|
||||||
|
|
||||||
import { CommandPalette } from "@/components/CommandPalette";
|
import { CommandPalette } from "@/components/CommandPalette";
|
||||||
import { StatusFooter } from "@/components/StatusFooter";
|
import { StatusFooter } from "@/components/StatusFooter";
|
||||||
@@ -63,6 +63,13 @@ export function AppShell() {
|
|||||||
const showSidebarToggle =
|
const showSidebarToggle =
|
||||||
location.pathname === "/" || location.pathname === "/online";
|
location.pathname === "/" || location.pathname === "/online";
|
||||||
|
|
||||||
|
const navigationType = useNavigationType();
|
||||||
|
useEffect(() => {
|
||||||
|
// New navigations start at the top; history back/forward keeps position.
|
||||||
|
if (navigationType === "POP") return;
|
||||||
|
window.scrollTo({ top: 0 });
|
||||||
|
}, [location.pathname, navigationType]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
<header className="sticky top-0 z-40 border-b border-ctp-surface0 bg-ctp-crust/95 backdrop-blur">
|
<header className="sticky top-0 z-40 border-b border-ctp-surface0 bg-ctp-crust/95 backdrop-blur">
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
export function Spoiler({ children }: { children: ReactNode }) {
|
||||||
|
const [revealed, setRevealed] = useState(false);
|
||||||
|
if (revealed) return <span>{children}</span>;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
title="Click to reveal spoiler"
|
||||||
|
onClick={() => setRevealed(true)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") setRevealed(true);
|
||||||
|
}}
|
||||||
|
className="cursor-pointer rounded bg-ctp-surface1 px-1 text-transparent transition hover:bg-ctp-surface2"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,12 +8,13 @@ import {
|
|||||||
HeartOff,
|
HeartOff,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import { Button, EmptyState, Spinner } from "@/components/ui";
|
import { Button, EmptyState, Spinner } from "@/components/ui";
|
||||||
import { TagChip } from "@/components/TagChip";
|
import { TagChip } from "@/components/TagChip";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
import { renderDText } from "@/lib/dtext";
|
||||||
import {
|
import {
|
||||||
e621ErrorMessage,
|
e621ErrorMessage,
|
||||||
effectiveCredentials,
|
effectiveCredentials,
|
||||||
@@ -46,6 +47,8 @@ export default function OnlineDetailPage() {
|
|||||||
const { id = "" } = useParams();
|
const { id = "" } = useParams();
|
||||||
const postId = Number(id);
|
const postId = Number(id);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const from = (location.state as { from?: string } | null)?.from ?? "/online";
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const credentials = useE621((state) => state.credentials);
|
const credentials = useE621((state) => state.credentials);
|
||||||
|
|
||||||
@@ -54,6 +57,8 @@ export default function OnlineDetailPage() {
|
|||||||
queryFn: ({ signal }) =>
|
queryFn: ({ signal }) =>
|
||||||
fetchPost(effectiveCredentials(credentials), postId, signal),
|
fetchPost(effectiveCredentials(credentials), postId, signal),
|
||||||
enabled: Number.isFinite(postId) && postId > 0,
|
enabled: Number.isFinite(postId) && postId > 0,
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
gcTime: 30 * 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const post = query.data;
|
const post = query.data;
|
||||||
@@ -79,6 +84,8 @@ export default function OnlineDetailPage() {
|
|||||||
queryFn: ({ signal }) =>
|
queryFn: ({ signal }) =>
|
||||||
fetchPostsByIds(effectiveCredentials(credentials), relatedIds, signal),
|
fetchPostsByIds(effectiveCredentials(credentials), relatedIds, signal),
|
||||||
enabled: relatedIds.length > 0,
|
enabled: relatedIds.length > 0,
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
gcTime: 30 * 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const lookupQuery = useQuery({
|
const lookupQuery = useQuery({
|
||||||
@@ -136,7 +143,7 @@ export default function OnlineDetailPage() {
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center gap-3 text-sm">
|
<div className="flex items-center gap-3 text-sm">
|
||||||
<Link
|
<Link
|
||||||
to="/online"
|
to={from}
|
||||||
className="inline-flex items-center gap-1 text-ctp-subtext0 transition hover:text-ctp-text"
|
className="inline-flex items-center gap-1 text-ctp-subtext0 transition hover:text-ctp-text"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" /> Online
|
<ArrowLeft className="h-4 w-4" /> Online
|
||||||
@@ -300,9 +307,9 @@ export default function OnlineDetailPage() {
|
|||||||
<summary className="cursor-pointer text-sm font-semibold text-ctp-subtext1">
|
<summary className="cursor-pointer text-sm font-semibold text-ctp-subtext1">
|
||||||
Description
|
Description
|
||||||
</summary>
|
</summary>
|
||||||
<p className="mt-2 whitespace-pre-line text-sm text-ctp-subtext0">
|
<div className="mt-2 text-sm leading-relaxed text-ctp-subtext0">
|
||||||
{post.description}
|
{renderDText(post.description)}
|
||||||
</p>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -362,6 +369,7 @@ export default function OnlineDetailPage() {
|
|||||||
<Link
|
<Link
|
||||||
key={related.id}
|
key={related.id}
|
||||||
to={`/online/view/${related.id}`}
|
to={`/online/view/${related.id}`}
|
||||||
|
state={{ from }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"overflow-hidden rounded-md border bg-ctp-mantle transition",
|
"overflow-hidden rounded-md border bg-ctp-mantle transition",
|
||||||
relatedBorder[related.rating] ?? "border-ctp-surface0",
|
relatedBorder[related.rating] ?? "border-ctp-surface0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Link, useSearchParams } from "react-router-dom";
|
import { Link, useLocation, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
import { CollapsibleSidebar } from "@/components/CollapsibleSidebar";
|
import { CollapsibleSidebar } from "@/components/CollapsibleSidebar";
|
||||||
import { TagChip } from "@/components/TagChip";
|
import { TagChip } from "@/components/TagChip";
|
||||||
@@ -23,8 +23,10 @@ const EMPTY_POSTS: E621Post[] = [];
|
|||||||
|
|
||||||
export default function OnlinePage() {
|
export default function OnlinePage() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const location = useLocation();
|
||||||
const tags = searchParams.get("tags") ?? "";
|
const tags = searchParams.get("tags") ?? "";
|
||||||
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
|
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
|
||||||
|
const from = `${location.pathname}${location.search}`;
|
||||||
|
|
||||||
const credentials = useE621((state) => state.credentials);
|
const credentials = useE621((state) => state.credentials);
|
||||||
const blacklist = useE621((state) => state.blacklist);
|
const blacklist = useE621((state) => state.blacklist);
|
||||||
@@ -41,7 +43,8 @@ export default function OnlinePage() {
|
|||||||
signal,
|
signal,
|
||||||
}),
|
}),
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
staleTime: 30_000,
|
staleTime: 5 * 60_000,
|
||||||
|
gcTime: 30 * 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const posts = postsQuery.data ?? EMPTY_POSTS;
|
const posts = postsQuery.data ?? EMPTY_POSTS;
|
||||||
@@ -270,6 +273,7 @@ export default function OnlinePage() {
|
|||||||
key={post.id}
|
key={post.id}
|
||||||
post={post}
|
post={post}
|
||||||
inLibrary={inLibrary.has(post.file.md5)}
|
inLibrary={inLibrary.has(post.file.md5)}
|
||||||
|
from={from}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,13 +20,16 @@ const ratingPill: Record<string, string> = {
|
|||||||
export function PostCard({
|
export function PostCard({
|
||||||
post,
|
post,
|
||||||
inLibrary,
|
inLibrary,
|
||||||
|
from,
|
||||||
}: {
|
}: {
|
||||||
post: E621Post;
|
post: E621Post;
|
||||||
inLibrary: boolean;
|
inLibrary: boolean;
|
||||||
|
from?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={`/online/view/${post.id}`}
|
to={`/online/view/${post.id}`}
|
||||||
|
state={from ? { from } : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group overflow-hidden rounded-lg border bg-ctp-base transition",
|
"group overflow-hidden rounded-lg border bg-ctp-base transition",
|
||||||
ratingBorder[post.rating] ?? "border-ctp-surface0",
|
ratingBorder[post.rating] ?? "border-ctp-surface0",
|
||||||
|
|||||||
@@ -0,0 +1,511 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { Spoiler } from "@/components/Spoiler";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
const INLINE_TAGS = new Set([
|
||||||
|
"b",
|
||||||
|
"i",
|
||||||
|
"u",
|
||||||
|
"s",
|
||||||
|
"sup",
|
||||||
|
"sub",
|
||||||
|
"code",
|
||||||
|
"spoiler",
|
||||||
|
"quote",
|
||||||
|
"color",
|
||||||
|
"url",
|
||||||
|
"wiki",
|
||||||
|
"thumb",
|
||||||
|
"img",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const COLOR_NAMES = new Set([
|
||||||
|
"red",
|
||||||
|
"green",
|
||||||
|
"blue",
|
||||||
|
"yellow",
|
||||||
|
"orange",
|
||||||
|
"purple",
|
||||||
|
"pink",
|
||||||
|
"brown",
|
||||||
|
"black",
|
||||||
|
"white",
|
||||||
|
"gray",
|
||||||
|
"grey",
|
||||||
|
"cyan",
|
||||||
|
"magenta",
|
||||||
|
"lime",
|
||||||
|
"olive",
|
||||||
|
"navy",
|
||||||
|
"teal",
|
||||||
|
"maroon",
|
||||||
|
"silver",
|
||||||
|
"gold",
|
||||||
|
"aqua",
|
||||||
|
"fuchsia",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function safeColor(value: string | undefined): string | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const trimmed = value.trim().toLowerCase();
|
||||||
|
if (/^#[0-9a-f]{3,8}$/.test(trimmed)) return trimmed;
|
||||||
|
if (COLOR_NAMES.has(trimmed)) return trimmed;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeUrl(value: string | undefined): string | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripTags(value: string): string {
|
||||||
|
return value.replace(/\[[^\]]*\]/g, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function findOpenTag(
|
||||||
|
text: string,
|
||||||
|
tag: string,
|
||||||
|
from: number,
|
||||||
|
): { index: number; length: number } | null {
|
||||||
|
const regex = new RegExp(`\\[${tag}(?:=[^\\]]*)?\\]`, "g");
|
||||||
|
regex.lastIndex = from;
|
||||||
|
const match = regex.exec(text);
|
||||||
|
return match ? { index: match.index, length: match[0].length } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTrailing(url: string): { url: string; trailing: string } {
|
||||||
|
const match = /[.,;:!?)\]]+$/.exec(url);
|
||||||
|
if (!match) return { url, trailing: "" };
|
||||||
|
return { url: url.slice(0, url.length - match[0].length), trailing: match[0] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkify(text: string, keyPrefix: string): ReactNode[] {
|
||||||
|
const nodes: ReactNode[] = [];
|
||||||
|
const regex = /"([^"]+)":(\S+)|(https?:\/\/\S+)/g;
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
let counter = 0;
|
||||||
|
while ((match = regex.exec(text)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
nodes.push(text.slice(lastIndex, match.index));
|
||||||
|
}
|
||||||
|
if (match[1] && match[2]) {
|
||||||
|
const { url, trailing } = splitTrailing(match[2]);
|
||||||
|
const href = safeUrl(url);
|
||||||
|
if (!href) {
|
||||||
|
nodes.push(match[0]);
|
||||||
|
} else {
|
||||||
|
nodes.push(
|
||||||
|
<a
|
||||||
|
key={`${keyPrefix}-${counter++}`}
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-ctp-blue hover:underline"
|
||||||
|
>
|
||||||
|
{match[1]}
|
||||||
|
</a>,
|
||||||
|
);
|
||||||
|
if (trailing) nodes.push(trailing);
|
||||||
|
}
|
||||||
|
} else if (match[3]) {
|
||||||
|
const { url, trailing } = splitTrailing(match[3]);
|
||||||
|
nodes.push(
|
||||||
|
<a
|
||||||
|
key={`${keyPrefix}-${counter++}`}
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="break-all text-ctp-blue hover:underline"
|
||||||
|
>
|
||||||
|
{url}
|
||||||
|
</a>,
|
||||||
|
);
|
||||||
|
if (trailing) nodes.push(trailing);
|
||||||
|
}
|
||||||
|
lastIndex = regex.lastIndex;
|
||||||
|
}
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
nodes.push(text.slice(lastIndex));
|
||||||
|
}
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderText(
|
||||||
|
text: string,
|
||||||
|
keyPrefix: string,
|
||||||
|
extras: Map<string, ReactNode>,
|
||||||
|
): ReactNode[] {
|
||||||
|
const nodes: ReactNode[] = [];
|
||||||
|
const pieces = text.split(/(\uE000\d+\uE001)/);
|
||||||
|
pieces.forEach((piece, pieceIndex) => {
|
||||||
|
if (!piece) return;
|
||||||
|
const tokenMatch = /^\uE000(\d+)\uE001$/.exec(piece);
|
||||||
|
if (tokenMatch) {
|
||||||
|
const node = extras.get(piece);
|
||||||
|
if (node) nodes.push(node);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lines = piece.split("\n");
|
||||||
|
lines.forEach((line, lineIndex) => {
|
||||||
|
if (lineIndex > 0) {
|
||||||
|
nodes.push(<br key={`${keyPrefix}-br-${pieceIndex}-${lineIndex}`} />);
|
||||||
|
}
|
||||||
|
nodes.push(
|
||||||
|
...linkify(line, `${keyPrefix}-l-${pieceIndex}-${lineIndex}`),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTag(
|
||||||
|
tag: string,
|
||||||
|
attr: string | undefined,
|
||||||
|
inner: string,
|
||||||
|
keyPrefix: string,
|
||||||
|
extras: Map<string, ReactNode>,
|
||||||
|
): ReactNode {
|
||||||
|
switch (tag) {
|
||||||
|
case "b":
|
||||||
|
return (
|
||||||
|
<strong key={keyPrefix}>{renderInline(inner, keyPrefix, extras)}</strong>
|
||||||
|
);
|
||||||
|
case "i":
|
||||||
|
return <em key={keyPrefix}>{renderInline(inner, keyPrefix, extras)}</em>;
|
||||||
|
case "u":
|
||||||
|
return (
|
||||||
|
<span key={keyPrefix} className="underline">
|
||||||
|
{renderInline(inner, keyPrefix, extras)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
case "s":
|
||||||
|
return (
|
||||||
|
<span key={keyPrefix} className="line-through">
|
||||||
|
{renderInline(inner, keyPrefix, extras)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
case "sup":
|
||||||
|
return <sup key={keyPrefix}>{renderInline(inner, keyPrefix, extras)}</sup>;
|
||||||
|
case "sub":
|
||||||
|
return <sub key={keyPrefix}>{renderInline(inner, keyPrefix, extras)}</sub>;
|
||||||
|
case "code":
|
||||||
|
return (
|
||||||
|
<code
|
||||||
|
key={keyPrefix}
|
||||||
|
className="whitespace-pre-wrap rounded bg-ctp-surface0 px-1 py-0.5 font-mono text-[0.85em]"
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
case "spoiler":
|
||||||
|
return (
|
||||||
|
<Spoiler key={keyPrefix}>
|
||||||
|
{renderInline(inner, keyPrefix, extras)}
|
||||||
|
</Spoiler>
|
||||||
|
);
|
||||||
|
case "quote":
|
||||||
|
return (
|
||||||
|
<blockquote
|
||||||
|
key={keyPrefix}
|
||||||
|
className="border-l-2 border-ctp-surface1 pl-3 text-ctp-subtext0"
|
||||||
|
>
|
||||||
|
{attr ? (
|
||||||
|
<span className="mr-1 font-medium text-ctp-overlay0">
|
||||||
|
{stripTags(attr)}:
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{renderInline(inner, keyPrefix, extras)}
|
||||||
|
</blockquote>
|
||||||
|
);
|
||||||
|
case "color": {
|
||||||
|
const color = safeColor(attr);
|
||||||
|
return color ? (
|
||||||
|
<span key={keyPrefix} style={{ color }}>
|
||||||
|
{renderInline(inner, keyPrefix, extras)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span key={keyPrefix}>{renderInline(inner, keyPrefix, extras)}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "url": {
|
||||||
|
const href = safeUrl(attr) ?? safeUrl(stripTags(inner));
|
||||||
|
if (!href) return <span key={keyPrefix}>{stripTags(inner)}</span>;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={keyPrefix}
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="break-all text-ctp-blue hover:underline"
|
||||||
|
>
|
||||||
|
{attr ? renderInline(inner, keyPrefix, extras) : href}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "wiki": {
|
||||||
|
const title = stripTags(attr ?? inner);
|
||||||
|
if (!title) return null;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={keyPrefix}
|
||||||
|
href={`https://e621.net/wiki_pages?title=${encodeURIComponent(title)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-ctp-blue hover:underline"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "thumb":
|
||||||
|
case "img": {
|
||||||
|
const href = safeUrl(attr) ?? safeUrl(stripTags(inner));
|
||||||
|
if (!href) return null;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={keyPrefix}
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-ctp-blue hover:underline"
|
||||||
|
>
|
||||||
|
[image]
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInline(
|
||||||
|
text: string,
|
||||||
|
keyPrefix: string,
|
||||||
|
extras: Map<string, ReactNode>,
|
||||||
|
): ReactNode[] {
|
||||||
|
const nodes: ReactNode[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
let counter = 0;
|
||||||
|
|
||||||
|
while (cursor < text.length) {
|
||||||
|
const slice = text.slice(cursor);
|
||||||
|
const match = /\[([a-z_]+)(?:=([^\]]*))?\]/.exec(slice);
|
||||||
|
if (!match) {
|
||||||
|
nodes.push(...renderText(slice, `${keyPrefix}-${counter++}`, extras));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const start = cursor + match.index;
|
||||||
|
const tag = match[1].toLowerCase();
|
||||||
|
if (!INLINE_TAGS.has(tag)) {
|
||||||
|
nodes.push(
|
||||||
|
...renderText(
|
||||||
|
text.slice(cursor, start + match[0].length),
|
||||||
|
`${keyPrefix}-${counter++}`,
|
||||||
|
extras,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
cursor = start + match[0].length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeTag = `[/${tag}]`;
|
||||||
|
let depth = 1;
|
||||||
|
let scan = start + match[0].length;
|
||||||
|
while (depth > 0) {
|
||||||
|
const nextOpen = findOpenTag(text, tag, scan);
|
||||||
|
const nextClose = text.indexOf(closeTag, scan);
|
||||||
|
if (nextClose === -1) break;
|
||||||
|
if (nextOpen && nextOpen.index < nextClose) {
|
||||||
|
depth += 1;
|
||||||
|
scan = nextOpen.index + nextOpen.length;
|
||||||
|
} else {
|
||||||
|
depth -= 1;
|
||||||
|
scan = nextClose + closeTag.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth !== 0) {
|
||||||
|
nodes.push(
|
||||||
|
...renderText(
|
||||||
|
text.slice(cursor, start + match[0].length),
|
||||||
|
`${keyPrefix}-${counter++}`,
|
||||||
|
extras,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
cursor = start + match[0].length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inner = text.slice(start + match[0].length, scan - closeTag.length);
|
||||||
|
nodes.push(...renderText(text.slice(cursor, start), `${keyPrefix}-t${counter}`, extras));
|
||||||
|
nodes.push(
|
||||||
|
renderTag(tag, match[2], inner, `${keyPrefix}-${tag}-${counter++}`, extras),
|
||||||
|
);
|
||||||
|
cursor = scan;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSections(
|
||||||
|
text: string,
|
||||||
|
keyPrefix: string,
|
||||||
|
): { processed: string; extras: Map<string, ReactNode> } {
|
||||||
|
const extras = new Map<string, ReactNode>();
|
||||||
|
let result = "";
|
||||||
|
let cursor = 0;
|
||||||
|
let counter = 0;
|
||||||
|
|
||||||
|
while (cursor < text.length) {
|
||||||
|
const slice = text.slice(cursor);
|
||||||
|
const match = /\[(section|expand)(?:=([^\]]*))?\]/.exec(slice);
|
||||||
|
if (!match) {
|
||||||
|
result += slice;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const start = cursor + match.index;
|
||||||
|
const tag = match[1];
|
||||||
|
const title = match[2];
|
||||||
|
const closeTag = `[/${tag}]`;
|
||||||
|
let depth = 1;
|
||||||
|
let scan = start + match[0].length;
|
||||||
|
while (depth > 0) {
|
||||||
|
const nextOpen = findOpenTag(text, tag, scan);
|
||||||
|
const nextClose = text.indexOf(closeTag, scan);
|
||||||
|
if (nextClose === -1) break;
|
||||||
|
if (nextOpen && nextOpen.index < nextClose) {
|
||||||
|
depth += 1;
|
||||||
|
scan = nextOpen.index + nextOpen.length;
|
||||||
|
} else {
|
||||||
|
depth -= 1;
|
||||||
|
scan = nextClose + closeTag.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (depth !== 0) {
|
||||||
|
result += slice;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inner = text.slice(start + match[0].length, scan - closeTag.length);
|
||||||
|
const token = `\uE000${counter}\uE001`;
|
||||||
|
const key = `${keyPrefix}-${tag}-${counter}`;
|
||||||
|
const content = parseBlocks(inner, `${key}-body`);
|
||||||
|
extras.set(
|
||||||
|
token,
|
||||||
|
tag === "section" ? (
|
||||||
|
<section key={key} className="flex flex-col gap-2">
|
||||||
|
{title ? (
|
||||||
|
<h4 className="text-sm font-semibold text-ctp-subtext1">
|
||||||
|
{stripTags(title)}
|
||||||
|
</h4>
|
||||||
|
) : null}
|
||||||
|
{content}
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<details key={key} className="rounded border border-ctp-surface1">
|
||||||
|
<summary className="cursor-pointer px-2 py-1 text-sm text-ctp-subtext1">
|
||||||
|
{title ? stripTags(title) : "Expand"}
|
||||||
|
</summary>
|
||||||
|
<div className="flex flex-col gap-2 border-t border-ctp-surface1 p-2">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
result += `${text.slice(cursor, start)}${token}`;
|
||||||
|
counter += 1;
|
||||||
|
cursor = scan;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { processed: result, extras };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBlocks(text: string, keyPrefix: string): ReactNode[] {
|
||||||
|
const { processed, extras } = extractSections(text, keyPrefix);
|
||||||
|
const blocks = processed.split(/\n{2,}/);
|
||||||
|
|
||||||
|
return blocks
|
||||||
|
.map((block, blockIndex) => {
|
||||||
|
const trimmed = block.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
if (/^\uE000\d+\uE001$/.test(trimmed)) {
|
||||||
|
const node = extras.get(trimmed);
|
||||||
|
return node ? (
|
||||||
|
<div key={`${keyPrefix}-${blockIndex}`}>{node}</div>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const heading = /^(h[1-6])\.\s+(.*)$/.exec(trimmed);
|
||||||
|
if (heading) {
|
||||||
|
const level = heading[1];
|
||||||
|
const HeadingTag: "h3" | "h4" | "h5" =
|
||||||
|
level <= "h2" ? "h3" : level === "h3" ? "h4" : "h5";
|
||||||
|
return (
|
||||||
|
<HeadingTag
|
||||||
|
key={`${keyPrefix}-${blockIndex}`}
|
||||||
|
className={cn(
|
||||||
|
"font-semibold text-ctp-subtext1",
|
||||||
|
HeadingTag === "h3" ? "text-base" : "text-sm",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{renderInline(heading[2], `${keyPrefix}-h-${blockIndex}`, extras)}
|
||||||
|
</HeadingTag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = trimmed.split("\n");
|
||||||
|
if (lines.length > 0 && lines.every((line) => /^[*+-]\s+/.test(line))) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
key={`${keyPrefix}-${blockIndex}`}
|
||||||
|
className="ml-4 list-disc space-y-1"
|
||||||
|
>
|
||||||
|
{lines.map((line, lineIndex) => (
|
||||||
|
<li key={`${keyPrefix}-${blockIndex}-${lineIndex}`}>
|
||||||
|
{renderInline(
|
||||||
|
line.replace(/^[*+-]\s+/, ""),
|
||||||
|
`${keyPrefix}-${blockIndex}-${lineIndex}`,
|
||||||
|
extras,
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (lines.length > 0 && lines.every((line) => /^#\s+/.test(line))) {
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
key={`${keyPrefix}-${blockIndex}`}
|
||||||
|
className="ml-4 list-decimal space-y-1"
|
||||||
|
>
|
||||||
|
{lines.map((line, lineIndex) => (
|
||||||
|
<li key={`${keyPrefix}-${blockIndex}-${lineIndex}`}>
|
||||||
|
{renderInline(
|
||||||
|
line.replace(/^#\s+/, ""),
|
||||||
|
`${keyPrefix}-${blockIndex}-${lineIndex}`,
|
||||||
|
extras,
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`${keyPrefix}-${blockIndex}`} className="leading-relaxed">
|
||||||
|
{renderInline(trimmed, `${keyPrefix}-p-${blockIndex}`, extras)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderDText(text: string): ReactNode {
|
||||||
|
if (!text.trim()) return null;
|
||||||
|
return <div className="flex flex-col gap-2">{parseBlocks(text, "dtext")}</div>;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ const queryClient = new QueryClient({
|
|||||||
queries: {
|
queries: {
|
||||||
retry: 1,
|
retry: 1,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
|
gcTime: 30 * 60_000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user