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:
2026-09-17 09:19:25 -05:00
parent 04448bf615
commit 81939d8ca3
7 changed files with 562 additions and 8 deletions
+8 -1
View File
@@ -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">
+20
View File
@@ -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",
+6 -2
View File
@@ -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",
+511
View File
@@ -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>;
}
+1
View File
@@ -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,
}, },
}, },