launch-calcium's picture
Upload folder using huggingface_hub
a35961c verified
Raw
History Blame Contribute Delete
25.3 kB
import { createFileRoute } from "@tanstack/react-router";
import { useState, useMemo, useEffect } 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 [customFilename, setCustomFilename] = useState("");
const [customSubfolder, setCustomSubfolder] = useState("");
const [e621Username, setE621Username] = useState("");
const [e621ApiKey, setE621ApiKey] = useState("");
const [autocompleteEnabled, setAutocompleteEnabled] = useState(true);
const [activeTagFile, setActiveTagFile] = useState("tags-selected.csv");
const [availableTagFiles, setAvailableTagFiles] = useState<string[]>([]);
const [newTagFileUrl, setNewTagFileUrl] = useState("");
const [downloadingTagFile, setDownloadingTagFile] = useState(false);
const [tagSuggestions, setTagSuggestions] = useState<{ name: string; count: number }[]>([]);
const [activeWordInfo, setActiveWordInfo] = useState<{ word: string; start: number; end: number } | null>(null);
const [focusedSuggestionIdx, setFocusedSuggestionIdx] = useState(-1);
const fetchTagFiles = async () => {
try {
const res = await fetch("/api/tags?action=list");
const data = await res.json();
if (data.success && Array.isArray(data.files)) {
setAvailableTagFiles(data.files);
if (data.files.length > 0 && !data.files.includes(activeTagFile)) {
// Default to tags-selected.csv if available
if (data.files.includes("tags-selected.csv")) {
setActiveTagFile("tags-selected.csv");
} else {
setActiveTagFile(data.files[0]);
}
}
}
} catch (err) {
console.error("Failed to list tag files:", err);
}
};
const handleDownloadTagFile = async () => {
if (!newTagFileUrl.trim()) return;
setDownloadingTagFile(true);
try {
const res = await fetch("/api/tags", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ downloadUrl: newTagFileUrl.trim() }),
});
const data = await res.json();
if (!res.ok) {
alert(`Error: ${data.error}`);
} else {
alert(`Successfully downloaded and decompressed ${data.filename}!`);
setNewTagFileUrl("");
await fetchTagFiles();
setActiveTagFile(data.filename);
}
} catch (err) {
alert(`Download failed: ${err instanceof Error ? err.message : "unknown error"}`);
} finally {
setDownloadingTagFile(false);
}
};
const updateActiveWord = (el: HTMLInputElement) => {
const val = el.value;
const pos = el.selectionStart;
if (pos === null || !autocompleteEnabled) {
setTagSuggestions([]);
setActiveWordInfo(null);
return;
}
// Find start and end of word under cursor
let start = pos;
while (start > 0 && !/\s/.test(val[start - 1])) {
start--;
}
let end = pos;
while (end < val.length && !/\s/.test(val[end])) {
end++;
}
const word = val.slice(start, end).trim();
// Exclude colon commands like rating:safe
if (word && !word.includes(":")) {
setActiveWordInfo({ word, start, end });
} else {
setTagSuggestions([]);
setActiveWordInfo(null);
}
};
const selectSuggestion = (tagName: string) => {
if (!activeWordInfo) return;
const before = tags.slice(0, activeWordInfo.start);
const after = tags.slice(activeWordInfo.end);
// Replace word and add a trailing space
const newTags = before + tagName + " " + (after.trim() ? after : "");
setTags(newTags);
setTagSuggestions([]);
setActiveWordInfo(null);
// Refocus input and place cursor after inserted word + space
const inputEl = document.getElementById("search-tags-input") as HTMLInputElement;
if (inputEl) {
inputEl.focus();
const cursorTarget = before.length + tagName.length + 1;
setTimeout(() => {
inputEl.setSelectionRange(cursorTarget, cursorTarget);
}, 0);
}
};
const handleAutocompleteKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (tagSuggestions.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setFocusedSuggestionIdx((prev) => (prev + 1) % tagSuggestions.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setFocusedSuggestionIdx((prev) => (prev - 1 + tagSuggestions.length) % tagSuggestions.length);
} else if (e.key === "Enter") {
if (focusedSuggestionIdx >= 0 && focusedSuggestionIdx < tagSuggestions.length) {
e.preventDefault();
selectSuggestion(tagSuggestions[focusedSuggestionIdx].name);
} else {
runSearch(1);
}
} else if (e.key === "Escape") {
e.preventDefault();
setTagSuggestions([]);
setActiveWordInfo(null);
}
} else {
if (e.key === "Enter") {
runSearch(1);
}
}
};
useEffect(() => {
if (typeof window !== "undefined") {
setE621Username(localStorage.getItem("e621_username") || "");
setE621ApiKey(localStorage.getItem("e621_api_key") || "");
}
fetchTagFiles();
}, []);
useEffect(() => {
if (!autocompleteEnabled || !activeWordInfo || !activeWordInfo.word) {
setTagSuggestions([]);
return;
}
const delay = setTimeout(async () => {
try {
const res = await fetch(
`/api/tags?action=autocomplete&query=${encodeURIComponent(
activeWordInfo.word
)}&file=${encodeURIComponent(activeTagFile)}&limit=15`
);
const data = await res.json();
if (data.success && Array.isArray(data.suggestions)) {
setTagSuggestions(data.suggestions);
setFocusedSuggestionIdx(-1);
}
} catch (err) {
console.error("Autocomplete fetch failed:", err);
}
}, 150);
return () => clearTimeout(delay);
}, [activeWordInfo, activeTagFile, autocompleteEnabled]);
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,
login: site.id === "e621" ? e621Username : undefined,
apiKey: site.id === "e621" ? e621ApiKey : undefined,
},
});
},
});
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),
zipName: customFilename.trim() || undefined,
}),
});
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;
let finalName = "";
if (customFilename.trim()) {
finalName = customFilename.trim();
if (!finalName.endsWith(".zip")) {
finalName += ".zip";
}
} else {
finalName = `grabber-${site.id}-${Date.now()}.zip`;
}
a.download = finalName;
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(),
zipName: customFilename.trim() || undefined,
subfolder: customSubfolder.trim() || undefined,
}),
});
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">
<div className="relative flex-1 min-w-64 z-20">
<Input
id="search-tags-input"
placeholder="tags (e.g. rating:safe fluffy)"
value={tags}
onChange={(e) => {
setTags(e.target.value);
updateActiveWord(e.target);
}}
onKeyUp={(e) => updateActiveWord(e.currentTarget)}
onSelect={(e) => updateActiveWord(e.currentTarget)}
onFocus={(e) => updateActiveWord(e.currentTarget)}
onBlur={() => {
// Short timeout to allow clicking suggestion buttons
setTimeout(() => {
setTagSuggestions([]);
setActiveWordInfo(null);
}, 200);
}}
onKeyDown={handleAutocompleteKeyDown}
className="w-full font-sans text-sm"
autoComplete="off"
/>
{tagSuggestions.length > 0 && (
<div className="absolute left-0 right-0 top-full mt-1 max-h-60 overflow-y-auto bg-popover text-popover-foreground border rounded-md shadow-lg z-50">
{tagSuggestions.map((suggestion, idx) => (
<button
key={suggestion.name}
type="button"
className={`w-full text-left px-3 py-1.5 text-xs flex justify-between items-center transition hover:bg-accent hover:text-accent-foreground ${
idx === focusedSuggestionIdx ? "bg-accent text-accent-foreground" : ""
}`}
onClick={() => selectSuggestion(suggestion.name)}
>
<span className="font-medium">{suggestion.name}</span>
<span className="text-[10px] text-muted-foreground font-mono">
{suggestion.count.toLocaleString()}
</span>
</button>
))}
</div>
)}
</div>
<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="Custom ZIP / Filename"
value={customFilename}
onChange={(e) => setCustomFilename(e.target.value)}
className="w-64 max-w-xs"
/>
<Input
placeholder="Exclude tags from ZIP"
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>
{/* e621 Authentication Panel */}
{siteId === "e621" && (
<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">
e621 Authentication:
</span>
<Input
placeholder="Username"
value={e621Username}
onChange={(e) => {
setE621Username(e.target.value);
localStorage.setItem("e621_username", e.target.value);
}}
className="w-48 max-w-xs"
/>
<Input
type="password"
placeholder="API Key"
value={e621ApiKey}
onChange={(e) => {
setE621ApiKey(e.target.value);
localStorage.setItem("e621_api_key", e.target.value);
}}
className="w-48 max-w-xs"
/>
<span className="text-xs text-muted-foreground italic">
(Credentials stored only in your local browser storage)
</span>
</div>
)}
{/* Autocomplete Settings 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">
Autocomplete Settings:
</span>
<div className="flex items-center gap-2 mr-4">
<label htmlFor="autocomplete-toggle" className="text-xs font-medium cursor-pointer">
ON
</label>
<Checkbox
id="autocomplete-toggle"
checked={autocompleteEnabled}
onCheckedChange={(checked) => setAutocompleteEnabled(!!checked)}
/>
</div>
{autocompleteEnabled && availableTagFiles.length > 0 && (
<>
<span className="text-xs text-muted-foreground mr-1">Tags File:</span>
<Select value={activeTagFile} onValueChange={setActiveTagFile}>
<SelectTrigger className="w-48 h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableTagFiles.map((file) => (
<SelectItem key={file} value={file} className="text-xs">
{file}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
<span className="text-xs text-muted-foreground ml-2 mr-1">Download Tags Link:</span>
<Input
placeholder="https://.../tags.csv.gz"
value={newTagFileUrl}
onChange={(e) => setNewTagFileUrl(e.target.value)}
className="w-64 max-w-xs h-9 text-xs"
/>
<Button
onClick={handleDownloadTagFile}
disabled={downloadingTagFile || !newTagFileUrl.trim()}
variant="outline"
size="sm"
className="h-9 text-xs"
>
{downloadingTagFile ? (
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
) : (
<Download className="w-3 h-3 mr-1" />
)}
Download
</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"
/>
<Input
placeholder="Custom Subfolder Path (optional)"
value={customSubfolder}
onChange={(e) => setCustomSubfolder(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>
);
}