diff --git a/src/components/layout/app-layout.tsx b/src/components/layout/app-layout.tsx index e45122c7..ff30e565 100644 --- a/src/components/layout/app-layout.tsx +++ b/src/components/layout/app-layout.tsx @@ -3,11 +3,9 @@ import { useEffect } from "react" import { LeftPane } from "./left-pane" import { GraphPane } from "@/components/universe/graph-pane" -import { AddContentModal } from "@/components/modals/add-content-modal" +import { AddModal } from "@/components/modals/add-modal" import { BudgetModal } from "@/components/modals/budget-modal" -import { AddNodeModal } from "@/components/modals/add-node-modal" import { EditNodeModal } from "@/components/modals/edit-node-modal" -import { AddEdgeModal } from "@/components/modals/add-edge-modal" import { MediaPlayer } from "@/components/player/media-player" import { useDefaultLayout } from "react-resizable-panels" import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable" @@ -53,10 +51,8 @@ export function AppLayout() { - - + - diff --git a/src/components/layout/my-content-panel.tsx b/src/components/layout/my-content-panel.tsx index d6b8b0c1..819b5f3c 100644 --- a/src/components/layout/my-content-panel.tsx +++ b/src/components/layout/my-content-panel.tsx @@ -49,7 +49,7 @@ export function MyContentPanel({ onClose }: { onClose: () => void }) { const { pubKey, isAdmin } = useUserStore() const myContentRefreshKey = useAppStore((s) => s.myContentRefreshKey) const schemas = useSchemaStore((s) => s.schemas) - const openModal = useModalStore((s) => s.open) + const openAdd = useModalStore((s) => s.openAdd) const setHoveredNode = useGraphStore((s) => s.setHoveredNode) const setSidebarSelectedNode = useGraphStore((s) => s.setSidebarSelectedNode) const mocksEnabled = isMocksEnabled() @@ -379,7 +379,7 @@ export function MyContentPanel({ onClose }: { onClose: () => void }) { Add content and start earning money for contributing

