Spaces:
Paused
Paused
| import { useState } from "react"; | |
| import type { SiteConfig } from "@/lib/grabber/sites"; | |
| import { | |
| Dialog, | |
| DialogContent, | |
| DialogHeader, | |
| DialogTitle, | |
| DialogFooter, | |
| } from "@/components/ui/dialog"; | |
| import { Input } from "@/components/ui/input"; | |
| import { Label } from "@/components/ui/label"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Textarea } from "@/components/ui/textarea"; | |
| type Props = { | |
| open: boolean; | |
| onOpenChange: (v: boolean) => void; | |
| onAdd: (site: SiteConfig) => void; | |
| }; | |
| const DEFAULT: SiteConfig = { | |
| id: "danbooru", | |
| name: "Danbooru", | |
| baseUrl: "https://danbooru.donmai.us", | |
| searchPath: "/posts.json", | |
| params: { tags: "tags", page: "page", limit: "limit" }, | |
| postsPath: "", | |
| fields: { | |
| id: "id", | |
| previewUrl: "preview_file_url", | |
| fileUrl: "file_url", | |
| ext: "file_ext", | |
| tags: "tag_string_general", | |
| source: "source", | |
| rating: "rating", | |
| score: "score", | |
| sampleUrl: "large_file_url", | |
| fileSize: "file_size", | |
| width: "image_width", | |
| height: "image_height", | |
| md5: "md5", | |
| uploader: "uploader_name", | |
| createdAt: "created_at", | |
| }, | |
| }; | |
| export function AddSiteDialog({ open, onOpenChange, onAdd }: Props) { | |
| const [s, setS] = useState<SiteConfig>(DEFAULT); | |
| const [headersText, setHeadersText] = useState(""); | |
| const [pasteSchemaText, setPasteSchemaText] = useState(""); | |
| const [parseError, setParseError] = useState(""); | |
| const set = <K extends keyof SiteConfig>(k: K, v: SiteConfig[K]) => | |
| setS((p) => ({ ...p, [k]: v })); | |
| const setField = (k: keyof SiteConfig["fields"], v: string) => | |
| setS((p) => ({ ...p, fields: { ...p.fields, [k]: v } })); | |
| const setParam = (k: keyof SiteConfig["params"], v: string) => | |
| setS((p) => ({ ...p, params: { ...p.params, [k]: v } })); | |
| const handlePasteSchema = () => { | |
| setParseError(""); | |
| if (!pasteSchemaText.trim()) return; | |
| try { | |
| const parsed = JSON.parse(pasteSchemaText); | |
| if (typeof parsed !== "object" || parsed === null) { | |
| throw new Error("Schema must be an object"); | |
| } | |
| const merged: SiteConfig = { | |
| id: parsed.id || s.id, | |
| name: parsed.name || s.name, | |
| baseUrl: parsed.baseUrl || s.baseUrl, | |
| searchPath: parsed.searchPath || s.searchPath, | |
| params: { | |
| tags: parsed.params?.tags || s.params.tags, | |
| page: parsed.params?.page || s.params.page, | |
| limit: parsed.params?.limit || s.params.limit, | |
| }, | |
| postsPath: typeof parsed.postsPath === "string" ? parsed.postsPath : s.postsPath, | |
| fields: { | |
| id: parsed.fields?.id || s.fields.id, | |
| previewUrl: parsed.fields?.previewUrl || s.fields.previewUrl, | |
| fileUrl: parsed.fields?.fileUrl || s.fields.fileUrl, | |
| ext: parsed.fields?.ext || s.fields.ext, | |
| tags: parsed.fields?.tags || s.fields.tags, | |
| source: parsed.fields?.source || s.fields.source, | |
| rating: parsed.fields?.rating || s.fields.rating, | |
| score: parsed.fields?.score || s.fields.score, | |
| sampleUrl: parsed.fields?.sampleUrl || s.fields.sampleUrl, | |
| fileSize: parsed.fields?.fileSize || s.fields.fileSize, | |
| width: parsed.fields?.width || s.fields.width, | |
| height: parsed.fields?.height || s.fields.height, | |
| md5: parsed.fields?.md5 || s.fields.md5, | |
| uploader: parsed.fields?.uploader || s.fields.uploader, | |
| createdAt: parsed.fields?.createdAt || s.fields.createdAt, | |
| }, | |
| }; | |
| if (parsed.headers && typeof parsed.headers === "object") { | |
| setHeadersText(JSON.stringify(parsed.headers, null, 2)); | |
| } | |
| setS(merged); | |
| setPasteSchemaText(""); | |
| } catch (e: unknown) { | |
| if (e instanceof Error) { | |
| setParseError(e.message); | |
| } else { | |
| setParseError("Failed to parse JSON"); | |
| } | |
| } | |
| }; | |
| const submit = () => { | |
| let headers: Record<string, string> | undefined; | |
| if (headersText.trim()) { | |
| try { | |
| headers = JSON.parse(headersText); | |
| } catch { | |
| alert("Headers must be valid JSON"); | |
| return; | |
| } | |
| } | |
| onAdd({ ...s, headers }); | |
| onOpenChange(false); | |
| }; | |
| return ( | |
| <Dialog open={open} onOpenChange={onOpenChange}> | |
| <DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto"> | |
| <DialogHeader> | |
| <DialogTitle>Add Site (schema)</DialogTitle> | |
| </DialogHeader> | |
| {/* New Paste Schema Input Box */} | |
| <div className="border p-3 rounded-md bg-muted/30 mb-4"> | |
| <Label className="text-xs font-semibold mb-1 block"> | |
| Paste Site Configuration JSON (API Support) | |
| </Label> | |
| <div className="flex gap-2"> | |
| <Textarea | |
| rows={3} | |
| value={pasteSchemaText} | |
| onChange={(e) => setPasteSchemaText(e.target.value)} | |
| placeholder={`Paste JSON schema here, e.g.:\n{\n "id": "danbooru",\n "name": "Danbooru",\n "baseUrl": "https://danbooru.donmai.us",\n "fields": { "fileUrl": "file_url" }\n}`} | |
| className="font-mono text-xs flex-1" | |
| /> | |
| <Button variant="secondary" size="sm" onClick={handlePasteSchema} className="self-end"> | |
| Apply Schema | |
| </Button> | |
| </div> | |
| {parseError && ( | |
| <div className="text-xs text-destructive mt-1 font-semibold">{parseError}</div> | |
| )} | |
| </div> | |
| <div className="grid grid-cols-2 gap-3"> | |
| <Field label="ID (unique)"> | |
| <Input value={s.id} onChange={(e) => set("id", e.target.value)} /> | |
| </Field> | |
| <Field label="Name"> | |
| <Input value={s.name} onChange={(e) => set("name", e.target.value)} /> | |
| </Field> | |
| <Field label="Base URL"> | |
| <Input value={s.baseUrl} onChange={(e) => set("baseUrl", e.target.value)} /> | |
| </Field> | |
| <Field label="Search Path"> | |
| <Input value={s.searchPath} onChange={(e) => set("searchPath", e.target.value)} /> | |
| </Field> | |
| <Field label="tags param"> | |
| <Input value={s.params.tags} onChange={(e) => setParam("tags", e.target.value)} /> | |
| </Field> | |
| <Field label="page param"> | |
| <Input value={s.params.page} onChange={(e) => setParam("page", e.target.value)} /> | |
| </Field> | |
| <Field label="limit param"> | |
| <Input value={s.params.limit} onChange={(e) => setParam("limit", e.target.value)} /> | |
| </Field> | |
| <Field label="posts JSON path (empty = root)"> | |
| <Input value={s.postsPath} onChange={(e) => set("postsPath", e.target.value)} /> | |
| </Field> | |
| <div className="col-span-2 border-t pt-3 mt-1 text-sm font-semibold"> | |
| Field mappings (dotted paths) | |
| </div> | |
| {( | |
| [ | |
| "id", | |
| "previewUrl", | |
| "fileUrl", | |
| "ext", | |
| "tags", | |
| "source", | |
| "rating", | |
| "score", | |
| "sampleUrl", | |
| "fileSize", | |
| "width", | |
| "height", | |
| "md5", | |
| "uploader", | |
| "createdAt", | |
| ] as const | |
| ).map((k) => ( | |
| <Field key={k} label={k}> | |
| <Input value={s.fields[k] ?? ""} onChange={(e) => setField(k, e.target.value)} /> | |
| </Field> | |
| ))} | |
| <Field label="Extra headers (JSON, optional)" full> | |
| <Textarea | |
| rows={3} | |
| value={headersText} | |
| onChange={(e) => setHeadersText(e.target.value)} | |
| placeholder='{"User-Agent":"MyGrabber/1.0"}' | |
| /> | |
| </Field> | |
| </div> | |
| <DialogFooter> | |
| <Button variant="outline" onClick={() => onOpenChange(false)}> | |
| Cancel | |
| </Button> | |
| <Button onClick={submit}>Add site</Button> | |
| </DialogFooter> | |
| </DialogContent> | |
| </Dialog> | |
| ); | |
| } | |
| function Field({ | |
| label, | |
| children, | |
| full, | |
| }: { | |
| label: string; | |
| children: React.ReactNode; | |
| full?: boolean; | |
| }) { | |
| return ( | |
| <div className={full ? "col-span-2" : ""}> | |
| <Label className="text-xs mb-1 block">{label}</Label> | |
| {children} | |
| </div> | |
| ); | |
| } | |