From 525b2c208e0f0a34b3a464eeaa4d3162700750ee Mon Sep 17 00:00:00 2001 From: Rassl Date: Fri, 22 May 2026 01:30:44 +0400 Subject: [PATCH] feat: image validation --- src/components/layout/node-preview-panel.tsx | 50 +++- src/components/modals/add-node-modal.tsx | 299 +++++++++++-------- src/components/universe/graph-canvas.tsx | 1 + src/lib/graph-api.ts | 58 ++++ 4 files changed, 275 insertions(+), 133 deletions(-) diff --git a/src/components/layout/node-preview-panel.tsx b/src/components/layout/node-preview-panel.tsx index 0079f28..668b37e 100644 --- a/src/components/layout/node-preview-panel.tsx +++ b/src/components/layout/node-preview-panel.tsx @@ -13,6 +13,7 @@ import { payL402 } from "@/lib/sphinx" import { isSphinx } from "@/lib/sphinx/detect" import { buildSphinxDeepLink } from "@/lib/sphinx/deep-link" import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu" +import { Dialog, DialogContent } from "@/components/ui/dialog" import { unlockNode } from "@/lib/unlock-node" import { isMocksEnabled, MOCK_FULL_NODES } from "@/lib/mock-data" import { usePlayerStore } from "@/stores/player-store" @@ -463,6 +464,48 @@ function PersonCard({ props }: { props: Record }) { ) } +// Image-type node — the image IS the content, so render it full-width with +// native aspect ratio. Click opens a lightbox at viewport size. Falls back +// across the property keys an Image node might use depending on backend +// (uploaded files land in image_url; some pipelines use source_link/url). +function ImageCard({ props }: { props: Record }) { + const [open, setOpen] = useState(false) + const raw = + (typeof props.image_url === "string" && props.image_url) || + (typeof props.source_link === "string" && props.source_link) || + (typeof props.url === "string" && props.url) || + null + if (!raw) return null + + return ( + <> + + + + + + + + ) +} + // --- Ordered children / parent breadcrumb components --- export function ChildContentBlock({ heading, body }: { heading: string; body: string }) { @@ -788,11 +831,13 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp const thumbnail = (props?.image_url ?? props?.thumbnail) as string | undefined // Hide the static thumbnail when this node is the one currently playing — // the inline MediaPlayer card (rendered by MediaCard below) already shows - // the video frame, so both together would be a duplicate. + // the video frame, so both together would be a duplicate. Also suppress + // for Image-type nodes, which render their own full-width ImageCard. const isThisNodePlayingHere = usePlayerStore( (s) => s.playingNode?.ref_id === currentNode.ref_id ) - const showThumbnail = !!thumbnail && !isThisNodePlayingHere + const isImageNode = currentNode.node_type === "Image" + const showThumbnail = !!thumbnail && !isThisNodePlayingHere && !isImageNode async function handleUnlock() { setUnlockState("loading") @@ -1268,6 +1313,7 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp {hasTwitterAccount && } {hasPerson && } {hasMedia && fullNode && } + {isImageNode && } {hasSummary && } {hasArticle && } {hasWebPageLink && ( diff --git a/src/components/modals/add-node-modal.tsx b/src/components/modals/add-node-modal.tsx index 5e12389..ca38d6c 100644 --- a/src/components/modals/add-node-modal.tsx +++ b/src/components/modals/add-node-modal.tsx @@ -15,21 +15,29 @@ 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, - uploadImageToNode, type SchemaDomainsResponse, } from "@/lib/graph-api" import type { SchemaNode } from "@/app/ontology/page" -import { SYSTEM_ATTRIBUTES, fieldsForSchema } from "@/lib/node-schema-utils" +import { fieldsForSchema } from "@/lib/node-schema-utils" type Status = "idle" | "checking" | "submitting" | "success" | "error" | "uploading" -// Image type triggers a second phase: after node creation we ask for a file -// to upload to /v2/images//upload. Constant lives here so other parts -// of the modal can branch on the same name without typos. +// 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. @@ -39,18 +47,6 @@ function actualKeyField(schema: SchemaNode): string { return raw.startsWith(prefix) ? raw.slice(prefix.length) : raw } -function extractRefId(response: unknown): string | null { - if (!response || typeof response !== "object") return null - const r = response as Record - const data = r.data as Record | undefined - if (data && typeof data.ref_id === "string") return data.ref_id - const nodes = r.nodes as Array> | undefined - if (Array.isArray(nodes) && nodes[0] && typeof nodes[0].ref_id === "string") { - return nodes[0].ref_id - } - return null -} - // 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 { @@ -82,11 +78,25 @@ export function AddNodeModal() { const [price, setPrice] = useState(null) const [status, setStatus] = useState("idle") const [errorMsg, setErrorMsg] = useState(null) - // When non-null, the modal is in "upload phase": Image node already created, - // waiting for the user to pick a file to attach. - const [pendingImage, setPendingImage] = useState<{ refId: string } | null>(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 @@ -128,6 +138,15 @@ export function AddNodeModal() { [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 @@ -147,7 +166,7 @@ export function AddNodeModal() { setFieldValues({}) setErrorMsg(null) setStatus("idle") - setPendingImage(null) + setSelectedFile(null) abortRef.current?.abort() close() }, [close]) @@ -165,8 +184,33 @@ export function AddNodeModal() { return } - // Required attributes must all have values. - const missing = fields + 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) @@ -175,9 +219,52 @@ export function AddNodeModal() { return } - // The node_key (usually `name`) is what the server matches duplicates - // on. Backend stores it type-prefixed (e.g. "transport-name") — strip - // that to recover the actual attribute name the form is using. + 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) { @@ -185,12 +272,6 @@ export function AddNodeModal() { return } - abortRef.current?.abort() - const controller = new AbortController() - abortRef.current = controller - - // Build the payload: only fields with non-empty trimmed values, coerced - // by their declared attribute type. const nodeData: Record = {} for (const f of fields) { const v = parseFieldValue(f.type, fieldValues[f.key] ?? "") @@ -228,22 +309,6 @@ export function AddNodeModal() { setErrorMsg(`A ${selectedSchema.type} with this ${keyField} already exists.`) return } - // Image nodes flip into the upload phase instead of auto-closing. - // Every other type closes after a brief success flash. - if (selectedSchema.type === IMAGE_TYPE) { - const refId = extractRefId(response) - if (refId) { - setStatus("idle") - setPendingImage({ refId }) - return - } - // No ref_id in the response — treat as a soft error so the user - // sees we got partway. Node likely exists; they'd need to re-find - // it to attach the image, which isn't possible from this modal. - setStatus("error") - setErrorMsg("Image node created but ref_id missing — can't attach file. Reload and try again.") - return - } setStatus("success") setTimeout(() => handleClose(), 1500) } @@ -267,93 +332,26 @@ export function AddNodeModal() { setErrorMsg("Something went wrong. Please try again.") } }, - [selectedSchema, fields, fieldValues, setBudget, handleClose] - ) - - // File-pick handler for the upload phase. Uploads immediately and closes - // the modal on success; failures keep the phase open so the user can retry - // with a different file. - const handleFilePick = useCallback( - async (file: File | null) => { - if (!file || !pendingImage) return - abortRef.current?.abort() - const controller = new AbortController() - abortRef.current = controller - - setStatus("uploading") - setErrorMsg(null) - try { - await uploadImageToNode(pendingImage.refId, file, controller.signal) - setStatus("success") - setTimeout(() => handleClose(), 800) - } catch (err) { - if (err instanceof DOMException && err.name === "AbortError") 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.") - } - } - }, - [pendingImage, handleClose] + [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()}> - {pendingImage ? "Attach Image" : "Add Node"} + Add Node - {pendingImage - ? "Image node created. Pick a file to upload — it'll be resized and stored automatically." - : "Create a new node in the graph. Choose a type, then fill in its attributes."} + Create a new node in the graph. Choose a type, then fill in its attributes. - {pendingImage ? ( -
- - - {status === "uploading" && ( -

Uploading…

- )} - {status === "success" && ( -

Uploaded. Closing…

- )} - {errorMsg &&

{errorMsg}

} - -