+ + + + ) +} diff --git a/src/components/modals/add-edge-modal.tsx b/src/components/modals/add-edge-modal.tsx deleted file mode 100644 index 2032fa73..00000000 --- a/src/components/modals/add-edge-modal.tsx +++ /dev/null @@ -1,191 +0,0 @@ -"use client" - -import { useCallback, useEffect, useMemo, useState } from "react" -import { CheckCircle } from "lucide-react" -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { SelectCustom } from "@/components/ui/select-custom" -import { NodeSearchInput } from "@/components/ui/node-search-input" -import { useModalStore } from "@/stores/modal-store" -import { useSchemaStore } from "@/stores/schema-store" -import { createEdge, type GraphNode } from "@/lib/graph-api" - -type Status = "idle" | "submitting" | "success" | "error" - -export function AddEdgeModal() { - const activeModal = useModalStore((s) => s.activeModal) - const storeSourceNode = useModalStore((s) => s.sourceNode) - const close = useModalStore((s) => s.close) - - const schemaEdges = useSchemaStore((s) => s.edges) - - const [selectedSource, setSelectedSource] = useState(null) - const [selectedTarget, setSelectedTarget] = useState(null) - const [edgeType, setEdgeType] = useState("") - const [status, setStatus] = useState("idle") - const [errorMsg, setErrorMsg] = useState(null) - - const isOpen = activeModal === "addEdge" - - // Sync selectedSource when modal opens with a pre-filled sourceNode - useEffect(() => { - if (isOpen) { - setSelectedSource(storeSourceNode ?? null) - setSelectedTarget(null) - setEdgeType("") - setStatus("idle") - setErrorMsg(null) - } - }, [isOpen, storeSourceNode]) - - // Derive unique edge types excluding CHILD_OF, computed once when modal opens - const edgeTypeOptions = useMemo(() => { - const seen = new Set() - const options: { value: string; label: string }[] = [] - for (const e of schemaEdges) { - if (e.edge_type && e.edge_type !== "CHILD_OF" && !seen.has(e.edge_type)) { - seen.add(e.edge_type) - options.push({ value: e.edge_type, label: e.edge_type }) - } - } - return options.sort((a, b) => a.label.localeCompare(b.label)) - }, [schemaEdges]) - - const handleClose = useCallback(() => { - setSelectedSource(null) - setSelectedTarget(null) - setEdgeType("") - setStatus("idle") - setErrorMsg(null) - close() - }, [close]) - - const handleSubmit = useCallback( - async (e: React.FormEvent) => { - e.preventDefault() - - if (!selectedSource || !selectedTarget || !edgeType) { - setErrorMsg("All three fields are required.") - return - } - - setStatus("submitting") - setErrorMsg(null) - - try { - await createEdge({ - source: selectedSource.ref_id, - target: selectedTarget.ref_id, - edge_type: edgeType, - }) - setStatus("success") - setTimeout(() => handleClose(), 1500) - } catch (err) { - setStatus("error") - if (err instanceof Response) { - const body = await err.json().catch(() => null) as { message?: string; error?: string } | null - setErrorMsg(body?.message || body?.error || `Request failed (HTTP ${err.status})`) - } else if (err instanceof Error) { - setErrorMsg(err.message || "Something went wrong. Please try again.") - } else { - setErrorMsg("Something went wrong. Please try again.") - } - } - }, - [selectedSource, selectedTarget, edgeType, handleClose] - ) - - const busy = status === "submitting" || status === "success" - - return ( - !open && handleClose()}> - - - - Add Edge - - - Create a relationship between two graph nodes. - - - -
- {/* Source node */} -
- - { setSelectedSource(node); setErrorMsg(null) }} - placeholder="Search source node…" - disabled={busy} - /> -
- - {/* Target node */} -
- - { setSelectedTarget(node); setErrorMsg(null) }} - placeholder="Search target node…" - disabled={busy} - /> -
- - {/* Edge type */} -
- - {edgeTypeOptions.length === 0 ? ( -
- No edge types available. Load schemas first. -
- ) : ( - { setEdgeType(v); setErrorMsg(null) }} - options={edgeTypeOptions} - placeholder="Choose an edge type..." - /> - )} -
- - {/* Error */} - {errorMsg && ( -

{errorMsg}

- )} - - {/* Success */} - {status === "success" && ( -
- - Edge created! -
- )} - - {/* Submit */} -
- -
-
-
-
- ) -} diff --git a/src/components/modals/add-modal.tsx b/src/components/modals/add-modal.tsx new file mode 100644 index 00000000..ac8a14df --- /dev/null +++ b/src/components/modals/add-modal.tsx @@ -0,0 +1,80 @@ +"use client" + +import { Sparkles, Workflow, GitMerge } from "lucide-react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog" +import { cn } from "@/lib/utils" +import { useModalStore, type AddTab } from "@/stores/modal-store" +import { AddSourceForm } from "@/components/modals/add-source-form" +import { AddNodeForm } from "@/components/modals/add-node-form" +import { AddEdgeForm } from "@/components/modals/add-edge-form" + +// All three modes are open to everyone — like Add Node, edge creation is a +// paid action gated by sats (handled in the form), not by role. +const TABS: { id: AddTab; label: string; icon: React.ComponentType<{ className?: string }> }[] = [ + { id: "source", label: "Smart", icon: Sparkles }, + { id: "node", label: "Node", icon: Workflow }, + { id: "edge", label: "Edge", icon: GitMerge }, +] + +export function AddModal() { + const activeModal = useModalStore((s) => s.activeModal) + const tab = useModalStore((s) => s.addTab) + const setTab = useModalStore((s) => s.setAddTab) + const close = useModalStore((s) => s.close) + + const isOpen = activeModal === "add" + + // Guard against a stale/invalid tab id. + const activeTab = TABS.some((t) => t.id === tab) ? tab : "source" + + return ( + !open && close()}> + + + + Add to graph + + + Paste a link and we'll build it for you — or switch modes to create a node or edge by hand. + + + + {/* Segmented control */} +
+ {TABS.map((t) => { + const Icon = t.icon + const active = t.id === activeTab + return ( + + ) + })} +
+ + {/* Only the active tab is mounted — gives each form fresh state and + re-runs its on-mount fetches when selected. */} + {activeTab === "source" && } + {activeTab === "node" && } + {activeTab === "edge" && } +
+
+ ) +} diff --git a/src/components/modals/add-node-form.tsx b/src/components/modals/add-node-form.tsx new file mode 100644 index 00000000..14401e40 --- /dev/null +++ b/src/components/modals/add-node-form.tsx @@ -0,0 +1,570 @@ +"use client" + +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { ChevronRight } from "lucide-react" +import { Button } from "@/components/ui/button" +import { SelectCustom } from "@/components/ui/select-custom" +import { useModalStore } from "@/stores/modal-store" +import { useUserStore } from "@/stores/user-store" +import { useSchemaStore } from "@/stores/schema-store" +import { getPrice, payL402 } from "@/lib/sphinx" +import { + ALLOWED_IMAGE_TYPES, + MAX_IMAGE_UPLOAD_BYTES, + addImageContent, + checkNodeExists, + createNode, + getSchemaDomains, + type SchemaDomainsResponse, +} from "@/lib/graph-api" +import type { SchemaNode, SchemaAttribute } from "@/app/ontology/page" +import { + fieldsForSchema, + humanizeFieldKey, + fieldTypeHint, + categorizeField, + OPTIONAL_GROUP_ORDER, + OPTIONAL_GROUP_LABELS, +} from "@/lib/node-schema-utils" +import { cn } from "@/lib/utils" + +type Status = "idle" | "checking" | "submitting" | "success" | "error" | "uploading" + +// Image is special-cased: the user picks a file directly in this modal and a +// single multipart POST to /v2/content/image handles upload + node creation + +// Stakwork dispatch. source_link/url are minted server-side from the upload, +// so we hide those form fields entirely. +const IMAGE_TYPE = "Image" +const IMAGE_AUTO_FIELDS = new Set(["source_link", "url"]) + +// Pre-submit gate. Backend re-validates with the same thresholds — these are +// just here to catch obvious mistakes before burning a multipart roundtrip. +const ALLOWED_IMAGE_TYPE_SET = new Set(ALLOWED_IMAGE_TYPES) + +// Backend stores node_key as `{typeLower}-{attribute}` (e.g. "image-source_link", +// "transport-name"). The actual attribute name is the part after the dash. +function actualKeyField(schema: SchemaNode): string { + const raw = schema.node_key || "name" + const prefix = `${schema.type.toLowerCase()}-` + return raw.startsWith(prefix) ? raw.slice(prefix.length) : raw +} + +// Coerce a raw form value into the JSON shape the backend wants. Empty +// strings become "not provided" — dropped from the payload entirely. +function parseFieldValue(type: string, raw: string): unknown { + const t = raw.trim() + if (t === "") return undefined + if (type === "int" || type === "integer") { + const n = Number(t) + return Number.isFinite(n) ? Math.trunc(n) : t + } + if (type === "float" || type === "number") { + const n = Number(t) + return Number.isFinite(n) ? n : t + } + if (type === "bool" || type === "boolean") { + return t.toLowerCase() === "true" || t === "1" + } + return t +} + +export function AddNodeForm() { + const { close } = useModalStore() + const setBudget = useUserStore((s) => s.setBudget) + const pubKey = useUserStore((s) => s.pubKey) + const schemas = useSchemaStore((s) => s.schemas) + + const [selectedType, setSelectedType] = useState("") + const [fieldValues, setFieldValues] = useState>({}) + const [domains, setDomains] = useState(null) + const [price, setPrice] = useState(null) + const [status, setStatus] = useState("idle") + const [errorMsg, setErrorMsg] = useState(null) + // Whether optional fields are expanded. Required fields always show. + const [showOptional, setShowOptional] = useState(false) + // Image-only: the file picked by the user, validated client-side before + // we hit the multipart endpoint. + const [selectedFile, setSelectedFile] = useState(null) + // Object URL for the in-modal preview. Created from the local File so the + // user sees the image before any network roundtrip. Revoked on change/close. + const [previewUrl, setPreviewUrl] = useState(null) + const abortRef = useRef(null) + + // Object-URL lifecycle is a genuine external-system sync: mint a preview URL + // when a file is picked, revoke it on change/unmount. + useEffect(() => { + if (!selectedFile) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- clears stale object-URL preview + setPreviewUrl(null) + return + } + const url = URL.createObjectURL(selectedFile) + setPreviewUrl(url) + return () => URL.revokeObjectURL(url) + }, [selectedFile]) + + // Visible schemas: drop hidden types and anything in a hidden domain. A + // schema's domain is the lowercased name of its root ancestor under Thing. + // Hidden lists arrive after the domains fetch resolves; until then, show + // every schema rather than blocking the picker. + const visibleSchemas = useMemo(() => { + const hiddenTypes = new Set(domains?.hidden_types ?? []) + const hiddenDomains = new Set(domains?.hidden_domains ?? []) + const parentOf = new Map() + for (const s of schemas) { + if (s.type && s.parent) parentOf.set(s.type, s.parent) + } + const rootDomain = (type: string): string => { + let cur = type + const seen = new Set() + while (true) { + if (seen.has(cur)) return cur + seen.add(cur) + const p = parentOf.get(cur) + if (!p || p === "Thing") return cur + cur = p + } + } + return schemas + .filter((s) => s.type && !hiddenTypes.has(s.type)) + .filter((s) => !hiddenDomains.has(rootDomain(s.type).toLowerCase())) + .slice() + .sort((a, b) => a.type.localeCompare(b.type)) + }, [schemas, domains]) + + const selectedSchema = useMemo( + () => visibleSchemas.find((s) => s.type === selectedType) ?? null, + [visibleSchemas, selectedType] + ) + + const fields = useMemo( + () => (selectedSchema ? fieldsForSchema(selectedSchema) : []), + [selectedSchema] + ) + + // What we actually render in the form. For Image, source_link/url are + // populated server-side from the upload — no user input. + const visibleFields = useMemo(() => { + if (selectedSchema?.type === IMAGE_TYPE) { + return fields.filter((f) => !IMAGE_AUTO_FIELDS.has(f.key)) + } + return fields + }, [selectedSchema, fields]) + + // Required fields show immediately; optional ones collapse behind a toggle. + const requiredFields = useMemo( + () => visibleFields.filter((f) => f.required), + [visibleFields] + ) + const optionalFields = useMemo( + () => visibleFields.filter((f) => !f.required), + [visibleFields] + ) + + // Optional attributes bucketed into labelled sections (Content / Metadata / + // Signals & scoring), preserving group order and dropping empty groups. + const optionalGroups = useMemo( + () => + OPTIONAL_GROUP_ORDER.map((group) => ({ + group, + label: OPTIONAL_GROUP_LABELS[group], + fields: optionalFields.filter((f) => categorizeField(f.key, f.type) === group), + })).filter((g) => g.fields.length > 0), + [optionalFields] + ) + const filledOptional = useMemo( + () => optionalFields.filter((f) => (fieldValues[f.key] ?? "").trim() !== "").length, + [optionalFields, fieldValues] + ) + + // Fetch price + domains on mount + useEffect(() => { + getPrice("v2/nodes", "post").then(setPrice).catch(() => setPrice(null)) + const controller = new AbortController() + getSchemaDomains(controller.signal) + .then(setDomains) + // Non-fatal: without the domains list we just show every schema. + .catch(() => setDomains(null)) + return () => controller.abort() + }, []) + + const typeOptions = useMemo( + () => visibleSchemas.map((s) => ({ value: s.type, label: s.type })), + [visibleSchemas] + ) + + const renderField = useCallback( + (f: SchemaAttribute) => ( +
+ + { + const val = e.target.value + setFieldValues((prev) => ({ ...prev, [f.key]: val })) + setErrorMsg(null) + }} + placeholder={f.required ? "Required" : "Optional"} + maxLength={1000} + disabled={status === "checking" || status === "submitting" || status === "success" || status === "uploading"} + className="h-10 w-full rounded-md border border-border/50 bg-muted/50 px-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary/40 focus:outline-none disabled:opacity-50" + /> +
+ ), + [fieldValues, status] + ) + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault() + if (!selectedSchema) { + setErrorMsg("Choose a type first") + return + } + + const isImage = selectedSchema.type === IMAGE_TYPE + + // Image flow: needs a file, and the file has to pass format + size + // gates before we burn a paid roundtrip. + if (isImage) { + if (!selectedFile) { + setErrorMsg("Pick an image to upload") + return + } + if (!ALLOWED_IMAGE_TYPE_SET.has(selectedFile.type)) { + setErrorMsg( + `Unsupported format "${selectedFile.type || "unknown"}". Allowed: JPEG, PNG, WebP, GIF.` + ) + return + } + if (selectedFile.size > MAX_IMAGE_UPLOAD_BYTES) { + const maxMb = Math.round(MAX_IMAGE_UPLOAD_BYTES / (1024 * 1024)) + const fileMb = (selectedFile.size / (1024 * 1024)).toFixed(1) + setErrorMsg(`File is ${fileMb} MB; max is ${maxMb} MB.`) + return + } + } + + // Required attributes must all have values. For Image we skip + // source_link/url since the backend mints them from the upload. + const missing = visibleFields + .filter((f) => f.required) + .filter((f) => (fieldValues[f.key] ?? "").trim() === "") + .map((f) => f.key) + if (missing.length > 0) { + setErrorMsg(`Missing required: ${missing.join(", ")}`) + return + } + + abortRef.current?.abort() + const controller = new AbortController() + abortRef.current = controller + + // Image path: single multipart POST. Backend handles upload + node + // create + Stakwork dispatch. + if (isImage) { + const name = (fieldValues["name"] ?? "").trim() || selectedFile!.name + const doUpload = async () => { + await addImageContent( + selectedFile!, + { name }, + controller.signal + ) + setStatus("success") + setTimeout(() => close(), 1500) + } + + setStatus("uploading") + setErrorMsg(null) + try { + await doUpload() + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") return + if (err instanceof Response && err.status === 402) { + try { + await payL402(setBudget) + await doUpload() + } catch { + setStatus("error") + setErrorMsg("Payment failed. Please try again.") + } + return + } + setStatus("error") + if (err instanceof Response) { + const body = await err.json().catch(() => null) as { errorCode?: string; message?: string } | null + setErrorMsg(body?.message || body?.errorCode || `Upload failed (HTTP ${err.status})`) + } else { + setErrorMsg("Upload failed. Try again or pick a different file.") + } + } + return + } + + // Non-image path: existing checkNodeExists + createNode flow. + const keyField = actualKeyField(selectedSchema) + const keyValue = (fieldValues[keyField] ?? "").trim() + if (!keyValue) { + setErrorMsg(`"${humanizeFieldKey(keyField)}" is required`) + return + } + + const nodeData: Record = {} + for (const f of fields) { + const v = parseFieldValue(f.type, fieldValues[f.key] ?? "") + if (v !== undefined) nodeData[f.key] = v + } + + // 1. Preflight duplicate check (free, no payment) + setStatus("checking") + setErrorMsg(null) + try { + const check = await checkNodeExists( + selectedSchema.type, + keyValue, + controller.signal + ) + if (check.exists) { + setStatus("error") + setErrorMsg(`A ${selectedSchema.type} with this ${humanizeFieldKey(keyField)} already exists.`) + return + } + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") return + // Non-fatal: server will catch on submit + } + + // 2. Submit with payment retry on 402 + const doCreate = async () => { + const response = await createNode( + selectedSchema.type, + nodeData, + controller.signal + ) + if ((response as Record)?.status === "Warning") { + setStatus("error") + setErrorMsg(`A ${selectedSchema.type} with this ${humanizeFieldKey(keyField)} already exists.`) + return + } + setStatus("success") + setTimeout(() => close(), 1500) + } + + setStatus("submitting") + try { + await doCreate() + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") return + if (err instanceof Response && err.status === 402) { + try { + await payL402(setBudget) + await doCreate() + } catch { + setStatus("error") + setErrorMsg("Payment failed. Please try again.") + } + return + } + setStatus("error") + setErrorMsg("Something went wrong. Please try again.") + } + }, + [selectedSchema, fields, visibleFields, fieldValues, selectedFile, setBudget, close] + ) + + const busy = status === "checking" || status === "submitting" || status === "success" || status === "uploading" + const isImageType = selectedSchema?.type === IMAGE_TYPE + + // Whether all required inputs are satisfied — drives the footer status hint. + const nodeReady = + !!selectedSchema && + requiredFields.every((f) => (fieldValues[f.key] ?? "").trim() !== "") && + (!isImageType || !!selectedFile) + const statusHint = !selectedSchema + ? "Pick a type to begin" + : status === "success" + ? "Created" + : nodeReady + ? "Ready to create" + : "Fill the required fields" + + return ( +
+ {/* Type picker */} +
+ + {typeOptions.length === 0 ? ( +
+ No node types available. Load schemas or check the Domains settings. +
+ ) : ( + { + // Different schemas have different keys — drop any value the + // user already entered so validation matches the new attribute + // set cleanly. + setSelectedType(v) + setFieldValues({}) + setSelectedFile(null) + setShowOptional(false) + setErrorMsg(null) + }} + options={typeOptions} + placeholder="Choose a type..." + searchable + /> + )} +
+ + {/* Image file picker — only for Image type. */} + {isImageType && ( +
+ + { + setSelectedFile(e.target.files?.[0] ?? null) + setErrorMsg(null) + }} + className="block w-full text-xs text-muted-foreground file:mr-3 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-2 file:text-xs file:text-primary-foreground hover:file:bg-primary/90 disabled:opacity-50" + /> + {selectedFile && ( + + {selectedFile.name} ({(selectedFile.size / 1024).toFixed(0)} KB) + + )} + {previewUrl && ( + // eslint-disable-next-line @next/next/no-img-element + Preview of selected image — local only, not yet uploaded + )} +
+ )} + + {/* Empty state — before a type is picked, only its required fields show */} + {typeOptions.length > 0 && !selectedSchema && ( +
+ Pick a type and we'll show only the fields it needs. +
+ )} + + {/* Required fields — always shown once a type is chosen */} + {selectedSchema && requiredFields.length > 0 && ( +
{requiredFields.map(renderField)}
+ )} + + {/* Optional attributes — collapsed, grouped into Content / Metadata / Signals */} + {selectedSchema && optionalFields.length > 0 && ( +
+ + {showOptional && ( +
+ {optionalGroups.map(({ group, label, fields: groupFields }) => ( +
+
+ {label} + +
+
{groupFields.map(renderField)}
+
+ ))} +
+ )} +
+ )} + + {/* Error */} + {errorMsg &&

{errorMsg}

} + + {/* Anon-loss disclosure */} + {!pubKey && ( +

+ Earnings are credited to this browser's L402. Clearing storage will lose your sats. +

+ )} + + {/* Footer — contextual status hint + actions, bled to the modal edges */} +
+ {statusHint} + + {price !== null && price > 0 && ( + {price} sats + )} + + +
+
+ ) +} diff --git a/src/components/modals/add-node-modal.tsx b/src/components/modals/add-node-modal.tsx deleted file mode 100644 index ca38d6cc..00000000 --- a/src/components/modals/add-node-modal.tsx +++ /dev/null @@ -1,503 +0,0 @@ -"use client" - -import { useCallback, useEffect, useMemo, useRef, useState } from "react" -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { SelectCustom } from "@/components/ui/select-custom" -import { useModalStore } from "@/stores/modal-store" -import { useUserStore } from "@/stores/user-store" -import { useSchemaStore } from "@/stores/schema-store" -import { getPrice, payL402 } from "@/lib/sphinx" -import { - ALLOWED_IMAGE_TYPES, - MAX_IMAGE_UPLOAD_BYTES, - addImageContent, - checkNodeExists, - createNode, - getSchemaDomains, - type SchemaDomainsResponse, -} from "@/lib/graph-api" -import type { SchemaNode } from "@/app/ontology/page" -import { fieldsForSchema } from "@/lib/node-schema-utils" - -type Status = "idle" | "checking" | "submitting" | "success" | "error" | "uploading" - -// Image is special-cased: the user picks a file directly in this modal and a -// single multipart POST to /v2/content/image handles upload + node creation + -// Stakwork dispatch. source_link/url are minted server-side from the upload, -// so we hide those form fields entirely. -const IMAGE_TYPE = "Image" -const IMAGE_AUTO_FIELDS = new Set(["source_link", "url"]) - -// Pre-submit gate. Backend re-validates with the same thresholds — these are -// just here to catch obvious mistakes before burning a multipart roundtrip. -const ALLOWED_IMAGE_TYPE_SET = new Set(ALLOWED_IMAGE_TYPES) - -// Backend stores node_key as `{typeLower}-{attribute}` (e.g. "image-source_link", -// "transport-name"). The actual attribute name is the part after the dash. -function actualKeyField(schema: SchemaNode): string { - const raw = schema.node_key || "name" - const prefix = `${schema.type.toLowerCase()}-` - return raw.startsWith(prefix) ? raw.slice(prefix.length) : raw -} - -// Coerce a raw form value into the JSON shape the backend wants. Empty -// strings become "not provided" — dropped from the payload entirely. -function parseFieldValue(type: string, raw: string): unknown { - const t = raw.trim() - if (t === "") return undefined - if (type === "int" || type === "integer") { - const n = Number(t) - return Number.isFinite(n) ? Math.trunc(n) : t - } - if (type === "float" || type === "number") { - const n = Number(t) - return Number.isFinite(n) ? n : t - } - if (type === "bool" || type === "boolean") { - return t.toLowerCase() === "true" || t === "1" - } - return t -} - -export function AddNodeModal() { - const { activeModal, close } = useModalStore() - const setBudget = useUserStore((s) => s.setBudget) - const pubKey = useUserStore((s) => s.pubKey) - const schemas = useSchemaStore((s) => s.schemas) - - const [selectedType, setSelectedType] = useState("") - const [fieldValues, setFieldValues] = useState>({}) - const [domains, setDomains] = useState(null) - const [price, setPrice] = useState(null) - const [status, setStatus] = useState("idle") - const [errorMsg, setErrorMsg] = useState(null) - // Image-only: the file picked by the user, validated client-side before - // we hit the multipart endpoint. - const [selectedFile, setSelectedFile] = useState(null) - // Object URL for the in-modal preview. Created from the local File so the - // user sees the image before any network roundtrip — the file isn't on S3 - // yet at this point. Revoked when the file changes or the modal closes. - const [previewUrl, setPreviewUrl] = useState(null) - const abortRef = useRef(null) - - useEffect(() => { - if (!selectedFile) { - setPreviewUrl(null) - return - } - const url = URL.createObjectURL(selectedFile) - setPreviewUrl(url) - return () => URL.revokeObjectURL(url) - }, [selectedFile]) - - // Visible schemas: drop hidden types and anything in a hidden domain. A - // schema's domain is the lowercased name of its root ancestor under Thing - // (e.g. Function → Codeartifact → "codeartifact"). hidden_domains returns - // domain names lowercased, so we have to lowercase the walked root before - // checking. Hidden lists arrive after the domains fetch resolves; until - // then, show every schema rather than blocking the picker. - const visibleSchemas = useMemo(() => { - const hiddenTypes = new Set(domains?.hidden_types ?? []) - const hiddenDomains = new Set(domains?.hidden_domains ?? []) - const parentOf = new Map() - for (const s of schemas) { - if (s.type && s.parent) parentOf.set(s.type, s.parent) - } - const rootDomain = (type: string): string => { - let cur = type - const seen = new Set() - while (true) { - if (seen.has(cur)) return cur - seen.add(cur) - const p = parentOf.get(cur) - if (!p || p === "Thing") return cur - cur = p - } - } - return schemas - .filter((s) => s.type && !hiddenTypes.has(s.type)) - .filter((s) => !hiddenDomains.has(rootDomain(s.type).toLowerCase())) - .slice() - .sort((a, b) => a.type.localeCompare(b.type)) - }, [schemas, domains]) - - const selectedSchema = useMemo( - () => visibleSchemas.find((s) => s.type === selectedType) ?? null, - [visibleSchemas, selectedType] - ) - - const fields = useMemo( - () => (selectedSchema ? fieldsForSchema(selectedSchema) : []), - [selectedSchema] - ) - - // What we actually render in the form. For Image, source_link/url are - // populated server-side from the upload — no user input. - const visibleFields = useMemo(() => { - if (selectedSchema?.type === IMAGE_TYPE) { - return fields.filter((f) => !IMAGE_AUTO_FIELDS.has(f.key)) - } - return fields - }, [selectedSchema, fields]) - - // Fetch price + domains on open - useEffect(() => { - if (activeModal !== "addNode") return - getPrice("v2/nodes", "post").then(setPrice).catch(() => setPrice(null)) - const controller = new AbortController() - getSchemaDomains(controller.signal) - .then(setDomains) - // Non-fatal: without the domains list we just show every schema. - .catch(() => setDomains(null)) - return () => controller.abort() - }, [activeModal]) - - // All close paths funnel through here so the modal's internal state stays - // fresh on each open without needing a reset-in-effect. - const handleClose = useCallback(() => { - setSelectedType("") - setFieldValues({}) - setErrorMsg(null) - setStatus("idle") - setSelectedFile(null) - abortRef.current?.abort() - close() - }, [close]) - - const typeOptions = useMemo( - () => visibleSchemas.map((s) => ({ value: s.type, label: s.type })), - [visibleSchemas] - ) - - const handleSubmit = useCallback( - async (e: React.FormEvent) => { - e.preventDefault() - if (!selectedSchema) { - setErrorMsg("Choose a type first") - return - } - - const isImage = selectedSchema.type === IMAGE_TYPE - - // Image flow: needs a file, and the file has to pass format + size - // gates before we burn a paid roundtrip. Backend re-checks both — these - // are friendlier upfront errors. - if (isImage) { - if (!selectedFile) { - setErrorMsg("Pick an image to upload") - return - } - if (!ALLOWED_IMAGE_TYPE_SET.has(selectedFile.type)) { - setErrorMsg( - `Unsupported format "${selectedFile.type || "unknown"}". Allowed: JPEG, PNG, WebP, GIF.` - ) - return - } - if (selectedFile.size > MAX_IMAGE_UPLOAD_BYTES) { - const maxMb = Math.round(MAX_IMAGE_UPLOAD_BYTES / (1024 * 1024)) - const fileMb = (selectedFile.size / (1024 * 1024)).toFixed(1) - setErrorMsg(`File is ${fileMb} MB; max is ${maxMb} MB.`) - return - } - } - - // Required attributes must all have values. For Image we skip - // source_link/url since the backend mints them from the upload. - const missing = visibleFields - .filter((f) => f.required) - .filter((f) => (fieldValues[f.key] ?? "").trim() === "") - .map((f) => f.key) - if (missing.length > 0) { - setErrorMsg(`Missing required: ${missing.join(", ")}`) - return - } - - abortRef.current?.abort() - const controller = new AbortController() - abortRef.current = controller - - // Image path: single multipart POST. Backend handles upload + node - // create + Stakwork dispatch. - if (isImage) { - const name = (fieldValues["name"] ?? "").trim() || selectedFile!.name - const doUpload = async () => { - await addImageContent( - selectedFile!, - { name }, - controller.signal - ) - setStatus("success") - setTimeout(() => handleClose(), 1500) - } - - setStatus("uploading") - setErrorMsg(null) - try { - await doUpload() - } catch (err) { - if (err instanceof DOMException && err.name === "AbortError") return - if (err instanceof Response && err.status === 402) { - try { - await payL402(setBudget) - await doUpload() - } catch { - setStatus("error") - setErrorMsg("Payment failed. Please try again.") - } - return - } - setStatus("error") - if (err instanceof Response) { - const body = await err.json().catch(() => null) as { errorCode?: string; message?: string } | null - setErrorMsg(body?.message || body?.errorCode || `Upload failed (HTTP ${err.status})`) - } else { - setErrorMsg("Upload failed. Try again or pick a different file.") - } - } - return - } - - // Non-image path: existing checkNodeExists + createNode flow. - const keyField = actualKeyField(selectedSchema) - const keyValue = (fieldValues[keyField] ?? "").trim() - if (!keyValue) { - setErrorMsg(`"${keyField}" is required`) - return - } - - const nodeData: Record = {} - for (const f of fields) { - const v = parseFieldValue(f.type, fieldValues[f.key] ?? "") - if (v !== undefined) nodeData[f.key] = v - } - - // 1. Preflight duplicate check (free, no payment) - setStatus("checking") - setErrorMsg(null) - try { - const check = await checkNodeExists( - selectedSchema.type, - keyValue, - controller.signal - ) - if (check.exists) { - setStatus("error") - setErrorMsg(`A ${selectedSchema.type} with this ${keyField} already exists.`) - return - } - } catch (err) { - if (err instanceof DOMException && err.name === "AbortError") return - // Non-fatal: server will catch on submit - } - - // 2. Submit with payment retry on 402 - const doCreate = async () => { - const response = await createNode( - selectedSchema.type, - nodeData, - controller.signal - ) - if ((response as Record)?.status === "Warning") { - setStatus("error") - setErrorMsg(`A ${selectedSchema.type} with this ${keyField} already exists.`) - return - } - setStatus("success") - setTimeout(() => handleClose(), 1500) - } - - setStatus("submitting") - try { - await doCreate() - } catch (err) { - if (err instanceof DOMException && err.name === "AbortError") return - if (err instanceof Response && err.status === 402) { - try { - await payL402(setBudget) - await doCreate() - } catch { - setStatus("error") - setErrorMsg("Payment failed. Please try again.") - } - return - } - setStatus("error") - setErrorMsg("Something went wrong. Please try again.") - } - }, - [selectedSchema, fields, visibleFields, fieldValues, selectedFile, setBudget, handleClose] - ) - - const isOpen = activeModal === "addNode" - const busy = status === "checking" || status === "submitting" || status === "success" || status === "uploading" - - const isImageType = selectedSchema?.type === IMAGE_TYPE - - return ( - !open && handleClose()}> - - - - Add Node - - - Create a new node in the graph. Choose a type, then fill in its attributes. - - - -
- {/* Type picker */} -
- - {typeOptions.length === 0 ? ( -
- No node types available. Load schemas or check the Domains settings. -
- ) : ( - { - // Different schemas have different keys — drop any value - // the user already entered so validation matches the new - // attribute set cleanly. - setSelectedType(v) - setFieldValues({}) - setSelectedFile(null) - setErrorMsg(null) - }} - options={typeOptions} - placeholder="Choose a type..." - /> - )} -
- - {/* Image file picker — only for Image type. Backend mints - source_link/url from the upload, so we don't render those - fields below. */} - {isImageType && ( -
- - { - setSelectedFile(e.target.files?.[0] ?? null) - setErrorMsg(null) - }} - className="block w-full text-xs text-muted-foreground file:mr-3 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-2 file:text-xs file:text-primary-foreground hover:file:bg-primary/90 disabled:opacity-50" - /> - {selectedFile && ( - - {selectedFile.name} ({(selectedFile.size / 1024).toFixed(0)} KB) - - )} - {previewUrl && ( - // eslint-disable-next-line @next/next/no-img-element - Preview of selected image — local only, not yet uploaded - )} -
- )} - - {/* Dynamic fields — appear once a type is chosen */} - {selectedSchema && visibleFields.length > 0 && ( -
- {visibleFields.map((f) => ( -
- - { - const val = e.target.value - setFieldValues((prev) => ({ ...prev, [f.key]: val })) - setErrorMsg(null) - }} - placeholder={f.required ? "Required" : ""} - maxLength={1000} - disabled={busy} - className="h-10 w-full rounded-md border border-border/50 bg-muted/50 px-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary/40 focus:outline-none disabled:opacity-50" - /> -
- ))} -
- )} - - {/* Error */} - {errorMsg &&

{errorMsg}

} - - {/* Anon-loss disclosure */} - {!pubKey && ( -

- Earnings are credited to this browser's L402. Clearing storage will lose your sats. -

- )} - - {/* Price + Submit */} -
- {price !== null && price > 0 ? ( - {price} sats - ) : ( - - )} - -
-
-
-
- ) -} diff --git a/src/components/modals/add-content-modal.tsx b/src/components/modals/add-source-form.tsx similarity index 51% rename from src/components/modals/add-content-modal.tsx rename to src/components/modals/add-source-form.tsx index 111ec324..2bfe1048 100644 --- a/src/components/modals/add-content-modal.tsx +++ b/src/components/modals/add-source-form.tsx @@ -2,13 +2,6 @@ import { useCallback, useEffect, useState } from "react" import { Loader2, CheckCircle2, LinkIcon, Zap, X, RefreshCw } from "lucide-react" -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from "@/components/ui/dialog" import { Button } from "@/components/ui/button" import { Separator } from "@/components/ui/separator" import { MAX_LENGTHS } from "@/lib/input-limits" @@ -43,8 +36,8 @@ const IN_PROGRESS_STATUSES = ["in_progress", "running", "pending"] type CacheStatus = "miss" | "hit-completed" | "hit-in-progress" | null type PreviewState = "pending" | "owned" | "pay-required" | "fallback" | null -export function AddContentModal() { - const { activeModal, close, open: openModal } = useModalStore() +export function AddSourceForm() { + const { close, open: openModal } = useModalStore() const { budget, setBudget, pubKey, routeHint, isAdmin } = useUserStore() const refreshBalance = useUserStore((s) => s.refreshBalance) const [sourceUrl, setSourceUrl] = useState("") @@ -61,15 +54,15 @@ export function AddContentModal() { const [cacheStatus, setCacheStatus] = useState(null) const [cachedRefId, setCachedRefId] = useState(null) const [previewState, setPreviewState] = useState(null) - const [previewedNode, setPreviewedNode] = useState(null) + const [, setPreviewedNode] = useState(null) // Fetch price based on detected type useEffect(() => { - if (activeModal === "addContent" && detectedType) { + if (detectedType) { const endpoint = isSubscriptionSource(detectedType) ? "radar" : "v2/content" getPrice(endpoint).then(setPrice) } - }, [activeModal, detectedType]) + }, [detectedType]) const handleDetect = useCallback(async (value: string) => { setSourceUrl(value) @@ -292,29 +285,7 @@ export function AddContentModal() { } finally { setSubmitting(false) } - }, [sourceUrl, detectedType, close, setBudget, submitWithAuth, refreshBalance]) - - const handleOpenChange = useCallback( - (open: boolean) => { - if (!open) { - close() - setSourceUrl("") - setDetectedType(null) - setSuccess(false) - setError("") - setPrice(null) - setTopics([]) - setTopicDraft("") - setCategory("") - setWeight(null) - setCacheStatus(null) - setCachedRefId(null) - setPreviewState(null) - setPreviewedNode(null) - } - }, - [close] - ) + }, [sourceUrl, detectedType, close, setBudget, submitWithAuth, refreshBalance, openModal, cacheStatus]) const addTopic = useCallback(() => { const t = topicDraft.trim() @@ -352,235 +323,234 @@ export function AddContentModal() { })() return ( - - - - - Add Content - - - Paste a URL and we'll detect the source type automatically. - - - -
- {/* URL Input */} -
- +
+ {/* URL Input */} +
+ + handleDetect(e.target.value)} + placeholder="Paste URL, Twitter handle, RSS feed..." + maxLength={MAX_LENGTHS.SOURCE_URL} + className="h-10 w-full rounded-md border border-border/50 bg-muted/50 pl-9 pr-10 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary/40 focus:outline-none" + /> + {detecting && ( + + )} +
+ + {/* Detected type badge */} + {detectedType && !detecting && ( +
+
+ + Detected: {SOURCE_TYPE_LABELS[detectedType] ?? detectedType} + +
+ )} + + {/* Subscription source callout */} + {detectedType && !detecting && isSubscriptionSource(detectedType) && ( +
+ + + This source will be ingested continuously on a schedule — new content appears automatically. + +
+ )} + + {/* Cache state badge */} + {cacheStatus === "hit-completed" && !detecting && ( +
+ + + Cached — instant unlock + +
+ )} + + {cacheStatus === "hit-in-progress" && !detecting && ( +
+ + + Processing — check back shortly + +
+ )} + + {detectedType === SOURCE_TYPES.TWITTER_HANDLE && !detecting && ( +
+ +
+ {topics.map((t) => ( + + {t} + + + ))} handleDetect(e.target.value)} - placeholder="Paste URL, Twitter handle, RSS feed..." - maxLength={MAX_LENGTHS.SOURCE_URL} - className="h-10 w-full rounded-md border border-border/50 bg-muted/50 pl-9 pr-10 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary/40 focus:outline-none" + value={topicDraft} + onChange={(e) => setTopicDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault() + addTopic() + } else if (e.key === "Backspace" && !topicDraft && topics.length) { + removeTopic(topics[topics.length - 1]) + } + }} + onBlur={addTopic} + placeholder={topics.length ? "" : "AI, devtools (Enter to add)"} + className="flex-1 min-w-[80px] bg-transparent text-xs text-foreground placeholder:text-muted-foreground focus:outline-none" /> - {detecting && ( - - )}
- - {/* Detected type badge */} - {detectedType && !detecting && ( -
-
- - Detected: {SOURCE_TYPE_LABELS[detectedType] ?? detectedType} - -
- )} - - {/* Subscription source callout */} - {detectedType && !detecting && isSubscriptionSource(detectedType) && ( -
- - - This source will be ingested continuously on a schedule — new content appears automatically. - -
- )} - - {/* Cache state badge */} - {cacheStatus === "hit-completed" && !detecting && ( -
- - - Cached — instant unlock - -
- )} - - {cacheStatus === "hit-in-progress" && !detecting && ( -
- - - Processing — check back shortly - -
- )} - - {detectedType === SOURCE_TYPES.TWITTER_HANDLE && !detecting && ( -
- -
- {topics.map((t) => ( - - {t} - - - ))} - setTopicDraft(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === ",") { - e.preventDefault() - addTopic() - } else if (e.key === "Backspace" && !topicDraft && topics.length) { - removeTopic(topics[topics.length - 1]) - } - }} - onBlur={addTopic} - placeholder={topics.length ? "" : "AI, devtools (Enter to add)"} - className="flex-1 min-w-[80px] bg-transparent text-xs text-foreground placeholder:text-muted-foreground focus:outline-none" - /> -
-

- Only tweets matching one of these topics will be ingested. -

-
- )} - - {/* Admin-only category & weight inputs for subscription sources */} - {isAdmin && detectedType && isSubscriptionSource(detectedType) && ( -
-
- - setCategory(e.target.value)} - placeholder="e.g. AI, crypto, finance" - className="mt-1 w-full rounded-md border border-border/50 bg-muted/50 px-3 py-1.5 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/50" - /> -
-
- - setWeight(e.target.value ? parseFloat(e.target.value) : null)} - placeholder="0.0 – 1.0" - className="mt-1 w-full rounded-md border border-border/50 bg-muted/50 px-3 py-1.5 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/50" - /> -
+

+ Only tweets matching one of these topics will be ingested. +

+
+ )} + + {/* Admin-only category & weight inputs for subscription sources */} + {isAdmin && detectedType && isSubscriptionSource(detectedType) && ( +
+
+ + setCategory(e.target.value)} + placeholder="e.g. AI, crypto, finance" + className="mt-1 w-full rounded-md border border-border/50 bg-muted/50 px-3 py-1.5 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/50" + /> +
+
+ + setWeight(e.target.value ? parseFloat(e.target.value) : null)} + placeholder="0.0 – 1.0" + className="mt-1 w-full rounded-md border border-border/50 bg-muted/50 px-3 py-1.5 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/50" + /> +
+
+ )} + + {/* Cost & Budget — hidden while preview probe is in-flight or content is owned */} + {detectedType && price !== null && price > 0 && !hidePaymentUI && ( + <> + +
+
+ + Cost
- )} - - {/* Cost & Budget — hidden while preview probe is in-flight or content is owned */} - {detectedType && price !== null && price > 0 && !hidePaymentUI && ( + + {price} sats + +
+
+ Budget + + {formattedBudget} sats + +
+ + )} + + {error && ( +

{error}

+ )} + + {isSubscriptionBlocked && ( +

+ Adding subscription sources requires admin access. +

+ )} + + {/* Anon-loss disclosure */} + {!pubKey && ( +

+ Earnings are credited to this browser's L402. Clearing storage will lose your sats. +

+ )} + + {/* Post-submit success note for subscription sources */} + {success && detectedType && isSubscriptionSource(detectedType) && ( +

+ Ingestion begins on the next scheduled run. +

+ )} + + {/* Footer — contextual status hint + actions, bled to the modal edges */} +
+ + {!detectedType + ? "Paste a link to begin" + : isInProgress + ? "Processing — check back shortly" + : isSubscriptionBlocked + ? "Admin access required" + : cacheStatus === "hit-completed" + ? "Cached — instant unlock" + : "Auto-built — review afterwards in the graph"} + + + + - -
-
- -
+ + + ) } diff --git a/src/components/ui/anchored-popover.tsx b/src/components/ui/anchored-popover.tsx new file mode 100644 index 00000000..9157f62e --- /dev/null +++ b/src/components/ui/anchored-popover.tsx @@ -0,0 +1,128 @@ +"use client" + +import * as React from "react" +import { createPortal } from "react-dom" +import { cn } from "@/lib/utils" + +interface AnchoredPopoverProps { + // The element the popover floats next to (usually the trigger/input wrapper). + anchorRef: React.RefObject + open: boolean + onClose: () => void + children: React.ReactNode + className?: string + // Match the anchor's width (the common case for selects/comboboxes). + matchWidth?: boolean + gap?: number + // Upper bound on the surface height so a long list stays a tidy, scrollable + // popover instead of stretching to fill the viewport. + maxHeight?: number +} + +interface Position { + left: number + width?: number + top?: number + bottom?: number + maxHeight: number +} + +// Above the Dialog content (16777274) so the list is never clipped by the +// modal's overflow. +const Z_INDEX = 16777275 +const VIEWPORT_MARGIN = 8 +const PREFERRED_MIN = 220 +const DEFAULT_MAX_HEIGHT = 300 + +/** + * A dropdown surface rendered in a portal with fixed positioning, anchored to a + * trigger element. Because it lives at the document root it escapes any scroll + * container (e.g. a modal's `overflow-y-auto`) instead of being clipped inside + * it. Flips above the anchor when there isn't room below, and re-measures on + * scroll/resize. Owns its own outside-click handling. + */ +export function AnchoredPopover({ + anchorRef, + open, + onClose, + children, + className, + matchWidth = true, + gap = 6, + maxHeight = DEFAULT_MAX_HEIGHT, +}: AnchoredPopoverProps) { + const popoverRef = React.useRef(null) + const [mounted, setMounted] = React.useState(false) + const [pos, setPos] = React.useState(null) + + React.useEffect(() => setMounted(true), []) + + const compute = React.useCallback(() => { + const el = anchorRef.current + if (!el) return + const r = el.getBoundingClientRect() + const spaceBelow = window.innerHeight - r.bottom - gap - VIEWPORT_MARGIN + const spaceAbove = r.top - gap - VIEWPORT_MARGIN + // Prefer below; flip up only when below is cramped and above has more room. + const placeBelow = spaceBelow >= PREFERRED_MIN || spaceBelow >= spaceAbove + const available = placeBelow ? spaceBelow : spaceAbove + setPos({ + left: r.left, + width: matchWidth ? r.width : undefined, + top: placeBelow ? r.bottom + gap : undefined, + bottom: placeBelow ? undefined : window.innerHeight - r.top + gap, + // Cap to a tidy size, but never exceed the room actually available. + maxHeight: Math.min(maxHeight, Math.max(120, available)), + }) + }, [anchorRef, gap, matchWidth, maxHeight]) + + // Position on open and keep it pinned as the page scrolls/resizes. Capture + // scroll so we also catch scrolling inside ancestor containers (the modal). + React.useLayoutEffect(() => { + if (!open) return + compute() + const onScrollOrResize = () => compute() + window.addEventListener("scroll", onScrollOrResize, true) + window.addEventListener("resize", onScrollOrResize) + return () => { + window.removeEventListener("scroll", onScrollOrResize, true) + window.removeEventListener("resize", onScrollOrResize) + } + }, [open, compute]) + + // Close on a click outside both the anchor and the popover. Uses mousedown + // (matching the components this replaced) — clicks inside the portal are + // ignored, so selecting an option doesn't dismiss before the click lands. + React.useEffect(() => { + if (!open) return + const onMouseDown = (e: MouseEvent) => { + const target = e.target as Node + if (anchorRef.current?.contains(target)) return + if (popoverRef.current?.contains(target)) return + onClose() + } + document.addEventListener("mousedown", onMouseDown, true) + return () => document.removeEventListener("mousedown", onMouseDown, true) + }, [open, onClose, anchorRef]) + + if (!mounted || !open || !pos) return null + + return createPortal( +
+ {children} +
, + document.body + ) +} diff --git a/src/components/ui/node-search-input.tsx b/src/components/ui/node-search-input.tsx index e6440fb9..de13c4ec 100644 --- a/src/components/ui/node-search-input.tsx +++ b/src/components/ui/node-search-input.tsx @@ -8,6 +8,7 @@ import { resolveNodeTitle } from "@/lib/node-display" import { searchNodes, type GraphNode } from "@/lib/graph-api" import { useDebounce } from "@/hooks/use-debounce" import { useSchemaStore } from "@/stores/schema-store" +import { AnchoredPopover } from "@/components/ui/anchored-popover" interface NodeSearchInputProps { value: GraphNode | null @@ -34,27 +35,21 @@ export function NodeSearchInput({ const debouncedQuery = useDebounce(query, 300) - // Outside-click closes dropdown - useEffect(() => { - function handleClickOutside(e: MouseEvent) { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - setOpen(false) - } - } - if (open) { - document.addEventListener("mousedown", handleClickOutside) - return () => document.removeEventListener("mousedown", handleClickOutside) - } - }, [open]) + // Outside-click is handled by AnchoredPopover (which knows about the portal), + // so no separate listener is needed here. // Debounced search useEffect(() => { if (value !== null) return // don't search in selected state if (debouncedQuery.trim() === "") { + // Sync the result list with the (debounced, async) search query — clearing + // when the query empties is the resting state, not a cascading render. + /* eslint-disable react-hooks/set-state-in-effect */ setResults([]) setFetched(false) setOpen(false) + /* eslint-enable react-hooks/set-state-in-effect */ return } @@ -160,8 +155,15 @@ export function NodeSearchInput({ )} - {showDropdown && ( -
+ {/* Rendered in a portal so results float above (and aren't clipped by) a + modal's scroll container. */} + setOpen(false)} + className="rounded-lg border border-border/50 bg-popover py-1 shadow-lg shadow-black/20" + > +
{loading && results.length === 0 ? null : fetched && results.length === 0 ? (
No nodes found
) : ( @@ -193,7 +195,7 @@ export function NodeSearchInput({ }) )}
- )} +
) } diff --git a/src/components/ui/select-custom.tsx b/src/components/ui/select-custom.tsx index cb7d3c3f..eb0d739d 100644 --- a/src/components/ui/select-custom.tsx +++ b/src/components/ui/select-custom.tsx @@ -1,8 +1,9 @@ "use client" -import { useState, useRef, useEffect } from "react" -import { ChevronDown, Check } from "lucide-react" +import { useState, useRef, useEffect, useMemo } from "react" +import { ChevronDown, Check, Search } from "lucide-react" import { cn } from "@/lib/utils" +import { AnchoredPopover } from "@/components/ui/anchored-popover" interface Option { value: string @@ -16,6 +17,10 @@ interface SelectCustomProps { placeholder?: string className?: string compact?: boolean + // When true, a filter input is pinned at the top of the open dropdown and + // narrows the options by label. Off by default — existing call sites are + // unaffected. + searchable?: boolean } export function SelectCustom({ @@ -25,29 +30,40 @@ export function SelectCustom({ placeholder, className, compact = false, + searchable = false, }: SelectCustomProps) { const [open, setOpen] = useState(false) + const [query, setQuery] = useState("") const ref = useRef(null) + const searchRef = useRef(null) const selected = options.find((o) => o.value === value) + const filtered = useMemo(() => { + if (!searchable) return options + const q = query.trim().toLowerCase() + if (!q) return options + return options.filter((o) => o.label.toLowerCase().includes(q)) + }, [options, query, searchable]) + + // Focus the filter input when the dropdown opens (DOM sync only — the query + // itself is reset in the open handler to avoid setState-in-effect). useEffect(() => { - function handleClickOutside(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) { - setOpen(false) - } - } - if (open) { - document.addEventListener("mousedown", handleClickOutside) - return () => document.removeEventListener("mousedown", handleClickOutside) + if (open && searchable) { + const id = requestAnimationFrame(() => searchRef.current?.focus()) + return () => cancelAnimationFrame(id) } - }, [open]) + }, [open, searchable]) return (
- {open && ( -
-
- {options.map((opt) => ( + {/* Rendered in a portal so it floats above (and is never clipped by) any + scroll container such as a modal body. */} + setOpen(false)} + className="min-w-[120px] rounded-lg border border-border/50 bg-popover py-1 shadow-lg shadow-black/20" + > + {searchable && ( +
+ + setQuery(e.target.value)} + placeholder="Search…" + className="h-7 w-full rounded-md border border-border/50 bg-muted/40 pl-7 pr-2 text-xs text-foreground placeholder:text-muted-foreground focus:border-primary/40 focus:outline-none" + /> +
+ )} +
+ {filtered.length === 0 ? ( +
No matches
+ ) : ( + filtered.map((opt) => ( - ))} -
+ )) + )}
- )} +
) } diff --git a/src/lib/__tests__/add-content-modal.test.tsx b/src/lib/__tests__/add-content-modal.test.tsx index c6e8dda1..9d68ef09 100644 --- a/src/lib/__tests__/add-content-modal.test.tsx +++ b/src/lib/__tests__/add-content-modal.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { describe, it, expect, vi, beforeEach } from "vitest" import { render, screen, waitFor } from "@testing-library/react" import userEvent from "@testing-library/user-event" import React from "react" @@ -136,7 +136,7 @@ const mockNode = { properties: {}, } -import { AddContentModal } from "@/components/modals/add-content-modal" +import { AddSourceForm } from "@/components/modals/add-source-form" describe("AddContentModal — preview probe", () => { beforeEach(() => { @@ -159,7 +159,7 @@ describe("AddContentModal — preview probe", () => { }) mockApiGet.mockResolvedValue({ nodes: [mockNode] }) - render() + render() const input = screen.getByPlaceholderText(/Paste URL/) await userEvent.type(input, "https://youtube.com/watch?v=test123") @@ -188,7 +188,7 @@ describe("AddContentModal — preview probe", () => { }) mockApiGet.mockRejectedValue(new Response(null, { status: 402 })) - render() + render() const input = screen.getByPlaceholderText(/Paste URL/) await userEvent.type(input, "https://youtube.com/watch?v=test123") @@ -217,7 +217,7 @@ describe("AddContentModal — preview probe", () => { }) mockApiGet.mockRejectedValue(new Error("Network failure")) - render() + render() const input = screen.getByPlaceholderText(/Paste URL/) await userEvent.type(input, "https://youtube.com/watch?v=test123") @@ -242,7 +242,7 @@ describe("AddContentModal — preview probe", () => { status: "in_progress", }) - render() + render() const input = screen.getByPlaceholderText(/Paste URL/) await userEvent.type(input, "https://youtube.com/watch?v=test123") @@ -262,7 +262,7 @@ describe("AddContentModal — preview probe", () => { status: null, }) - render() + render() const input = screen.getByPlaceholderText(/Paste URL/) await userEvent.type(input, "https://youtube.com/watch?v=test123") @@ -278,7 +278,7 @@ describe("AddContentModal — preview probe", () => { mockDetectSourceType.mockResolvedValue("web_page") // checkNodeExists would not be called either, so apiGet definitely won't - render() + render() const input = screen.getByPlaceholderText(/Paste URL/) await userEvent.type(input, "https://example.com/article") @@ -308,7 +308,7 @@ describe("AddContentModal — subscription source callout", () => { mockDetectSourceType.mockResolvedValue("youtube_channel") mockIsSubscriptionSource.mockReturnValue(true) - render() + render() const input = screen.getByPlaceholderText(/Paste URL/) await userEvent.type(input, "https://youtube.com/c/testchannel") @@ -324,7 +324,7 @@ describe("AddContentModal — subscription source callout", () => { mockIsSubscriptionSource.mockReturnValue(false) mockCheckNodeExists.mockResolvedValue({ exists: false, ref_id: null, status: null }) - render() + render() const input = screen.getByPlaceholderText(/Paste URL/) await userEvent.type(input, "https://youtube.com/watch?v=abc123") @@ -354,7 +354,7 @@ describe("AddContentModal — bumpMyContentRefresh on submission", () => { it("calls bumpMyContentRefresh after successful non-subscription submission", async () => { mockDetectSourceType.mockResolvedValue("youtube_video") - render() + render() const input = screen.getByPlaceholderText(/Paste URL/) await userEvent.type(input, "https://youtube.com/watch?v=newvideo") @@ -377,7 +377,7 @@ describe("AddContentModal — bumpMyContentRefresh on submission", () => { mockDetectSourceType.mockResolvedValue("twitter_handle") mockIsSubscriptionSource.mockReturnValue(true) - render() + render() const input = screen.getByPlaceholderText(/Paste URL/) await userEvent.type(input, "https://twitter.com/satoshi") @@ -413,7 +413,7 @@ describe("AddContentModal — admin category/weight fields", () => { mockDetectSourceType.mockResolvedValue("youtube_channel") mockIsSubscriptionSource.mockReturnValue(true) - render() + render() const input = screen.getByPlaceholderText(/Paste URL/) await userEvent.type(input, "https://youtube.com/@testchannel") @@ -428,7 +428,7 @@ describe("AddContentModal — admin category/weight fields", () => { mockDetectSourceType.mockResolvedValue("youtube_channel") mockIsSubscriptionSource.mockReturnValue(true) - render() + render() const input = screen.getByPlaceholderText(/Paste URL/) await userEvent.type(input, "https://youtube.com/@testchannel") @@ -446,7 +446,7 @@ describe("AddContentModal — admin category/weight fields", () => { mockIsSubscriptionSource.mockReturnValue(false) mockCheckNodeExists.mockResolvedValue({ exists: false, ref_id: null, status: null }) - render() + render() const input = screen.getByPlaceholderText(/Paste URL/) await userEvent.type(input, "https://youtube.com/watch?v=abc") diff --git a/src/lib/__tests__/add-edge-modal.test.tsx b/src/lib/__tests__/add-edge-modal.test.tsx index 7c6c7f29..cb5eb849 100644 --- a/src/lib/__tests__/add-edge-modal.test.tsx +++ b/src/lib/__tests__/add-edge-modal.test.tsx @@ -22,6 +22,18 @@ vi.mock("@/lib/mock-data", () => ({ isMocksEnabled: () => false, })) +// Pricing / payment helpers — edge creation is a paid action. +vi.mock("@/lib/sphinx", () => ({ + getPrice: vi.fn().mockResolvedValue(0), + payL402: vi.fn().mockResolvedValue(undefined), +})) + +// User store — only setBudget is read by the edge form. +vi.mock("@/stores/user-store", () => ({ + useUserStore: (sel: (s: Record) => unknown) => + sel({ setBudget: vi.fn() }), +})) + // --------------------------------------------------------------------------- // Fixture nodes // --------------------------------------------------------------------------- @@ -38,18 +50,20 @@ const FIXTURE_TARGET: GraphNode = { } // --------------------------------------------------------------------------- -// Modal store — per-selector mock +// Modal store — per-selector mock. AddEdgeForm reads sourceNode (for prefill) +// and close. The modal shell owns open/close gating, so those aren't tested +// here. // --------------------------------------------------------------------------- -let mockActiveModal: string | null = null let mockSourceNode: GraphNode | null = null let mockClose = vi.fn() +const mockOpen = vi.fn() vi.mock("@/stores/modal-store", () => ({ useModalStore: (sel: (s: Record) => unknown) => sel({ - activeModal: mockActiveModal, sourceNode: mockSourceNode, close: mockClose, + open: mockOpen, }), })) @@ -66,27 +80,22 @@ const SCHEMA_EDGES = [ vi.mock("@/stores/schema-store", () => ({ useSchemaStore: (sel: (s: Record) => unknown) => - sel({ edges: SCHEMA_EDGES }), + sel({ edges: SCHEMA_EDGES, schemas: [] }), })) // --------------------------------------------------------------------------- // Import component after mocks are set up // --------------------------------------------------------------------------- -import { AddEdgeModal } from "@/components/modals/add-edge-modal" +import { AddEdgeForm } from "@/components/modals/add-edge-form" +import { payL402 } from "@/lib/sphinx" // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -function openModal(sourceNode: GraphNode | null = null) { - mockActiveModal = "addEdge" +function withSource(sourceNode: GraphNode | null = null) { mockSourceNode = sourceNode } -function closeModal() { - mockActiveModal = null - mockSourceNode = null -} - /** Type a query into a NodeSearchInput, wait for the dropdown result, and click it */ async function selectNode(placeholder: string, node: GraphNode) { mockSearchNodes.mockResolvedValue({ nodes: [node] }) @@ -104,30 +113,18 @@ async function selectNode(placeholder: string, node: GraphNode) { // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- -describe("AddEdgeModal", () => { +describe("AddEdgeForm", () => { beforeEach(() => { mockClose = vi.fn() vi.clearAllMocks() mockCreateEdge.mockResolvedValue({}) mockSearchNodes.mockResolvedValue({ nodes: [] }) - closeModal() - }) - - it("does not render when modal is closed", () => { - closeModal() - render() - expect(screen.queryByText("Add Edge")).toBeNull() + withSource(null) }) - it("renders when activeModal is 'addEdge'", () => { - openModal() - render() - expect(screen.getByRole("heading", { name: "Add Edge" })).toBeDefined() - }) - - it("renders with empty source and target NodeSearchInput fields when opened from toolbar", () => { - openModal(null) - render() + it("renders empty source and target NodeSearchInput fields when opened from toolbar", () => { + withSource(null) + render() const sourceInput = screen.getByPlaceholderText("Search source node…") as HTMLInputElement const targetInput = screen.getByPlaceholderText("Search target node…") as HTMLInputElement expect(sourceInput.value).toBe("") @@ -135,8 +132,8 @@ describe("AddEdgeModal", () => { }) it("pre-fills source field with node display name when sourceNode is set in the modal store", () => { - openModal(FIXTURE_SOURCE) - render() + withSource(FIXTURE_SOURCE) + render() // Selected state renders the node title, not a raw ref_id expect(screen.getByText("Source Topic")).toBeDefined() // Target should still be an empty search input @@ -144,8 +141,8 @@ describe("AddEdgeModal", () => { }) it("target field is always empty on open even when sourceNode is set", () => { - openModal(FIXTURE_SOURCE) - render() + withSource(FIXTURE_SOURCE) + render() const targetInput = screen.getByPlaceholderText("Search target node…") as HTMLInputElement expect(targetInput.value).toBe("") }) @@ -155,16 +152,16 @@ describe("AddEdgeModal", () => { // ------------------------------------------------------------------------- describe("Edge type dropdown", () => { it("excludes CHILD_OF from options", async () => { - openModal() - render() + withSource() + render() const trigger = screen.getByText("Choose an edge type...").closest("button") as HTMLButtonElement await userEvent.click(trigger) expect(screen.queryByText("CHILD_OF")).toBeNull() }) it("shows all unique edge types from schema store (deduped, no CHILD_OF)", async () => { - openModal() - render() + withSource() + render() const trigger = screen.getByText("Choose an edge type...").closest("button") as HTMLButtonElement await userEvent.click(trigger) expect(screen.getAllByText("HAS_TOPIC").length).toBeGreaterThan(0) @@ -178,8 +175,8 @@ describe("AddEdgeModal", () => { // ------------------------------------------------------------------------- describe("Validation", () => { it("shows an error when source is not selected on submit", async () => { - openModal(null) - render() + withSource(null) + render() // Select target only await selectNode("Search target node…", FIXTURE_TARGET) // Select edge type @@ -193,8 +190,8 @@ describe("AddEdgeModal", () => { }) it("shows an error when target is not selected on submit", async () => { - openModal(FIXTURE_SOURCE) - render() + withSource(FIXTURE_SOURCE) + render() // Select edge type const trigger = screen.getByText("Choose an edge type...").closest("button") as HTMLButtonElement await userEvent.click(trigger) @@ -205,8 +202,8 @@ describe("AddEdgeModal", () => { }) it("shows an error when edge type is not selected on submit", async () => { - openModal(null) - render() + withSource(null) + render() await selectNode("Search source node…", FIXTURE_SOURCE) await selectNode("Search target node…", FIXTURE_TARGET) await userEvent.click(screen.getByRole("button", { name: /add edge/i })) @@ -220,8 +217,8 @@ describe("AddEdgeModal", () => { // ------------------------------------------------------------------------- describe("Submission", () => { it("calls createEdge with ref_id values from selected nodes on valid submit", async () => { - openModal(null) - render() + withSource(null) + render() await selectNode("Search source node…", FIXTURE_SOURCE) await selectNode("Search target node…", FIXTURE_TARGET) const trigger = screen.getByText("Choose an edge type...").closest("button") as HTMLButtonElement @@ -238,8 +235,8 @@ describe("AddEdgeModal", () => { }) it("calls createEdge with pre-filled source node ref_id and selected target", async () => { - openModal(FIXTURE_SOURCE) - render() + withSource(FIXTURE_SOURCE) + render() await selectNode("Search target node…", FIXTURE_TARGET) const trigger = screen.getByText("Choose an edge type...").closest("button") as HTMLButtonElement await userEvent.click(trigger) @@ -255,8 +252,8 @@ describe("AddEdgeModal", () => { }) it("shows success state after createEdge resolves", async () => { - openModal(null) - render() + withSource(null) + render() await selectNode("Search source node…", FIXTURE_SOURCE) await selectNode("Search target node…", FIXTURE_TARGET) const trigger = screen.getByText("Choose an edge type...").closest("button") as HTMLButtonElement @@ -269,8 +266,8 @@ describe("AddEdgeModal", () => { }) it("calls close after success auto-close timeout", async () => { - openModal(null) - render() + withSource(null) + render() await selectNode("Search source node…", FIXTURE_SOURCE) await selectNode("Search target node…", FIXTURE_TARGET) const trigger = screen.getByText("Choose an edge type...").closest("button") as HTMLButtonElement @@ -281,66 +278,56 @@ describe("AddEdgeModal", () => { await waitFor(() => expect(mockClose).toHaveBeenCalled(), { timeout: 2500 }) }) - it("shows inline error and keeps modal open when createEdge rejects", async () => { - mockCreateEdge.mockRejectedValueOnce(new Error("Duplicate edge")) - openModal(null) - render() + it("on 402, settles the L402 invoice and retries, then succeeds", async () => { + mockCreateEdge.mockRejectedValueOnce(new Response(null, { status: 402 })) + mockCreateEdge.mockResolvedValueOnce({}) + withSource(null) + render() await selectNode("Search source node…", FIXTURE_SOURCE) await selectNode("Search target node…", FIXTURE_TARGET) const trigger = screen.getByText("Choose an edge type...").closest("button") as HTMLButtonElement await userEvent.click(trigger) await userEvent.click(screen.getByText("HAS_TOPIC")) await userEvent.click(screen.getByRole("button", { name: /add edge/i })) - await waitFor(() => { - expect(screen.getByText("Duplicate edge")).toBeDefined() - }) - // Modal stays open — title still visible - expect(screen.getByRole("heading", { name: "Add Edge" })).toBeDefined() - expect(mockClose).not.toHaveBeenCalled() - }) - }) - // ------------------------------------------------------------------------- - // Close / reset - // ------------------------------------------------------------------------- - describe("Close behaviour", () => { - it("calls close() when the dialog is dismissed via onOpenChange", async () => { - openModal() - const { rerender } = render() - // Simulate dialog close by changing activeModal - mockActiveModal = null - rerender() - expect(screen.queryByText("Add Edge")).toBeNull() + await waitFor(() => expect(payL402).toHaveBeenCalled()) + await waitFor(() => expect(mockCreateEdge).toHaveBeenCalledTimes(2)) + await waitFor(() => expect(screen.getByText("Edge created!")).toBeDefined()) + expect(mockOpen).not.toHaveBeenCalled() }) - it("resets all field values and clears errors after close and reopen", async () => { - mockCreateEdge.mockRejectedValueOnce(new Error("bad")) - openModal(null) - const { rerender } = render() + it("opens the budget bar when payment fails on a 402", async () => { + mockCreateEdge.mockRejectedValue(new Response(null, { status: 402 })) + vi.mocked(payL402).mockRejectedValueOnce(new Error("not enough sats")) + withSource(null) + render() + await selectNode("Search source node…", FIXTURE_SOURCE) + await selectNode("Search target node…", FIXTURE_TARGET) + const trigger = screen.getByText("Choose an edge type...").closest("button") as HTMLButtonElement + await userEvent.click(trigger) + await userEvent.click(screen.getByText("HAS_TOPIC")) + await userEvent.click(screen.getByRole("button", { name: /add edge/i })) + + await waitFor(() => expect(mockOpen).toHaveBeenCalledWith("budget")) + expect(mockClose).not.toHaveBeenCalled() + }) - // Fill and submit to trigger error + it("shows inline error and keeps the form mounted when createEdge rejects", async () => { + mockCreateEdge.mockRejectedValueOnce(new Error("Duplicate edge")) + withSource(null) + render() await selectNode("Search source node…", FIXTURE_SOURCE) await selectNode("Search target node…", FIXTURE_TARGET) const trigger = screen.getByText("Choose an edge type...").closest("button") as HTMLButtonElement await userEvent.click(trigger) await userEvent.click(screen.getByText("HAS_TOPIC")) await userEvent.click(screen.getByRole("button", { name: /add edge/i })) - await waitFor(() => expect(screen.getByText("bad")).toBeDefined()) - - // Close modal - mockActiveModal = null - mockSourceNode = null - rerender() - - // Reopen fresh - mockActiveModal = "addEdge" - mockSourceNode = null - rerender() - - // Fields should be reset — empty search inputs visible - expect(screen.getByPlaceholderText("Search source node…")).toBeDefined() - expect(screen.getByPlaceholderText("Search target node…")).toBeDefined() - expect(screen.queryByText("bad")).toBeNull() + await waitFor(() => { + expect(screen.getByText("Duplicate edge")).toBeDefined() + }) + // Form stays mounted — submit button still present, close not called + expect(screen.getByRole("button", { name: /add edge/i })).toBeDefined() + expect(mockClose).not.toHaveBeenCalled() }) }) }) diff --git a/src/lib/__tests__/node-schema-utils.test.ts b/src/lib/__tests__/node-schema-utils.test.ts new file mode 100644 index 00000000..e6660b21 --- /dev/null +++ b/src/lib/__tests__/node-schema-utils.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from "vitest" +import { + humanizeFieldKey, + fieldTypeHint, + categorizeField, + fieldsForSchema, + OPTIONAL_GROUP_ORDER, +} from "@/lib/node-schema-utils" +import type { SchemaNode } from "@/app/ontology/page" + +describe("humanizeFieldKey", () => { + it("title-cases snake_case keys", () => { + expect(humanizeFieldKey("source_link")).toBe("Source link") + expect(humanizeFieldKey("english_translation")).toBe("English translation") + }) + it("splits camelCase", () => { + expect(humanizeFieldKey("dateAddedToGraph")).toBe("Date added to graph") + }) + it("handles single words and empties", () => { + expect(humanizeFieldKey("name")).toBe("Name") + expect(humanizeFieldKey("")).toBe("") + }) +}) + +describe("fieldTypeHint", () => { + it("normalizes backend types to short hints", () => { + expect(fieldTypeHint("integer")).toBe("int") + expect(fieldTypeHint("number")).toBe("float") + expect(fieldTypeHint("date")).toBe("datetime") + expect(fieldTypeHint("boolean")).toBe("bool") + expect(fieldTypeHint("string")).toBe("string") + expect(fieldTypeHint("")).toBe("string") + }) +}) + +describe("categorizeField", () => { + // Mirrors the design's groupings for the example Clip / Claim schemas. + it("buckets scoring attributes into signal", () => { + expect(categorizeField("sentiment_score", "float")).toBe("signal") + expect(categorizeField("boost", "int")).toBe("signal") + expect(categorizeField("num_boost", "int")).toBe("signal") + expect(categorizeField("confidence", "float")).toBe("signal") + expect(categorizeField("reliability", "float")).toBe("signal") + expect(categorizeField("influence", "float")).toBe("signal") + }) + it("buckets provenance/bookkeeping attributes into meta", () => { + expect(categorizeField("language", "string")).toBe("meta") + expect(categorizeField("date", "datetime")).toBe("meta") + expect(categorizeField("pub_key", "string")).toBe("meta") + expect(categorizeField("duration", "string")).toBe("meta") + expect(categorizeField("followers", "int")).toBe("meta") + expect(categorizeField("keywords", "string")).toBe("meta") + }) + it("buckets remaining string attributes into content", () => { + expect(categorizeField("english_translation", "string")).toBe("content") + expect(categorizeField("link", "string")).toBe("content") + expect(categorizeField("thumbnail", "string")).toBe("content") + expect(categorizeField("description", "string")).toBe("content") + expect(categorizeField("stance", "string")).toBe("content") + }) + it("only ever returns a known group", () => { + for (const key of ["x", "weird_attr", "foo123"]) { + expect(OPTIONAL_GROUP_ORDER).toContain(categorizeField(key)) + } + }) +}) + +describe("fieldsForSchema — hidden attributes", () => { + const schema = { + type: "Clip", + attributes: [ + { key: "text", type: "string", required: true }, + { key: "language", type: "string", required: false }, + { key: "project_id", type: "string", required: false }, + { key: "pub_key", type: "string", required: false }, + { key: "pubkey", type: "string", required: false }, + { key: "boost", type: "int", required: false }, + { key: "num_boost", type: "int", required: false }, + { key: "sentiment_score", type: "float", required: false }, + { key: "weight", type: "float", required: false }, + ], + } as unknown as SchemaNode + + const keys = fieldsForSchema(schema).map((f) => f.key) + + it("keeps user-facing content/metadata fields", () => { + expect(keys).toContain("text") + expect(keys).toContain("language") + }) + + it("hides project_id and pub_key/pubkey provenance fields", () => { + expect(keys).not.toContain("project_id") + expect(keys).not.toContain("pub_key") + expect(keys).not.toContain("pubkey") + }) + + it("hides the entire Signals & scoring group", () => { + expect(keys).not.toContain("boost") + expect(keys).not.toContain("num_boost") + expect(keys).not.toContain("sentiment_score") + }) + + it("still hides pre-existing system attributes", () => { + expect(keys).not.toContain("weight") + }) +}) diff --git a/src/lib/__tests__/settings-modal-removed.test.ts b/src/lib/__tests__/settings-modal-removed.test.ts index 5d5a1848..1939f5cb 100644 --- a/src/lib/__tests__/settings-modal-removed.test.ts +++ b/src/lib/__tests__/settings-modal-removed.test.ts @@ -17,7 +17,7 @@ beforeEach(() => { describe("modal-store – settings removed", () => { it("valid modal ids do not include 'settings'", () => { // Open each remaining valid modal and verify they work - const validIds = ["addContent", "budget", "addNode", "editNode", "addEdge"] as const + const validIds = ["add", "budget", "editNode"] as const for (const id of validIds) { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -33,7 +33,7 @@ describe("modal-store – settings removed", () => { it("close() resets activeModal to null", () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any - useModalStore.getState().open("addContent" as any) + useModalStore.getState().open("add" as any) useModalStore.getState().close() expect(useModalStore.getState().activeModal).toBeNull() }) diff --git a/src/lib/__tests__/toolkit-fab.test.tsx b/src/lib/__tests__/toolkit-fab.test.tsx index 746d7550..c67624c9 100644 --- a/src/lib/__tests__/toolkit-fab.test.tsx +++ b/src/lib/__tests__/toolkit-fab.test.tsx @@ -18,9 +18,10 @@ vi.mock("@/stores/user-store", () => ({ })) const modalOpen = vi.fn() +const modalOpenAdd = vi.fn() vi.mock("@/stores/modal-store", () => ({ useModalStore: (sel?: (s: unknown) => unknown) => { - const state = { open: modalOpen } + const state = { open: modalOpen, openAdd: modalOpenAdd } return sel ? sel(state) : state }, })) @@ -93,11 +94,10 @@ describe("ToolkitFAB", () => { const fab = screen.getByRole("button", { name: "Open menu" }) fireEvent.click(fab) - expect(screen.getByText("Add Content")).toBeInTheDocument() + expect(screen.getByText("Add to graph")).toBeInTheDocument() expect(screen.getByText("My Content")).toBeInTheDocument() expect(screen.getByText("Sources")).toBeInTheDocument() expect(screen.getByText("Following")).toBeInTheDocument() - expect(screen.getByText("Add Node")).toBeInTheDocument() }) it("clicking FAB again (Close menu) closes the popup", async () => { @@ -105,10 +105,10 @@ describe("ToolkitFAB", () => { render() fireEvent.click(screen.getByRole("button", { name: "Open menu" })) - expect(screen.getByText("Add Content")).toBeInTheDocument() + expect(screen.getByText("Add to graph")).toBeInTheDocument() fireEvent.click(screen.getByRole("button", { name: "Close menu" })) - expect(screen.queryByText("Add Content")).not.toBeInTheDocument() + expect(screen.queryByText("Add to graph")).not.toBeInTheDocument() }) it("clicking backdrop closes the popup", async () => { @@ -116,13 +116,13 @@ describe("ToolkitFAB", () => { const { container } = render() fireEvent.click(screen.getByRole("button", { name: "Open menu" })) - expect(screen.getByText("Add Content")).toBeInTheDocument() + expect(screen.getByText("Add to graph")).toBeInTheDocument() // The backdrop is a fixed inset-0 div rendered before the wrapper const backdrop = container.querySelector(".fixed.inset-0.z-40") expect(backdrop).not.toBeNull() fireEvent.click(backdrop!) - expect(screen.queryByText("Add Content")).not.toBeInTheDocument() + expect(screen.queryByText("Add to graph")).not.toBeInTheDocument() }) it("clicking an action button triggers the action and closes popup", async () => { @@ -130,10 +130,10 @@ describe("ToolkitFAB", () => { render() fireEvent.click(screen.getByRole("button", { name: "Open menu" })) - fireEvent.click(screen.getByText("Add Content")) + fireEvent.click(screen.getByText("Add to graph")) - expect(modalOpen).toHaveBeenCalledWith("addContent") - expect(screen.queryByText("Add Content")).not.toBeInTheDocument() + expect(modalOpenAdd).toHaveBeenCalledWith("source") + expect(screen.queryByText("Add to graph")).not.toBeInTheDocument() }) it("Sources button calls onToggleSources and closes popup", async () => { diff --git a/src/lib/node-schema-utils.ts b/src/lib/node-schema-utils.ts index 4effedf5..fa8d46f1 100644 --- a/src/lib/node-schema-utils.ts +++ b/src/lib/node-schema-utils.ts @@ -4,29 +4,109 @@ import type { SchemaNode, SchemaAttribute } from "@/app/ontology/page" // owner_reference_id is set from the LSAT, weight/is_muted are // graph-internal moderation knobs, unique_source_id is for dedup of // ingested content (Stakwork). date_added_to_graph is auto-set. +// project_id is the graph association and pub_key/pubkey are provenance — +// both set by the backend, not typed by hand. export const SYSTEM_ATTRIBUTES = new Set([ "weight", "is_muted", "unique_source_id", "owner_reference_id", "date_added_to_graph", + "project_id", + "pub_key", + "pubkey", ]) +// A field is hidden from every node form when it's a system/book-keeping +// attribute OR a computed "Signals & scoring" value (boost, sentiment_score, +// confidence, reliability, …). None of these are hand-entered — they're +// backend-managed, so we never render or submit them. +function isHiddenAttribute(a: SchemaAttribute): boolean { + return SYSTEM_ATTRIBUTES.has(a.key) || categorizeField(a.key, a.type) === "signal" +} + // Merge own + inherited attributes into one form-field list, with own -// attributes first. Duplicate keys are deduped (own wins). System-level -// inherited attrs are filtered out — they're backend-managed, not user input. +// attributes first. Duplicate keys are deduped (own wins). System-level and +// computed-signal attrs are filtered out — they're backend-managed, not input. export function fieldsForSchema(schema: SchemaNode): SchemaAttribute[] { const seen = new Set() const out: SchemaAttribute[] = [] for (const a of schema.attributes) { - if (seen.has(a.key) || SYSTEM_ATTRIBUTES.has(a.key)) continue + if (seen.has(a.key) || isHiddenAttribute(a)) continue seen.add(a.key) out.push(a) } for (const a of schema.inherited_attributes ?? []) { - if (seen.has(a.key) || SYSTEM_ATTRIBUTES.has(a.key)) continue + if (seen.has(a.key) || isHiddenAttribute(a)) continue seen.add(a.key) out.push(a) } return out } + +// Optional attributes are bucketed into these groups so the node form can +// collapse them into labelled sections (Content / Metadata / Signals) instead +// of a flat dump. Required ("core") fields are rendered separately, up front. +export type FieldGroup = "content" | "meta" | "signal" + +export const OPTIONAL_GROUP_ORDER: FieldGroup[] = ["content", "meta", "signal"] + +export const OPTIONAL_GROUP_LABELS: Record = { + content: "Content", + meta: "Metadata", + signal: "Signals & scoring", +} + +// Mono type hint shown on the right of each field label. +export function fieldTypeHint(type: string): string { + const t = type.toLowerCase() + if (t === "integer") return "int" + if (t === "number") return "float" + if (t === "datetime" || t === "date") return "datetime" + if (t === "boolean") return "bool" + return t || "string" +} + +// Scoring / signal attributes — numeric weights and model outputs. +const SIGNAL_TOKENS = [ + "boost", "sentiment", "confidence", "reliab", "influence", + "significan", "prevalence", "score", "rating", "weight", "rank", +] +// Bookkeeping / provenance attributes. +const META_TOKENS = [ + "language", "lang", "country", "region", "date", "time", "year", + "created", "updated", "first_seen", "duration", "follower", "likes", + "repost", "alias", "keyword", "pub_key", "pubkey", "count", "_id", +] + +// Bucket an optional attribute into a display group from its key (and type as a +// tiebreaker). Heuristic — the backend schema has no group metadata, so we +// infer it; anything unclassified falls into Content (the default catch-all). +export function categorizeField(key: string, type?: string): FieldGroup { + const k = key.toLowerCase() + if (SIGNAL_TOKENS.some((t) => k.includes(t))) return "signal" + if (META_TOKENS.some((t) => k.includes(t))) return "meta" + // Untagged numeric values read as signals more often than content. + const t = (type ?? "").toLowerCase() + if (t === "float" || t === "number") return "signal" + return "content" +} + +// Turn a raw attribute key into a human-readable label for form display. +// Splits on underscores and camelCase boundaries, then Title-cases: +// "source_link" → "Source link" +// "dateAddedToGraph" → "Date added to graph" +// The raw key is still shown alongside as a muted hint, so this only has to +// be readable, not reversible. +export function humanizeFieldKey(key: string): string { + const words = key + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .split(/[_\s]+/) + .filter(Boolean) + if (words.length === 0) return key + const [first, ...rest] = words + return [ + first.charAt(0).toUpperCase() + first.slice(1).toLowerCase(), + ...rest.map((w) => w.toLowerCase()), + ].join(" ") +} diff --git a/src/lib/unlock-node.ts b/src/lib/unlock-node.ts index 0496a05b..88726c86 100644 --- a/src/lib/unlock-node.ts +++ b/src/lib/unlock-node.ts @@ -3,7 +3,7 @@ import { useGraphStore } from "@/stores/graph-store" import { usePlayerStore } from "@/stores/player-store" /** - * Shared unlock helper used by both AddContentModal (cache-hit branch) and + * Shared unlock helper used by both AddSourceForm (cache-hit branch) and * NodePreviewPanel's unlock button. * * - Fetches GET /v2/nodes/:ref_id?expand=edges (L402 auto-attached via api.get) diff --git a/src/stores/modal-store.ts b/src/stores/modal-store.ts index 49f80bb5..07fbf17d 100644 --- a/src/stores/modal-store.ts +++ b/src/stores/modal-store.ts @@ -3,13 +3,19 @@ import { create } from "zustand" import type { GraphNode } from "@/lib/graph-api" -type ModalId = "addContent" | "budget" | "addNode" | "editNode" | "addEdge" | null +type ModalId = "add" | "budget" | "editNode" | null +export type AddTab = "source" | "node" | "edge" interface ModalState { activeModal: ModalId + // Which tab the unified Add modal opens on. Persisted across the modal's + // lifetime so deep-links (e.g. "Add Edge" from a node) can target a tab. + addTab: AddTab editingNode: GraphNode | null sourceNode: GraphNode | null open: (id: ModalId) => void + openAdd: (tab?: AddTab) => void + setAddTab: (tab: AddTab) => void openEdit: (node: GraphNode) => void openAddEdge: (sourceNode?: GraphNode) => void close: () => void @@ -17,10 +23,15 @@ interface ModalState { export const useModalStore = create((set) => ({ activeModal: null, + addTab: "source", editingNode: null, sourceNode: null, open: (activeModal) => set({ activeModal }), + openAdd: (tab) => set({ activeModal: "add", addTab: tab ?? "source", sourceNode: null }), + setAddTab: (tab) => set({ addTab: tab }), openEdit: (node) => set({ activeModal: "editNode", editingNode: node }), - openAddEdge: (sourceNode?: GraphNode) => set({ activeModal: "addEdge", sourceNode: sourceNode ?? null }), - close: () => set({ activeModal: null, editingNode: null, sourceNode: null }), + openAddEdge: (sourceNode?: GraphNode) => + set({ activeModal: "add", addTab: "edge", sourceNode: sourceNode ?? null }), + close: () => + set({ activeModal: null, addTab: "source", editingNode: null, sourceNode: null }), }))