From 04448bf6152a8ced16fecb9c80d7ce4806c92517 Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Thu, 17 Sep 2026 09:00:17 -0500 Subject: [PATCH] Online browser + post detail: SPA talks to e621 directly Backend: - POST /api/files/lookup/ reports which MD5s are already in the library Frontend: - e621 client extended with post/tag/favorite types and helpers: post search, post detail, batch posts by id, tag autocomplete, toggle favorite - /online: tag search with autocomplete, post grid with rating colors and in-library badges, numbered pagination, page tag cloud, blacklist panel and filtered counts from /users/me.json, anonymous hint - /online/view/:id: media viewer (sample or original, video support), taxonomy-colored tags by category, specs sheet, favorite/unfavorite, description, sources, pools and parent/children thumbnails - Shared CollapsibleSidebar extracted from the library page; Online added to the nav, command palette and sidebar toggle --- backend/apps/library/views.py | 17 +- frontend/src/App.tsx | 4 + frontend/src/components/AppShell.tsx | 10 +- .../src/components/CollapsibleSidebar.tsx | 47 +++ frontend/src/components/CommandPalette.tsx | 7 + frontend/src/components/TagChip.tsx | 49 +++ frontend/src/features/library/LibraryPage.tsx | 36 +- .../src/features/online/OnlineDetailPage.tsx | 393 ++++++++++++++++++ frontend/src/features/online/OnlinePage.tsx | 297 +++++++++++++ frontend/src/features/online/PostCard.tsx | 76 ++++ .../src/features/online/TagAutocomplete.tsx | 125 ++++++ frontend/src/lib/e621.ts | 205 +++++++++ frontend/src/lib/hooks.ts | 12 + frontend/src/store/e621.ts | 36 +- 14 files changed, 1273 insertions(+), 41 deletions(-) create mode 100644 frontend/src/components/CollapsibleSidebar.tsx create mode 100644 frontend/src/components/TagChip.tsx create mode 100644 frontend/src/features/online/OnlineDetailPage.tsx create mode 100644 frontend/src/features/online/OnlinePage.tsx create mode 100644 frontend/src/features/online/PostCard.tsx create mode 100644 frontend/src/features/online/TagAutocomplete.tsx create mode 100644 frontend/src/lib/hooks.ts diff --git a/backend/apps/library/views.py b/backend/apps/library/views.py index b486ea3..be91b08 100644 --- a/backend/apps/library/views.py +++ b/backend/apps/library/views.py @@ -5,7 +5,7 @@ from django.db.models import Min from rest_framework import mixins, status, viewsets from rest_framework.decorators import action from rest_framework.parsers import FormParser, MultiPartParser -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView @@ -24,7 +24,7 @@ class MediaItemViewSet( ): serializer_class = MediaItemSerializer lookup_field = "md5" - http_method_names = ["get", "patch", "head", "options"] + http_method_names = ["get", "post", "patch", "head", "options"] def get_queryset(self): queryset = MediaItem.objects.prefetch_related("locations").annotate( @@ -79,6 +79,19 @@ class MediaItemViewSet( return services.serve_file(request, thumbnail) return services.serve_file(request, path) + @action(detail=False, methods=["post"], permission_classes=[AllowAny]) + def lookup(self, request): + """Return which of the given MD5s already exist in the library.""" + md5s = request.data.get("md5s") + if not isinstance(md5s, list): + return Response( + {"detail": "md5s must be a list."}, + status=status.HTTP_400_BAD_REQUEST, + ) + wanted = {str(value).strip().lower()[:32] for value in md5s if value} + found = MediaItem.objects.filter(md5__in=wanted).values_list("md5", flat=True) + return Response({"found": sorted(found)}) + @action(detail=True, methods=["get"]) def neighbors(self, request, md5=None): """Previous/next items in the ordered list, for keyboard navigation.""" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fbf738c..962bbc8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,8 @@ import LoginPage from "@/features/auth/LoginPage"; import RegisterPage from "@/features/auth/RegisterPage"; import FileDetailPage from "@/features/library/FileDetailPage"; import LibraryPage from "@/features/library/LibraryPage"; +import OnlineDetailPage from "@/features/online/OnlineDetailPage"; +import OnlinePage from "@/features/online/OnlinePage"; import UploadPage from "@/features/upload/UploadPage"; import { useAuth } from "@/store/auth"; @@ -37,6 +39,8 @@ export default function App() { }> } /> } /> + } /> + } /> state.user); const logout = useAuth((state) => state.logout); const toggleSidebar = useUi((state) => state.toggleSidebar); + const loadE621 = useE621((state) => state.load); const location = useLocation(); const [paletteOpen, setPaletteOpen] = useState(false); + useEffect(() => { + void loadE621(); + }, [loadE621]); + const statusQuery = useQuery({ queryKey: ["status"], queryFn: () => api("/api/status/"), @@ -53,7 +60,8 @@ export function AppShell() { return () => window.removeEventListener("keydown", handleKeyDown); }, [toggleSidebar]); - const showSidebarToggle = location.pathname === "/"; + const showSidebarToggle = + location.pathname === "/" || location.pathname === "/online"; return (
diff --git a/frontend/src/components/CollapsibleSidebar.tsx b/frontend/src/components/CollapsibleSidebar.tsx new file mode 100644 index 0000000..87d6285 --- /dev/null +++ b/frontend/src/components/CollapsibleSidebar.tsx @@ -0,0 +1,47 @@ +import type { ReactNode } from "react"; +import { X } from "lucide-react"; + +import { cn } from "@/lib/cn"; +import { useUi } from "@/store/ui"; + +export function CollapsibleSidebar({ + title = "Filters", + children, +}: { + title?: string; + children: ReactNode; +}) { + const sidebarOpen = useUi((state) => state.sidebarOpen); + const setSidebarOpen = useUi((state) => state.setSidebarOpen); + + if (!sidebarOpen) return null; + + return ( + <> +
setSidebarOpen(false)} + aria-hidden="true" + /> + + + ); +} diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx index c0d75eb..a65eba7 100644 --- a/frontend/src/components/CommandPalette.tsx +++ b/frontend/src/components/CommandPalette.tsx @@ -1,5 +1,6 @@ import { FolderOpen, + Globe, LogIn, LogOut, Menu, @@ -56,6 +57,12 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) { icon: Upload, run: () => navigate("/upload"), }, + { + id: "online", + label: "Go to Online browser", + icon: Globe, + run: () => navigate("/online"), + }, { id: "filters", label: "Toggle filters", diff --git a/frontend/src/components/TagChip.tsx b/frontend/src/components/TagChip.tsx new file mode 100644 index 0000000..0866aa2 --- /dev/null +++ b/frontend/src/components/TagChip.tsx @@ -0,0 +1,49 @@ +import { cn } from "@/lib/cn"; + +const TAG_STYLES: Record = { + general: + "border-ctp-blue/30 bg-ctp-blue/10 text-ctp-blue hover:bg-ctp-blue/20", + artist: + "border-ctp-yellow/30 bg-ctp-yellow/10 text-ctp-yellow hover:bg-ctp-yellow/20", + copyright: + "border-ctp-mauve/30 bg-ctp-mauve/10 text-ctp-mauve hover:bg-ctp-mauve/20", + character: + "border-ctp-green/30 bg-ctp-green/10 text-ctp-green hover:bg-ctp-green/20", + species: + "border-ctp-peach/30 bg-ctp-peach/10 text-ctp-peach hover:bg-ctp-peach/20", + meta: "border-ctp-subtext0/30 bg-ctp-subtext0/10 text-ctp-subtext0 hover:bg-ctp-subtext0/20", + lore: "border-ctp-teal/30 bg-ctp-teal/10 text-ctp-teal hover:bg-ctp-teal/20", + invalid: + "border-ctp-red/30 bg-ctp-red/10 text-ctp-red hover:bg-ctp-red/20", + contributor: + "border-ctp-lavender/30 bg-ctp-lavender/10 text-ctp-lavender hover:bg-ctp-lavender/20", + unknown: + "border-ctp-surface1 text-ctp-subtext0 hover:bg-ctp-surface0 hover:text-ctp-text", +}; + +export function TagChip({ + tag, + category, + count, + onClick, +}: { + tag: string; + category?: string; + count?: number; + onClick?: () => void; +}) { + const style = TAG_STYLES[category ?? "unknown"] ?? TAG_STYLES.unknown; + return ( + + ); +} diff --git a/frontend/src/features/library/LibraryPage.tsx b/frontend/src/features/library/LibraryPage.tsx index b238a0f..864b6c3 100644 --- a/frontend/src/features/library/LibraryPage.tsx +++ b/frontend/src/features/library/LibraryPage.tsx @@ -1,7 +1,8 @@ import { useQuery } from "@tanstack/react-query"; -import { Search, X } from "lucide-react"; +import { Search } from "lucide-react"; import { useState, useEffect, type FormEvent } from "react"; +import { CollapsibleSidebar } from "@/components/CollapsibleSidebar"; import { MediaCard } from "@/components/MediaCard"; import { Button, EmptyState, Spinner, inputClass } from "@/components/ui"; import { api } from "@/lib/api"; @@ -44,8 +45,6 @@ export default function LibraryPage() { const [search, setSearch] = useState(""); const [page, setPage] = useState(1); - const sidebarOpen = useUi((state) => state.sidebarOpen); - const setSidebarOpen = useUi((state) => state.setSidebarOpen); const ratings = useUi((state) => state.ratings); const setRatings = useUi((state) => state.setRatings); const perPage = useUi((state) => state.perPage); @@ -199,36 +198,7 @@ export default function LibraryPage() { return (
- {sidebarOpen ? ( - <> -
setSidebarOpen(false)} - aria-hidden="true" - /> - - - ) : null} + {filterPanel}
diff --git a/frontend/src/features/online/OnlineDetailPage.tsx b/frontend/src/features/online/OnlineDetailPage.tsx new file mode 100644 index 0000000..822b81c --- /dev/null +++ b/frontend/src/features/online/OnlineDetailPage.tsx @@ -0,0 +1,393 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + ArrowLeft, + CheckCircle2, + Download, + ExternalLink, + Heart, + HeartOff, +} from "lucide-react"; +import { useMemo } from "react"; +import { Link, 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 { + e621ErrorMessage, + effectiveCredentials, + fetchPost, + fetchPostsByIds, + RATING_LABELS, + setFavorite, + TAG_CATEGORY_LABELS, + TAG_CATEGORY_ORDER, +} from "@/lib/e621"; +import { formatBytes, formatDate } from "@/lib/format"; +import { useE621 } from "@/store/e621"; + +const ratingText: Record = { + s: "text-ctp-green", + q: "text-ctp-peach", + e: "text-ctp-red", +}; + +const relatedBorder: Record = { + s: "border-ctp-green/25 hover:border-ctp-green/60", + q: "border-ctp-peach/25 hover:border-ctp-peach/60", + e: "border-ctp-red/25 hover:border-ctp-red/60", +}; + +const linkButtonClass = + "inline-flex items-center justify-center gap-2 rounded-md border border-ctp-surface1 bg-ctp-surface0 px-3 py-1.5 text-sm font-medium text-ctp-text transition hover:bg-ctp-surface1"; + +export default function OnlineDetailPage() { + const { id = "" } = useParams(); + const postId = Number(id); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const credentials = useE621((state) => state.credentials); + + const query = useQuery({ + queryKey: ["e621", "post", postId, credentials?.username ?? ""], + queryFn: ({ signal }) => + fetchPost(effectiveCredentials(credentials), postId, signal), + enabled: Number.isFinite(postId) && postId > 0, + }); + + const post = query.data; + + const relatedIds = useMemo(() => { + if (!post) return []; + const ids: number[] = []; + if (post.relationships.parent_id) ids.push(post.relationships.parent_id); + for (const childId of post.relationships.children) { + if (ids.length >= 12) break; + ids.push(childId); + } + return ids; + }, [post]); + + const relatedQuery = useQuery({ + queryKey: [ + "e621", + "posts-by-ids", + relatedIds.join(","), + credentials?.username ?? "", + ], + queryFn: ({ signal }) => + fetchPostsByIds(effectiveCredentials(credentials), relatedIds, signal), + enabled: relatedIds.length > 0, + }); + + const lookupQuery = useQuery({ + queryKey: ["library-lookup", post?.file.md5 ?? ""], + queryFn: () => + api<{ found: string[] }>("/api/files/lookup/", { + method: "POST", + json: { md5s: [post?.file.md5] }, + }), + enabled: Boolean(post?.file.md5), + staleTime: 60_000, + }); + const inLibrary = (lookupQuery.data?.found.length ?? 0) > 0; + + const favoriteMutation = useMutation({ + mutationFn: (favorite: boolean) => + setFavorite(effectiveCredentials(credentials), postId, favorite), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ["e621", "post", postId], + }); + }, + }); + + if (query.isPending) { + return ( +
+ +
+ ); + } + + if (query.isError || !post) { + return ( + + Back to the online browser + + ) + } + /> + ); + } + + const isVideo = post.file.ext === "mp4" || post.file.ext === "webm"; + const imageSrc = + post.sample.has && post.sample.url ? post.sample.url : post.file.url; + + return ( +
+
+ + Online + + / + + Post #{post.id} + +
+ +
+ + +
+
+ + + + View on e621 + +
+ + {favoriteMutation.isError ? ( +

+ {e621ErrorMessage(favoriteMutation.error)} +

+ ) : null} + +
+ {isVideo ? ( +
+ + {post.description.trim() ? ( +
+
+ + Description + +

+ {post.description} +

+
+
+ ) : null} + + {post.sources.length > 0 ? ( +
+

+ Sources +

+
    + {post.sources.slice(0, 6).map((source) => ( +
  • + + {source} + +
  • + ))} +
+
+ ) : null} + + {post.pools.length > 0 ? ( +
+

Pools

+
+ {post.pools.map((poolId) => ( + + Pool #{poolId} + + ))} +
+
+ ) : null} + + {relatedIds.length > 0 ? ( +
+

+ Related posts +

+ {relatedQuery.isPending ? ( + + ) : ( +
+ {(relatedQuery.data ?? []).map((related) => ( + +
+ {related.preview.url ? ( + {`Post + ) : null} +
+

+ #{related.id} +

+ + ))} +
+ )} +
+ ) : null} +
+
+
+ ); +} diff --git a/frontend/src/features/online/OnlinePage.tsx b/frontend/src/features/online/OnlinePage.tsx new file mode 100644 index 0000000..cfc3fd1 --- /dev/null +++ b/frontend/src/features/online/OnlinePage.tsx @@ -0,0 +1,297 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { Link, useSearchParams } from "react-router-dom"; + +import { CollapsibleSidebar } from "@/components/CollapsibleSidebar"; +import { TagChip } from "@/components/TagChip"; +import { Button, EmptyState, Spinner } from "@/components/ui"; +import { PostCard } from "@/features/online/PostCard"; +import { TagAutocomplete } from "@/features/online/TagAutocomplete"; +import { api } from "@/lib/api"; +import { cn } from "@/lib/cn"; +import { + e621ErrorMessage, + effectiveCredentials, + fetchPosts, + postIsBlacklisted, + type E621Post, +} from "@/lib/e621"; +import { useE621 } from "@/store/e621"; + +const PAGE_SIZE = 48; +const EMPTY_POSTS: E621Post[] = []; + +export default function OnlinePage() { + const [searchParams, setSearchParams] = useSearchParams(); + const tags = searchParams.get("tags") ?? ""; + const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1); + + const credentials = useE621((state) => state.credentials); + const blacklist = useE621((state) => state.blacklist); + const [draft, setDraft] = useState(null); + const tagInput = draft ?? tags; + + const postsQuery = useQuery({ + queryKey: ["e621", "posts", tags, page, credentials?.username ?? ""], + queryFn: ({ signal }) => + fetchPosts(effectiveCredentials(credentials), { + tags, + page, + limit: PAGE_SIZE, + signal, + }), + placeholderData: keepPreviousData, + staleTime: 30_000, + }); + + const posts = postsQuery.data ?? EMPTY_POSTS; + + const md5s = useMemo(() => posts.map((post) => post.file.md5), [posts]); + const lookupQuery = useQuery({ + queryKey: ["library-lookup", md5s.join(",")], + queryFn: () => + api<{ found: string[] }>("/api/files/lookup/", { + method: "POST", + json: { md5s }, + }), + enabled: md5s.length > 0, + staleTime: 60_000, + }); + const inLibrary = useMemo( + () => new Set(lookupQuery.data?.found ?? []), + [lookupQuery.data], + ); + + const blacklistSet = useMemo(() => new Set(blacklist), [blacklist]); + const { visiblePosts, blacklistCounts, removedCount } = useMemo(() => { + const counts = new Map(); + const visible: E621Post[] = []; + let removed = 0; + for (const post of posts) { + const matches = postIsBlacklisted(post, blacklistSet); + if (matches.length > 0) { + removed += 1; + for (const match of matches) { + counts.set(match, (counts.get(match) ?? 0) + 1); + } + } else { + visible.push(post); + } + } + return { + visiblePosts: visible, + blacklistCounts: [...counts.entries()].sort((a, b) => b[1] - a[1]), + removedCount: removed, + }; + }, [posts, blacklistSet]); + + const pageTags = useMemo(() => { + const counts = new Map(); + for (const post of visiblePosts) { + for (const list of Object.values(post.tags)) { + for (const tag of list) { + counts.set(tag, (counts.get(tag) ?? 0) + 1); + } + } + } + return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 40); + }, [visiblePosts]); + + const hasNext = posts.length >= PAGE_SIZE; + + function buildParams(tagValue: string, pageValue: number): URLSearchParams { + const params = new URLSearchParams(); + if (tagValue.trim()) params.set("tags", tagValue.trim()); + if (pageValue > 1) params.set("page", String(pageValue)); + return params; + } + + function applySearch() { + setDraft(null); + setSearchParams(buildParams(tagInput, 1)); + } + + function clearSearch() { + setDraft(null); + setSearchParams(new URLSearchParams()); + } + + function addTag(tag: string) { + const next = tags ? `${tags} ${tag}` : tag; + setDraft(null); + setSearchParams(buildParams(next, 1)); + } + + function goToPage(nextPage: number) { + setSearchParams(buildParams(tags, nextPage)); + } + + return ( +
+ +
{ + event.preventDefault(); + applySearch(); + }} + className="flex flex-col gap-2" + > + + Tags + + +
+ + +
+

+ e621 syntax: space separated, -tag{" "} + excludes, ~tag ORs. +

+ + +
+ + Tags on this page + +
+ {pageTags.map(([tag, count]) => ( + addTag(tag)} + /> + ))} + {pageTags.length === 0 && postsQuery.isPending ? ( + + ) : null} +
+
+ +
+ + Your blacklist + + {blacklist.length > 0 ? ( +
+ {blacklist.map((tag) => ( + + {tag} + + ))} +
+ ) : ( +

No blacklisted tags.

+ )} +
+ +
+ + Blacklisted tags removed + + {blacklistCounts.length > 0 ? ( +
+ {blacklistCounts.map(([tag, count]) => ( + + {tag} ({count}) + + ))} +
+ ) : ( +

+ Nothing filtered on this page. +

+ )} +
+
+ +
+
+

+ {postsQuery.data + ? `${visiblePosts.length} post${visiblePosts.length === 1 ? "" : "s"} shown${ + removedCount > 0 + ? ` · ${removedCount} hidden by blacklist` + : "" + }` + : "…"} +

+

Page {page}

+
+ + {credentials && !credentials.configured ? ( +

+ Browsing anonymously.{" "} + + Add e621 credentials + {" "} + for favorites and your personal blacklist. +

+ ) : null} + + {postsQuery.isPending ? ( +
+ +
+ ) : postsQuery.isError ? ( + + ) : visiblePosts.length === 0 ? ( + 0 + ? "Everything on this page was blacklisted." + : "Try different tags." + } + /> + ) : ( +
+ {visiblePosts.map((post) => ( + + ))} +
+ )} + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/features/online/PostCard.tsx b/frontend/src/features/online/PostCard.tsx new file mode 100644 index 0000000..7d77897 --- /dev/null +++ b/frontend/src/features/online/PostCard.tsx @@ -0,0 +1,76 @@ +import { CheckCircle2 } from "lucide-react"; +import { Link } from "react-router-dom"; + +import { cn } from "@/lib/cn"; +import type { E621Post } from "@/lib/e621"; +import { formatBytes } from "@/lib/format"; + +const ratingBorder: Record = { + s: "border-ctp-green/25 hover:border-ctp-green/60", + q: "border-ctp-peach/25 hover:border-ctp-peach/60", + e: "border-ctp-red/25 hover:border-ctp-red/60", +}; + +const ratingPill: Record = { + s: "bg-ctp-green text-ctp-crust", + q: "bg-ctp-peach text-ctp-crust", + e: "bg-ctp-red text-ctp-crust", +}; + +export function PostCard({ + post, + inLibrary, +}: { + post: E621Post; + inLibrary: boolean; +}) { + return ( + +
+ {post.preview.url ? ( + {`Post + ) : ( +
+ No preview +
+ )} + + {post.rating} + + + {post.file.ext} + + {inLibrary ? ( + + IN + + ) : null} +
+
+ #{post.id} + + ▲ {post.score.total} · {formatBytes(post.file.size)} + +
+ + ); +} diff --git a/frontend/src/features/online/TagAutocomplete.tsx b/frontend/src/features/online/TagAutocomplete.tsx new file mode 100644 index 0000000..012d1b5 --- /dev/null +++ b/frontend/src/features/online/TagAutocomplete.tsx @@ -0,0 +1,125 @@ +import { useQuery } from "@tanstack/react-query"; +import { Search } from "lucide-react"; +import { useState } from "react"; + +import { inputClass } from "@/components/ui"; +import { cn } from "@/lib/cn"; +import { + effectiveCredentials, + fetchTagSuggestions, + tagCategoryName, + type E621Tag, +} from "@/lib/e621"; +import { useDebouncedValue } from "@/lib/hooks"; +import { useE621 } from "@/store/e621"; + +const categoryDot: Record = { + general: "bg-ctp-blue", + artist: "bg-ctp-yellow", + copyright: "bg-ctp-mauve", + character: "bg-ctp-green", + species: "bg-ctp-peach", + meta: "bg-ctp-subtext0", + lore: "bg-ctp-teal", + invalid: "bg-ctp-red", + contributor: "bg-ctp-lavender", + unknown: "bg-ctp-surface2", +}; + +export function TagAutocomplete({ + value, + onChange, +}: { + value: string; + onChange: (value: string) => void; +}) { + const credentials = useE621((state) => state.credentials); + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + + const lastWord = value.split(" ").pop() ?? ""; + const debounced = useDebouncedValue(lastWord, 450); + + const suggestionsQuery = useQuery({ + queryKey: ["e621", "tags", debounced, credentials?.username ?? ""], + queryFn: ({ signal }) => + fetchTagSuggestions(effectiveCredentials(credentials), debounced, signal), + enabled: debounced.trim().length >= 2, + staleTime: 60_000, + }); + const suggestions = suggestionsQuery.data ?? []; + const visible = open && suggestions.length > 0; + + function pick(tag: E621Tag) { + const prefix = value.slice(0, Math.max(0, value.length - lastWord.length)); + onChange(`${prefix}${tag.name} `); + setOpen(false); + setActiveIndex(0); + } + + return ( +
+ + { + onChange(event.target.value); + setOpen(true); + setActiveIndex(0); + }} + onKeyDown={(event) => { + if (!visible) return; + const suggestion = suggestions[activeIndex]; + if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveIndex((index) => Math.min(index + 1, suggestions.length - 1)); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveIndex((index) => Math.max(index - 1, 0)); + } else if (event.key === "Enter") { + event.preventDefault(); + if (suggestion) pick(suggestion); + } else if (event.key === "Escape") { + setOpen(false); + } + }} + onBlur={() => window.setTimeout(() => setOpen(false), 120)} + /> + {visible ? ( +
    + {suggestions.map((tag, index) => ( +
  • + +
  • + ))} +
+ ) : null} +
+ ); +} diff --git a/frontend/src/lib/e621.ts b/frontend/src/lib/e621.ts index 59df447..4420a60 100644 --- a/frontend/src/lib/e621.ts +++ b/frontend/src/lib/e621.ts @@ -12,6 +12,50 @@ export interface E621User { blacklisted_tags?: string; } +export interface E621Tag { + id: number; + name: string; + post_count: number; + category: number; +} + +export interface E621Post { + id: number; + created_at: string; + rating: "s" | "q" | "e"; + tags: Record; + file: { + width: number; + height: number; + size: number; + ext: string; + md5: string; + url: string | null; + }; + preview: { url: string | null; width: number; height: number }; + sample: { + has: boolean; + url: string | null; + width: number; + height: number; + }; + score: { up: number; down: number; total: number }; + fav_count: number; + comment_count: number; + is_favorited: boolean; + vote: number | null; + sources: string[]; + description: string; + pools: number[]; + relationships: { + parent_id: number | null; + has_children: boolean; + has_active_children: boolean; + children: number[]; + }; + uploader_name?: string; +} + export class E621Error extends Error { status: number; data: unknown; @@ -24,6 +68,19 @@ export class E621Error extends Error { } } +export const ANONYMOUS_CREDENTIALS: E621Credentials = { + username: "", + api_key: "", + base_url: "https://e621.net", + configured: false, +}; + +export function effectiveCredentials( + credentials: E621Credentials | null, +): E621Credentials { + return credentials ?? ANONYMOUS_CREDENTIALS; +} + const CLIENT_VERSION = "J621/0.1"; // e621 allows 2 requests/second hard, 1/second sustained. Serialize every @@ -142,3 +199,151 @@ export async function verifyE621Credentials( ); return data.user ?? (data as E621User); } + +export async function fetchPosts( + credentials: E621Credentials, + options: { + tags?: string; + page?: number; + limit?: number; + signal?: AbortSignal; + } = {}, +): Promise { + const data = await e621Request<{ posts: E621Post[] }>( + credentials, + "/posts.json", + { + params: { + tags: options.tags || undefined, + page: options.page, + limit: options.limit ?? 48, + }, + signal: options.signal, + }, + ); + return data.posts; +} + +export async function fetchPost( + credentials: E621Credentials, + id: number, + signal?: AbortSignal, +): Promise { + const data = await e621Request<{ post: E621Post }>( + credentials, + `/posts/${id}.json`, + { signal }, + ); + return data.post; +} + +export async function fetchPostsByIds( + credentials: E621Credentials, + ids: number[], + signal?: AbortSignal, +): Promise { + if (ids.length === 0) return []; + const data = await e621Request<{ posts: E621Post[] }>( + credentials, + "/posts.json", + { + params: { tags: `id:${ids.join(",")}`, limit: Math.min(ids.length, 100) }, + signal, + }, + ); + return data.posts; +} + +export async function fetchTagSuggestions( + credentials: E621Credentials, + query: string, + signal?: AbortSignal, +): Promise { + const trimmed = query.trim(); + if (!trimmed) return []; + return e621Request(credentials, "/tags.json", { + params: { + "search[name_matches]": `*${trimmed}*`, + "search[order]": "count", + limit: 8, + }, + signal, + }); +} + +export async function setFavorite( + credentials: E621Credentials, + postId: number, + favorite: boolean, +): Promise { + if (favorite) { + await e621Request(credentials, "/favorites.json", { + method: "POST", + body: new URLSearchParams({ post_id: String(postId) }), + }); + return; + } + await e621Request(credentials, `/favorites/${postId}.json`, { + method: "DELETE", + }); +} + +export function postIsBlacklisted( + post: E621Post, + blacklist: Set, +): string[] { + if (blacklist.size === 0) return []; + const matches: string[] = []; + for (const tags of Object.values(post.tags)) { + for (const tag of tags) { + if (blacklist.has(tag)) matches.push(tag); + } + } + return matches; +} + +export const TAG_CATEGORY_ORDER = [ + "general", + "species", + "character", + "copyright", + "artist", + "meta", + "lore", + "invalid", + "contributor", +] as const; + +export const TAG_CATEGORY_LABELS: Record = { + general: "General", + species: "Species", + character: "Character", + copyright: "Copyright", + artist: "Artist", + meta: "Meta", + lore: "Lore", + invalid: "Invalid", + contributor: "Contributor", +}; + +const CATEGORY_BY_NUMBER: Record = { + 0: "general", + 1: "artist", + 3: "copyright", + 4: "character", + 5: "species", + 6: "invalid", + 7: "meta", + 8: "lore", + 9: "contributor", +}; + +export function tagCategoryName(category: number): string { + return CATEGORY_BY_NUMBER[category] ?? "unknown"; +} + +export const RATING_LABELS: Record = { + s: "Safe", + q: "Questionable", + e: "Explicit", +}; diff --git a/frontend/src/lib/hooks.ts b/frontend/src/lib/hooks.ts new file mode 100644 index 0000000..cb983dc --- /dev/null +++ b/frontend/src/lib/hooks.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from "react"; + +export function useDebouncedValue(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const handle = window.setTimeout(() => setDebounced(value), delayMs); + return () => window.clearTimeout(handle); + }, [value, delayMs]); + + return debounced; +} diff --git a/frontend/src/store/e621.ts b/frontend/src/store/e621.ts index 8dcae98..e6cbdc7 100644 --- a/frontend/src/store/e621.ts +++ b/frontend/src/store/e621.ts @@ -1,12 +1,19 @@ import { create } from "zustand"; import { api } from "@/lib/api"; -import type { E621Credentials } from "@/lib/e621"; +import { + effectiveCredentials, + verifyE621Credentials, + type E621Credentials, +} from "@/lib/e621"; interface E621State { credentials: E621Credentials | null; + blacklist: string[]; loading: boolean; + ready: boolean; load: () => Promise; + loadBlacklist: () => Promise; save: (values: { username: string; api_key: string; @@ -15,17 +22,35 @@ interface E621State { clear: () => void; } -export const useE621 = create((set) => ({ +export const useE621 = create((set, get) => ({ credentials: null, + blacklist: [], loading: false, + ready: false, load: async () => { set({ loading: true }); try { const credentials = await api("/api/auth/e621/"); - set({ credentials, loading: false }); + set({ credentials, ready: true, loading: false }); + void get().loadBlacklist(); } catch { - set({ credentials: null, loading: false }); + set({ credentials: null, ready: true, loading: false }); + } + }, + + loadBlacklist: async () => { + try { + const user = await verifyE621Credentials( + effectiveCredentials(get().credentials), + ); + const tags = (user.blacklisted_tags ?? "") + .split("\n") + .map((tag) => tag.trim()) + .filter(Boolean); + set({ blacklist: tags }); + } catch { + // Keep the last known blacklist when e621 is unreachable. } }, @@ -35,8 +60,9 @@ export const useE621 = create((set) => ({ json: values, }); set({ credentials }); + void get().loadBlacklist(); return credentials; }, - clear: () => set({ credentials: null }), + clear: () => set({ credentials: null, blacklist: [], ready: false }), }));