"use client"; import { Check } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useMounted } from "@/components/theme"; import { Badge, Button } from "@/components/ui"; import { cn } from "@/components/ui/cn"; import { adminFetch, adminFetchText, type ConfigListItem, type DevtoolFetchResult, type DevtoolPreviewResult, type RunSampleResult, type SaveConfigResult, type ValidateConfigResult } from "@/lib/admin-api"; import { ErrorNote, Mono, Select, Spinner } from "../primitives"; import { AdminPageHeader } from "../shell"; import { useToast } from "../toast"; import { useAdminQuery } from "../use-admin-query"; import { useUrlState } from "../use-url-state"; import { buildYaml, draftFromConfig, EMPTY_DRAFT, loadDraft, saveDraft, type DevtoolShared, type Draft } from "./state"; import { StepDeploy } from "./step-deploy"; import { StepFetch } from "./step-fetch"; import { StepPreview } from "./step-preview"; import { StepRules } from "./step-rules"; import { StepSample } from "./step-sample"; import { StepSave } from "./step-save"; const STEPS = [ { n: 1, label: "Fetch", short: "Fetch sample" }, { n: 2, label: "Rules", short: "Extraction rules" }, { n: 3, label: "Preview", short: "Preview fields" }, { n: 4, label: "Save", short: "Save connector" }, { n: 5, label: "Sample", short: "Run on pages" }, { n: 6, label: "Deploy", short: "Compare & deploy" }, ] as const; export function DevTool() { const [params, setParams] = useUrlState(); const step = Math.min(6, Math.max(1, Number(params.step ?? 1) || 1)); // Draft is read lazily from localStorage (+ ?url=) on the client; the server render and the first client render // both return null (useMounted) so there is no hydration mismatch and no setState inside an effect. const hydrated = useMounted(); const [draft, setDraft] = useState(() => { if (typeof window === "undefined") return EMPTY_DRAFT; const stored = loadDraft() ?? EMPTY_DRAFT; const u = new URLSearchParams(window.location.search).get("url"); return u ? { ...stored, fetch: { ...stored.fetch, url: u } } : stored; }); const [initialId] = useState(() => (typeof window === "undefined" ? null : new URLSearchParams(window.location.search).get("id"))); const [fetchResult, setFetchResult] = useState(null); const [previewResult, setPreviewResult] = useState(null); const [validation, setValidation] = useState(null); const [saveResult, setSaveResult] = useState(null); const [sampleResult, setSampleResult] = useState(null); const [loadingId, setLoadingId] = useState(null); const [loadError, setLoadError] = useState(null); const toast = useToast(); const configs = useAdminQuery("devtool/configs", { timeoutMs: 30_000 }); // ?id= different from the stored draft → load it once after mount const requestedId = useRef(initialId && initialId !== draft.meta.id ? initialId : null); // persist (debounced) useEffect(() => { if (!hydrated) return; const t = setTimeout(() => saveDraft(draft), 300); return () => clearTimeout(t); }, [draft, hydrated]); const update = useCallback((patch: Partial | ((d: Draft) => Draft)) => { setDraft((d) => (typeof patch === "function" ? patch(d) : { ...d, ...patch })); }, []); const goto = useCallback( (n: number) => { setParams({ step: n === 1 ? null : n, url: draft.fetch.url || null, id: draft.meta.id || null }, { push: true }); }, [setParams, draft.fetch.url, draft.meta.id], ); // keep url/id in the address bar in sync (replace, no history) useEffect(() => { if (!hydrated) return; const wantUrl = draft.fetch.url || null; const wantId = draft.meta.id || null; if ((params.url ?? null) !== wantUrl || (params.id ?? null) !== wantId) setParams({ url: wantUrl, id: wantId }); }, [draft.fetch.url, draft.meta.id, hydrated, params.url, params.id, setParams]); const loadConnector = useCallback( async (id: string) => { if (!id) return; setLoadingId(id); setLoadError(null); const text = await adminFetchText(`devtool/configs/${encodeURIComponent(id)}`); if (!text.ok) { setLoadingId(null); setLoadError(`Could not load ${id}.yaml: ${text.error}`); return; } const v = await adminFetch("devtool/validate-config", { method: "POST", body: { yaml: text.text } }); setLoadingId(null); if (!v.ok) { setLoadError(`validate-config failed: ${v.error}`); return; } setValidation(v.data); if (v.data.ok) { setDraft((d) => draftFromConfig(v.data.ok ? v.data.config : {}, text.text, { ...d, fetch: { ...d.fetch, url: "" } })); toast({ tone: "ok", title: `Loaded ${id}`, description: `${Object.keys(v.data.config.extractors as object).length} extractor(s) · parser ${String(v.data.config.parserVersion)}` }); } else { setDraft((d) => ({ ...d, yaml: text.text, yamlDirty: true, meta: { ...d.meta, id } })); toast({ tone: "warn", title: `Loaded ${id} with schema errors`, description: v.data.errors.map((e) => `${e.path}: ${e.message}`).join("; ").slice(0, 200) }); setParams({ step: 4 }); } setFetchResult(null); setPreviewResult(null); setSaveResult(null); setSampleResult(null); }, [toast, setParams], ); useEffect(() => { if (!hydrated || !requestedId.current) return; const id = requestedId.current; requestedId.current = null; const t = setTimeout(() => void loadConnector(id), 0); return () => clearTimeout(t); }, [hydrated, loadConnector]); const reset = () => { if (!window.confirm("Clear the current draft (rules, YAML, sample URLs)?")) return; setDraft(EMPTY_DRAFT); setFetchResult(null); setPreviewResult(null); setValidation(null); setSaveResult(null); setSampleResult(null); setParams({ step: null, url: null, id: null }); }; const shared: DevtoolShared = useMemo( () => ({ draft, update, fetchResult, setFetchResult, previewResult, setPreviewResult, validation, setValidation, saveResult, setSaveResult, sampleResult, setSampleResult, goto }), [draft, update, fetchResult, previewResult, validation, saveResult, sampleResult, goto], ); const done = { 1: Boolean(fetchResult && !fetchResult.error), 2: draft.extractor.fields.some((f) => f.name.trim()), 3: Boolean(previewResult && previewResult.entities.length > 0), 4: Boolean(saveResult), 5: Boolean(sampleResult), 6: false, } as Record; if (!hydrated) return null; return (
{loadingId && }
} />
{draft.meta.id ? ( Editing {draft.meta.id} ) : ( New connector draft )} {draft.fetch.url && ( · {draft.fetch.url} )} {draft.savedAt && draft kept in this browser}
    {STEPS.map((s) => { const active = s.n === step; return (
  1. ); })}
{step === 1 && } {step === 2 && } {step === 3 && } {step === 4 && } {step === 5 && } {step === 6 && void configs.refresh()} />}
{step === 3 && !draft.yaml && ( the YAML template is generated when you enter step 4 )}
); }