Spaces:
Sleeping
Sleeping
| 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> | |
| ); | |
| } | |