| 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> |
| ); |
| } |
|
|