| import { createFileRoute } from "@tanstack/react-router"; |
| import { useState, useMemo } from "react"; |
| import { useMutation } from "@tanstack/react-query"; |
| import { Button } from "@/components/ui/button"; |
| import { Input } from "@/components/ui/input"; |
| import { |
| Select, |
| SelectContent, |
| SelectItem, |
| SelectTrigger, |
| SelectValue, |
| } from "@/components/ui/select"; |
| import { Checkbox } from "@/components/ui/checkbox"; |
| import { Badge } from "@/components/ui/badge"; |
| import { Plus, ChevronLeft, ChevronRight, Download, Search, Trash2, Loader2 } from "lucide-react"; |
| import { useSites } from "@/lib/grabber/useSites"; |
| import { searchPosts } from "@/lib/grabber/search.functions"; |
| import type { NormalizedPost } from "@/lib/grabber/sites"; |
| import { AddSiteDialog } from "@/components/grabber/AddSiteDialog"; |
| import { Lightbox } from "@/components/grabber/Lightbox"; |
|
|
| export const Route = createFileRoute("/")({ |
| head: () => ({ |
| meta: [ |
| { title: "Imageboard Grabber" }, |
| { |
| name: "description", |
| content: "Search booru-style imageboards, browse thumbnails, and batch-download as ZIP.", |
| }, |
| { property: "og:title", content: "Imageboard Grabber" }, |
| { |
| property: "og:description", |
| content: "Search booru-style imageboards, browse thumbnails, and batch-download as ZIP.", |
| }, |
| ], |
| }), |
| component: Grabber, |
| }); |
|
|
| function Grabber() { |
| const { sites, custom, addSite, removeSite, hydrated } = useSites(); |
| const [siteId, setSiteId] = useState("e621"); |
| const [tags, setTags] = useState(""); |
| const [limit, setLimit] = useState(40); |
| const [page, setPage] = useState(1); |
| const [selected, setSelected] = useState<Set<string>>(new Set()); |
| const [addOpen, setAddOpen] = useState(false); |
| const [lightbox, setLightbox] = useState<NormalizedPost | null>(null); |
| const [downloading, setDownloading] = useState(false); |
| const [excludeTags, setExcludeTags] = useState(""); |
| const [hfToken, setHfToken] = useState(""); |
| const [datasetName, setDatasetName] = useState(""); |
| const [uploading, setUploading] = useState(false); |
| const [uploadStatus, setUploadStatus] = useState(""); |
|
|
| const site = useMemo(() => sites.find((s) => s.id === siteId) ?? sites[0], [sites, siteId]); |
|
|
| const searchMut = useMutation({ |
| mutationFn: async (vars: { page: number }) => { |
| return await searchPosts({ data: { site, tags, page: vars.page, limit } }); |
| }, |
| }); |
|
|
| const posts = searchMut.data?.posts ?? []; |
| const err = searchMut.data?.error; |
|
|
| const runSearch = (p: number) => { |
| setPage(p); |
| setSelected(new Set()); |
| searchMut.mutate({ page: p }); |
| }; |
|
|
| const toggle = (id: string) => { |
| setSelected((prev) => { |
| const n = new Set(prev); |
| if (n.has(id)) n.delete(id); |
| else n.add(id); |
| return n; |
| }); |
| }; |
|
|
| const selectAll = () => { |
| if (selected.size === posts.length) setSelected(new Set()); |
| else setSelected(new Set(posts.map((p) => p.id))); |
| }; |
|
|
| const downloadZip = async () => { |
| const chosen = posts.filter((p) => selected.has(p.id)); |
| if (!chosen.length) return; |
| setDownloading(true); |
| try { |
| const res = await fetch("/api/download-zip", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ |
| siteName: site.id, |
| posts: chosen, |
| excludeTags: excludeTags |
| .split(/\s+|,/) |
| .map((t) => t.trim().toLowerCase()) |
| .filter(Boolean), |
| }), |
| }); |
| if (!res.ok) { |
| const t = await res.text(); |
| alert(`Download failed: ${t}`); |
| return; |
| } |
| const blob = await res.blob(); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement("a"); |
| a.href = url; |
| a.download = `grabber-${site.id}-${Date.now()}.zip`; |
| document.body.appendChild(a); |
| a.click(); |
| a.remove(); |
| URL.revokeObjectURL(url); |
| } finally { |
| setDownloading(false); |
| } |
| }; |
|
|
| const uploadToDataset = async () => { |
| const chosen = posts.filter((p) => selected.has(p.id)); |
| if (!chosen.length) { |
| alert("Please select some images to upload."); |
| return; |
| } |
| if (!hfToken.trim() || !datasetName.trim()) { |
| alert("Hugging Face write token and dataset name are required."); |
| return; |
| } |
| setUploading(true); |
| setUploadStatus("Uploading to Hugging Face..."); |
| try { |
| const res = await fetch("/api/upload-to-dataset", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ |
| siteName: site.id, |
| posts: chosen, |
| excludeTags: excludeTags |
| .split(/\s+|,/) |
| .map((t) => t.trim().toLowerCase()) |
| .filter(Boolean), |
| hfToken: hfToken.trim(), |
| datasetName: datasetName.trim(), |
| }), |
| }); |
| const data = await res.json(); |
| if (!res.ok) { |
| setUploadStatus(`Upload failed: ${data.error || "Unknown error"}`); |
| } else { |
| setUploadStatus(`Success! Uploaded ZIP as ${data.filename}`); |
| } |
| } catch (err: unknown) { |
| const errMsg = err instanceof Error ? err.message : "Network error"; |
| setUploadStatus(`Upload failed: ${errMsg}`); |
| } finally { |
| setUploading(false); |
| } |
| }; |
|
|
| return ( |
| <div className="min-h-screen bg-background text-foreground"> |
| <header className="border-b sticky top-0 bg-background/95 backdrop-blur z-10"> |
| <div className="max-w-7xl mx-auto px-4 py-3 flex items-center gap-3 flex-wrap"> |
| <h1 className="text-lg font-bold">Grabber</h1> |
| <div className="flex-1" /> |
| <Select value={siteId} onValueChange={setSiteId}> |
| <SelectTrigger className="w-40"> |
| <SelectValue /> |
| </SelectTrigger> |
| <SelectContent> |
| {sites.map((s) => ( |
| <SelectItem key={s.id} value={s.id}> |
| {s.name} |
| </SelectItem> |
| ))} |
| </SelectContent> |
| </Select> |
| {hydrated && custom.some((c) => c.id === siteId) && ( |
| <Button |
| variant="ghost" |
| size="icon" |
| onClick={() => { |
| removeSite(siteId); |
| setSiteId("e621"); |
| }} |
| title="Remove site" |
| > |
| <Trash2 className="w-4 h-4" /> |
| </Button> |
| )} |
| <Button variant="outline" size="sm" onClick={() => setAddOpen(true)}> |
| <Plus className="w-4 h-4 mr-1" /> Add site |
| </Button> |
| </div> |
| <div className="max-w-7xl mx-auto px-4 pb-3 flex gap-2 flex-wrap items-center"> |
| <Input |
| placeholder="tags (e.g. rating:safe fluffy)" |
| value={tags} |
| onChange={(e) => setTags(e.target.value)} |
| onKeyDown={(e) => e.key === "Enter" && runSearch(1)} |
| className="flex-1 min-w-64" |
| /> |
| <Input |
| type="number" |
| min={1} |
| max={200} |
| value={limit} |
| onChange={(e) => setLimit(Number(e.target.value) || 40)} |
| className="w-24" |
| /> |
| <Button onClick={() => runSearch(1)} disabled={searchMut.isPending}> |
| {searchMut.isPending ? ( |
| <Loader2 className="w-4 h-4 mr-1 animate-spin" /> |
| ) : ( |
| <Search className="w-4 h-4 mr-1" /> |
| )} |
| Search |
| </Button> |
| <div className="flex items-center gap-1"> |
| <Button |
| size="icon" |
| variant="outline" |
| disabled={page <= 1 || searchMut.isPending} |
| onClick={() => runSearch(page - 1)} |
| > |
| <ChevronLeft className="w-4 h-4" /> |
| </Button> |
| <span className="text-sm px-2 tabular-nums">page {page}</span> |
| <Button |
| size="icon" |
| variant="outline" |
| disabled={searchMut.isPending || posts.length === 0} |
| onClick={() => runSearch(page + 1)} |
| > |
| <ChevronRight className="w-4 h-4" /> |
| </Button> |
| </div> |
| <Button variant="outline" size="sm" onClick={selectAll} disabled={!posts.length}> |
| {selected.size === posts.length && posts.length > 0 ? "Deselect all" : "Select all"} |
| </Button> |
| <span className="text-sm text-muted-foreground">selected: {selected.size}</span> |
| <Input |
| placeholder="Exclude tags from ZIP (e.g. fluffy, safety)" |
| value={excludeTags} |
| onChange={(e) => setExcludeTags(e.target.value)} |
| className="w-64 max-w-xs" |
| /> |
| <Button onClick={downloadZip} disabled={!selected.size || downloading}> |
| {downloading ? ( |
| <Loader2 className="w-4 h-4 mr-1 animate-spin" /> |
| ) : ( |
| <Download className="w-4 h-4 mr-1" /> |
| )} |
| Download ZIP |
| </Button> |
| </div> |
| |
| {/* Hugging Face Dataset Integration Panel */} |
| <div className="max-w-7xl mx-auto px-4 pb-3 flex gap-2 flex-wrap items-center border-t pt-3 mt-1 bg-muted/20"> |
| <span className="font-semibold text-xs text-muted-foreground mr-1 uppercase tracking-wider"> |
| Hugging Face Upload: |
| </span> |
| <Input |
| type="password" |
| placeholder="HF Write Token" |
| value={hfToken} |
| onChange={(e) => setHfToken(e.target.value)} |
| className="w-48 max-w-xs" |
| /> |
| <Input |
| placeholder="dataset-username/dataset-name" |
| value={datasetName} |
| onChange={(e) => setDatasetName(e.target.value)} |
| className="w-64 max-w-xs" |
| /> |
| <Button |
| onClick={uploadToDataset} |
| disabled={!selected.size || uploading || !hfToken.trim() || !datasetName.trim()} |
| variant="secondary" |
| > |
| {uploading ? ( |
| <Loader2 className="w-4 h-4 mr-1 animate-spin" /> |
| ) : ( |
| <Plus className="w-4 h-4 mr-1" /> |
| )} |
| Upload to HF Dataset |
| </Button> |
| {uploadStatus && ( |
| <span |
| className={`text-xs font-semibold ${ |
| uploadStatus.includes("failed") ? "text-destructive" : "text-emerald-600" |
| }`} |
| > |
| {uploadStatus} |
| </span> |
| )} |
| </div> |
| </header> |
| |
| <main className="max-w-7xl mx-auto p-4"> |
| {err && ( |
| <div className="mb-4 p-3 rounded bg-destructive/10 text-destructive text-sm">{err}</div> |
| )} |
| {searchMut.isPending && ( |
| <div className="text-center text-muted-foreground py-12">Loading…</div> |
| )} |
| {!searchMut.isPending && posts.length === 0 && searchMut.isSuccess && !err && ( |
| <div className="text-center text-muted-foreground py-12">No results.</div> |
| )} |
| {!searchMut.isPending && !searchMut.isSuccess && ( |
| <div className="text-center text-muted-foreground py-12"> |
| Enter tags and press Search. |
| </div> |
| )} |
| <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3"> |
| {posts.map((p) => { |
| const isSel = selected.has(p.id); |
| return ( |
| <div |
| key={p.id} |
| className={`relative group rounded overflow-hidden border cursor-pointer transition ${isSel ? "ring-2 ring-primary" : ""}`} |
| onClick={() => setLightbox(p)} |
| > |
| <div className="aspect-square bg-muted"> |
| <img |
| src={p.previewUrl} |
| alt={`post ${p.id}`} |
| loading="lazy" |
| className="w-full h-full object-cover" |
| onError={(e) => (e.currentTarget.style.opacity = "0.2")} |
| /> |
| </div> |
| <div |
| className="absolute top-1 left-1" |
| onClick={(e) => { |
| e.stopPropagation(); |
| toggle(p.id); |
| }} |
| > |
| <Checkbox checked={isSel} className="bg-background/90 border-2" /> |
| </div> |
| <div className="absolute bottom-1 right-1 flex gap-1"> |
| {p.rating && ( |
| <Badge variant="secondary" className="text-[10px] px-1 py-0"> |
| {p.rating} |
| </Badge> |
| )} |
| {p.score !== undefined && ( |
| <Badge variant="secondary" className="text-[10px] px-1 py-0"> |
| ★{p.score} |
| </Badge> |
| )} |
| </div> |
| </div> |
| ); |
| })} |
| </div> |
| </main> |
| |
| <AddSiteDialog |
| open={addOpen} |
| onOpenChange={setAddOpen} |
| onAdd={(s) => { |
| addSite(s); |
| setSiteId(s.id); |
| }} |
| /> |
| <Lightbox post={lightbox} onClose={() => setLightbox(null)} /> |
| </div> |
| ); |
| } |
|
|