- Skip the upload to leave the node imageless — you can come back - later to attach a file. -

- -
- -
-
- ) : (
{/* Type picker */}
@@ -373,6 +371,7 @@ export function AddNodeModal() { // attribute set cleanly. setSelectedType(v) setFieldValues({}) + setSelectedFile(null) setErrorMsg(null) }} options={typeOptions} @@ -381,10 +380,47 @@ export function AddNodeModal() { )}
+ {/* 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 && fields.length > 0 && ( + {selectedSchema && visibleFields.length > 0 && (
- {fields.map((f) => ( + {visibleFields.map((f) => (
- )}
) diff --git a/src/components/universe/graph-canvas.tsx b/src/components/universe/graph-canvas.tsx index 9f1643f..874a4ef 100644 --- a/src/components/universe/graph-canvas.tsx +++ b/src/components/universe/graph-canvas.tsx @@ -341,6 +341,7 @@ function applyLayout(graph: Graph) { graph.originalPositions = snapshot } + // Matches DEPTH_SHRINK in computeRadialLayout. Click inflation is the // inverse: 1/0.45^d makes the ring around a depth-d node land at R1 again. const DEPTH_SHRINK = 0.45 diff --git a/src/lib/graph-api.ts b/src/lib/graph-api.ts index ae9052f..24a5b29 100644 --- a/src/lib/graph-api.ts +++ b/src/lib/graph-api.ts @@ -195,6 +195,64 @@ export async function uploadImageToNode( return response.json() } +// Mirrors jarvis ALLOWED_ORIGINAL_TYPES + MAX_IMAGE_UPLOAD_BYTES. Kept in sync +// manually — change both at once. SVG/HEIC deliberately excluded (XSS surface, +// missing Pillow codec respectively). +export const ALLOWED_IMAGE_TYPES = [ + "image/jpeg", + "image/png", + "image/webp", + "image/gif", +] as const +export const MAX_IMAGE_UPLOAD_BYTES = 20 * 1024 * 1024 + +// Single-shot image content upload — multipart POST to /v2/content/image. +// Backend stages the original bytes in sphinx-swarm/temp, creates the Image +// node, and triggers the Stakwork workflow that relocates the file to +// permanent storage. Caller doesn't need to call createNode first. +// +// Multipart same as uploadImageToNode — bypasses api.post (which forces +// application/json) so the browser can set the multipart boundary itself. +export async function addImageContent( + file: File, + opts: { name?: string; webhookUrl?: string } = {}, + signal?: AbortSignal +): Promise<{ + status: string + nodes: Array> + status_messages: string[] + temp_url?: string +}> { + const url = new URL(`${API_URL}/v2/content/image`) + + const signed = await getSignedMessage() + if (signed.signature) { + url.searchParams.append("sig", signed.signature) + url.searchParams.append("msg", signed.message) + } + + const headers: Record = {} + const l402 = await getL402() + if (l402) headers.Authorization = l402 + + const form = new FormData() + form.append("file", file) + if (opts.name) form.append("name", opts.name) + if (opts.webhookUrl) form.append("webhook_url", opts.webhookUrl) + + const response = await fetch(url.toString(), { + method: "POST", + headers, + body: form, + signal: signal ?? new AbortController().signal, + }) + + if (!response.ok) { + throw response + } + return response.json() +} + // Update a node export async function updateNode( refId: string,