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,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() {
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3 text-sm">
<Link
to="/online"
to={from}
className="inline-flex items-center gap-1 text-ctp-subtext0 transition hover:text-ctp-text"
>
<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">
Description
</summary>
<p className="mt-2 whitespace-pre-line text-sm text-ctp-subtext0">
{post.description}
</p>
<div className="mt-2 text-sm leading-relaxed text-ctp-subtext0">
{renderDText(post.description)}
</div>
</details>
</section>
) : null}
@@ -362,6 +369,7 @@ export default function OnlineDetailPage() {
<Link
key={related.id}
to={`/online/view/${related.id}`}
state={{ from }}
className={cn(
"overflow-hidden rounded-md border bg-ctp-mantle transition",
relatedBorder[related.rating] ?? "border-ctp-surface0",
+6 -2
View File
@@ -1,6 +1,6 @@
import { keepPreviousData, useQuery } from "@tanstack/react-query";
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 { TagChip } from "@/components/TagChip";
@@ -23,8 +23,10 @@ const EMPTY_POSTS: E621Post[] = [];
export default function OnlinePage() {
const [searchParams, setSearchParams] = useSearchParams();
const location = useLocation();
const tags = searchParams.get("tags") ?? "";
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
const from = `${location.pathname}${location.search}`;
const credentials = useE621((state) => 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}
/>
))}
</div>
@@ -20,13 +20,16 @@ const ratingPill: Record<string, string> = {
export function PostCard({
post,
inLibrary,
from,
}: {
post: E621Post;
inLibrary: boolean;
from?: string;
}) {
return (
<Link
to={`/online/view/${post.id}`}
state={from ? { from } : undefined}
className={cn(
"group overflow-hidden rounded-lg border bg-ctp-base transition",
ratingBorder[post.rating] ?? "border-ctp-surface0",