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:
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user