From 81939d8ca34a9e2accea2d9fa7fedef9d982a2ff Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Thu, 17 Sep 2026 09:19:25 -0500 Subject: [PATCH] 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 --- frontend/src/components/AppShell.tsx | 9 +- frontend/src/components/Spoiler.tsx | 20 + .../src/features/online/OnlineDetailPage.tsx | 18 +- frontend/src/features/online/OnlinePage.tsx | 8 +- frontend/src/features/online/PostCard.tsx | 3 + frontend/src/lib/dtext.tsx | 511 ++++++++++++++++++ frontend/src/main.tsx | 1 + 7 files changed, 562 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/Spoiler.tsx create mode 100644 frontend/src/lib/dtext.tsx diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 75b0706..527ab2f 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -1,7 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { LogOut, Menu } from "lucide-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 { StatusFooter } from "@/components/StatusFooter"; @@ -63,6 +63,13 @@ export function AppShell() { const showSidebarToggle = 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 (
diff --git a/frontend/src/components/Spoiler.tsx b/frontend/src/components/Spoiler.tsx new file mode 100644 index 0000000..6c444fb --- /dev/null +++ b/frontend/src/components/Spoiler.tsx @@ -0,0 +1,20 @@ +import { useState, type ReactNode } from "react"; + +export function Spoiler({ children }: { children: ReactNode }) { + const [revealed, setRevealed] = useState(false); + if (revealed) return {children}; + return ( + 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} + + ); +} diff --git a/frontend/src/features/online/OnlineDetailPage.tsx b/frontend/src/features/online/OnlineDetailPage.tsx index 822b81c..9a997bb 100644 --- a/frontend/src/features/online/OnlineDetailPage.tsx +++ b/frontend/src/features/online/OnlineDetailPage.tsx @@ -8,12 +8,13 @@ import { HeartOff, } from "lucide-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 { TagChip } from "@/components/TagChip"; import { api } from "@/lib/api"; import { cn } from "@/lib/cn"; +import { renderDText } from "@/lib/dtext"; import { e621ErrorMessage, effectiveCredentials, @@ -46,6 +47,8 @@ export default function OnlineDetailPage() { const { id = "" } = useParams(); const postId = Number(id); const navigate = useNavigate(); + const location = useLocation(); + const from = (location.state as { from?: string } | null)?.from ?? "/online"; const queryClient = useQueryClient(); const credentials = useE621((state) => state.credentials); @@ -54,6 +57,8 @@ export default function OnlineDetailPage() { queryFn: ({ signal }) => fetchPost(effectiveCredentials(credentials), postId, signal), enabled: Number.isFinite(postId) && postId > 0, + staleTime: 5 * 60_000, + gcTime: 30 * 60_000, }); const post = query.data; @@ -79,6 +84,8 @@ export default function OnlineDetailPage() { queryFn: ({ signal }) => fetchPostsByIds(effectiveCredentials(credentials), relatedIds, signal), enabled: relatedIds.length > 0, + staleTime: 5 * 60_000, + gcTime: 30 * 60_000, }); const lookupQuery = useQuery({ @@ -136,7 +143,7 @@ export default function OnlineDetailPage() {
Online @@ -300,9 +307,9 @@ export default function OnlineDetailPage() { Description -

- {post.description} -

+
+ {renderDText(post.description)} +
) : null} @@ -362,6 +369,7 @@ export default function OnlineDetailPage() { state.credentials); const blacklist = useE621((state) => state.blacklist); @@ -41,7 +43,8 @@ export default function OnlinePage() { signal, }), placeholderData: keepPreviousData, - staleTime: 30_000, + staleTime: 5 * 60_000, + gcTime: 30 * 60_000, }); const posts = postsQuery.data ?? EMPTY_POSTS; @@ -270,6 +273,7 @@ export default function OnlinePage() { key={post.id} post={post} inLibrary={inLibrary.has(post.file.md5)} + from={from} /> ))}
diff --git a/frontend/src/features/online/PostCard.tsx b/frontend/src/features/online/PostCard.tsx index 7d77897..f9cb60e 100644 --- a/frontend/src/features/online/PostCard.tsx +++ b/frontend/src/features/online/PostCard.tsx @@ -20,13 +20,16 @@ const ratingPill: Record = { export function PostCard({ post, inLibrary, + from, }: { post: E621Post; inLibrary: boolean; + from?: string; }) { return ( 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( + + {match[1]} + , + ); + if (trailing) nodes.push(trailing); + } + } else if (match[3]) { + const { url, trailing } = splitTrailing(match[3]); + nodes.push( + + {url} + , + ); + 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, +): 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(
); + } + nodes.push( + ...linkify(line, `${keyPrefix}-l-${pieceIndex}-${lineIndex}`), + ); + }); + }); + return nodes; +} + +function renderTag( + tag: string, + attr: string | undefined, + inner: string, + keyPrefix: string, + extras: Map, +): ReactNode { + switch (tag) { + case "b": + return ( + {renderInline(inner, keyPrefix, extras)} + ); + case "i": + return {renderInline(inner, keyPrefix, extras)}; + case "u": + return ( + + {renderInline(inner, keyPrefix, extras)} + + ); + case "s": + return ( + + {renderInline(inner, keyPrefix, extras)} + + ); + case "sup": + return {renderInline(inner, keyPrefix, extras)}; + case "sub": + return {renderInline(inner, keyPrefix, extras)}; + case "code": + return ( + + {inner} + + ); + case "spoiler": + return ( + + {renderInline(inner, keyPrefix, extras)} + + ); + case "quote": + return ( +
+ {attr ? ( + + {stripTags(attr)}: + + ) : null} + {renderInline(inner, keyPrefix, extras)} +
+ ); + case "color": { + const color = safeColor(attr); + return color ? ( + + {renderInline(inner, keyPrefix, extras)} + + ) : ( + {renderInline(inner, keyPrefix, extras)} + ); + } + case "url": { + const href = safeUrl(attr) ?? safeUrl(stripTags(inner)); + if (!href) return {stripTags(inner)}; + return ( + + {attr ? renderInline(inner, keyPrefix, extras) : href} + + ); + } + case "wiki": { + const title = stripTags(attr ?? inner); + if (!title) return null; + return ( + + {title} + + ); + } + case "thumb": + case "img": { + const href = safeUrl(attr) ?? safeUrl(stripTags(inner)); + if (!href) return null; + return ( + + [image] + + ); + } + default: + return null; + } +} + +function renderInline( + text: string, + keyPrefix: string, + extras: Map, +): 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 } { + const extras = new Map(); + 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" ? ( +
+ {title ? ( +

+ {stripTags(title)} +

+ ) : null} + {content} +
+ ) : ( +
+ + {title ? stripTags(title) : "Expand"} + +
+ {content} +
+
+ ), + ); + 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 ? ( +
{node}
+ ) : 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 ( + + {renderInline(heading[2], `${keyPrefix}-h-${blockIndex}`, extras)} + + ); + } + + const lines = trimmed.split("\n"); + if (lines.length > 0 && lines.every((line) => /^[*+-]\s+/.test(line))) { + return ( +
    + {lines.map((line, lineIndex) => ( +
  • + {renderInline( + line.replace(/^[*+-]\s+/, ""), + `${keyPrefix}-${blockIndex}-${lineIndex}`, + extras, + )} +
  • + ))} +
+ ); + } + if (lines.length > 0 && lines.every((line) => /^#\s+/.test(line))) { + return ( +
    + {lines.map((line, lineIndex) => ( +
  1. + {renderInline( + line.replace(/^#\s+/, ""), + `${keyPrefix}-${blockIndex}-${lineIndex}`, + extras, + )} +
  2. + ))} +
+ ); + } + + return ( +
+ {renderInline(trimmed, `${keyPrefix}-p-${blockIndex}`, extras)} +
+ ); + }) + .filter(Boolean); +} + +export function renderDText(text: string): ReactNode { + if (!text.trim()) return null; + return
{parseBlocks(text, "dtext")}
; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index c27fbf1..f62492a 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -11,6 +11,7 @@ const queryClient = new QueryClient({ queries: { retry: 1, staleTime: 30_000, + gcTime: 30 * 60_000, refetchOnWindowFocus: false, }, },