From 2617612ed7521f990671fa7f4f4dca0a186ce8cb Mon Sep 17 00:00:00 2001 From: Rassl Date: Mon, 15 Jun 2026 03:42:03 +0400 Subject: [PATCH] feat: dedicated collapsible image section in edit-node modal Move image_url out of the Save-gated Properties list into its own collapsed-by-default section (small thumbnail + label, expands on demand to upload/replace/remove). Image still persists immediately, independent of Save, and the section resets to collapsed on each open. Seed the edit modal from the preview panel's synced currentNode (openEdit(currentNode)) instead of a stale fullNode snapshot, and drop the now-unneeded fullNode image_url patch effect. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/layout/node-preview-panel.tsx | 18 +- src/components/modals/edit-node-modal.tsx | 264 +++++++++++++++---- 2 files changed, 232 insertions(+), 50 deletions(-) diff --git a/src/components/layout/node-preview-panel.tsx b/src/components/layout/node-preview-panel.tsx index a25ebad..e57a26b 100644 --- a/src/components/layout/node-preview-panel.tsx +++ b/src/components/layout/node-preview-panel.tsx @@ -866,13 +866,24 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp } } - // Reset local state when an external node selection replaces the prop + // Full reset (currentNode + history + scroll) only when a genuinely different + // node is selected. useEffect(() => { setCurrentNode(node) setHistory([]) scrollContentRef.current?.parentElement?.scrollTo({ top: 0, behavior: 'instant' }) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [node.ref_id]) + // Pick up in-place edits to the *currently displayed* node (same ref_id, new + // object) — e.g. an image upload/removal that re-sets selectedNode — so the + // panel refreshes without a full page reload. The ref_id guard avoids + // clobbering a peer the user has navigated to, and leaves history/scroll + // untouched. + useEffect(() => { + setCurrentNode((prev) => (prev.ref_id === node.ref_id ? node : prev)) + }, [node]) + function handleNavigate(peer: GraphNode) { setHistory((prev) => [...prev, currentNode]) setCurrentNode(peer) @@ -1313,7 +1324,10 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp {/* Edit node */} {isAdmin && ( - openEdit(fullNode ?? currentNode)}> + // Seed the modal from the panel's live, in-sync node (currentNode), + // not the separately-fetched fullNode — so reopening after an edit + // always reflects the latest values (one source of truth). + openEdit(currentNode)}> Edit node diff --git a/src/components/modals/edit-node-modal.tsx b/src/components/modals/edit-node-modal.tsx index 310a46b..9ba27c1 100644 --- a/src/components/modals/edit-node-modal.tsx +++ b/src/components/modals/edit-node-modal.tsx @@ -1,7 +1,7 @@ "use client" import { useState, useEffect, useMemo, useCallback } from "react" -import { X, Check, HelpCircle, AlertCircle, Trash2 } from "lucide-react" +import { Check, HelpCircle, AlertCircle, Trash2, ChevronDown, ImageIcon } from "lucide-react" import { Dialog, DialogContent, @@ -20,6 +20,7 @@ import { uploadImageToNode, ALLOWED_IMAGE_TYPES, MAX_IMAGE_UPLOAD_BYTES, + type GraphNode, } from "@/lib/graph-api" import { payL402 } from "@/lib/sphinx" import { isMocksEnabled } from "@/lib/mock-data" @@ -93,15 +94,16 @@ const SYNTHETIC_IMAGE_FIELD: SchemaAttribute = { // --------------------------------------------------------------------------- // Sub-component: image upload / remove row // --------------------------------------------------------------------------- -// Image is managed by action (upload a file, or remove), not by editing the -// URL string. Upload commits immediately via the multipart endpoint; remove is -// applied on Save (deletes the property). +// Image is managed by action (upload a file, or remove), not by editing the URL +// string. Both upload and remove persist IMMEDIATELY (independent of the Save +// button) — `notice` surfaces that so it's clear the change already applied. function ImageUploadRow({ field, value, localPreview, uploading, error, + notice, onPickFile, onRemove, disabled, @@ -111,6 +113,7 @@ function ImageUploadRow({ localPreview: string | null uploading: boolean error: string | null + notice: null | "saved" | "removed" onPickFile: (file: File) => void onRemove: () => void disabled: boolean @@ -186,11 +189,108 @@ function ImageUploadRow({ )} - {error && ( + {error ? (

{error}

+ ) : notice ? ( +

+ + {notice === "saved" ? "Image saved" : "Image removed"} — applied immediately, no need to Save. +

+ ) : ( +

+ Image changes save immediately. +

+ )} + + ) +} + +// --------------------------------------------------------------------------- +// Sub-component: collapsible image section +// --------------------------------------------------------------------------- +// Wraps ImageUploadRow in a disclosure that's collapsed by default. The header +// stays compact (small thumbnail + label) and visually distinct from the +// Save-gated property fields, reinforcing that image changes apply immediately. +function ImageSection({ + field, + value, + localPreview, + uploading, + error, + notice, + open, + onToggle, + onPickFile, + onRemove, + disabled, +}: { + field: SchemaAttribute + value: string + localPreview: string | null + uploading: boolean + error: string | null + notice: null | "saved" | "removed" + open: boolean + onToggle: () => void + onPickFile: (file: File) => void + onRemove: () => void + disabled: boolean +}) { + const thumb = localPreview ?? (value.trim() ? value.trim() : null) + const summary = uploading + ? "Working…" + : thumb + ? "Saved — click to replace or remove" + : "Add an image · saves immediately" + + return ( +
+ + + {open && ( +
+ +
)}
) @@ -269,6 +369,12 @@ export function EditNodeModal() { const [imagePreview, setImagePreview] = useState(null) // User cleared the image via "Remove" — delete the property on Save. const [imageRemoved, setImageRemoved] = useState(false) + // Inline confirmation under the image control: image changes persist + // immediately (independent of Save), so we tell the user so. + const [imageNotice, setImageNotice] = useState(null) + // The image section is collapsed by default (open on demand) — it's a separate + // concern from the Save-gated properties and shouldn't dominate the modal. + const [imageSectionOpen, setImageSectionOpen] = useState(false) // ----- Schema lookups ----- const originalSchema = useMemo( @@ -290,17 +396,19 @@ export function EditNodeModal() { [selectedSchema] ) - // Fields to actually render: schema fields, plus a synthetic image_url row on - // every node whose schema doesn't already declare it — image editing is - // offered universally (see SYNTHETIC_IMAGE_FIELD). - const schemaHasImageField = useMemo( - () => selectedFields.some((f) => f.key === IMAGE_FIELD_KEY), + // image_url is handled by its own dedicated section (see below), never as a + // Properties row — it persists immediately and shouldn't read as Save-gated. + // So strip it from the Save-driven property fields here. + const propertyFields = useMemo( + () => selectedFields.filter((f) => f.key !== IMAGE_FIELD_KEY), + [selectedFields] + ) + // The field metadata for the image section: the schema's own image_url field + // if it declares one, otherwise the synthetic universal field. + const imageField = useMemo( + () => selectedFields.find((f) => f.key === IMAGE_FIELD_KEY) ?? SYNTHETIC_IMAGE_FIELD, [selectedFields] ) - const renderFields = useMemo(() => { - if (schemaHasImageField) return selectedFields - return [...selectedFields, SYNTHETIC_IMAGE_FIELD] - }, [selectedFields, schemaHasImageField]) // ----- On modal open: initialise state ----- useEffect(() => { @@ -337,6 +445,8 @@ export function EditNodeModal() { setImageError(null) setImagePreview(null) setImageRemoved(false) + setImageNotice(null) + setImageSectionOpen(false) // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen]) @@ -346,6 +456,20 @@ export function EditNodeModal() { return () => URL.revokeObjectURL(imagePreview) }, [imagePreview]) + // Push an image change into the selected node so the preview panel reflects it + // immediately (no page reload). Image changes persist server-side on their own + // (upload endpoint / immediate delete), so this just mirrors that into the UI. + const reflectImageInPreview = useCallback( + (imageUrl: string | null) => { + if (!editingNode) return + const props = { ...editingNode.properties } + if (imageUrl) props[IMAGE_FIELD_KEY] = imageUrl + else delete props[IMAGE_FIELD_KEY] + setSelectedNode({ ...editingNode, properties: props }) + }, + [editingNode, setSelectedNode] + ) + // ----- Image upload handler ----- const handleImageUpload = useCallback( async (fieldKey: string, file: File) => { @@ -369,21 +493,24 @@ export function EditNodeModal() { // Immediate local preview. setImagePreview(URL.createObjectURL(file)) + setImageNotice(null) if (isMocksEnabled()) { console.log("[EditNodeModal] mock image upload", { ref_id: editingNode.ref_id, file }) + setImageNotice("saved") return } setImageUploading(true) const doUpload = async () => { // Backend stages to temp S3, sets image_url to the temp URL, and kicks - // off the workflow that swaps in the permanent URL. Sync the temp URL - // into the form purely for preview — image_url is persisted server-side, - // NOT via Save (see handleSave), so the workflow's permanent URL can't - // be clobbered. + // off the workflow that swaps in the permanent URL. The image is + // persisted server-side here (independent of Save), so mirror it into + // the form (preview) and into the selected node (live panel refresh). const res = await uploadImageToNode(editingNode.ref_id, file) setFieldValues((prev) => ({ ...prev, [fieldKey]: res.url })) + reflectImageInPreview(res.url) + setImageNotice("saved") } try { @@ -408,17 +535,39 @@ export function EditNodeModal() { setImageUploading(false) } }, - [editingNode, setBudget] + [editingNode, setBudget, reflectImageInPreview] ) // ----- Image remove handler ----- - // Clears the image locally; the property is deleted on Save. - const handleImageRemove = useCallback(() => { + // Removal persists immediately (like upload), so it's not tied to Save: + // delete image_url server-side, then mirror the change into the form + panel. + const handleImageRemove = useCallback(async () => { + if (!editingNode || imageUploading) return setImageError(null) setImagePreview(null) - setImageRemoved(true) - setFieldValues((prev) => ({ ...prev, [IMAGE_FIELD_KEY]: "" })) - }, []) + setImageNotice(null) + setImageUploading(true) + try { + if (!isMocksEnabled()) { + await adminUpdateNode({ + // Use the node's existing type — never the pending type change — so a + // removal can't accidentally relabel the node. + ref_id: editingNode.ref_id, + node_type: editingNode.node_type, + node_data: {}, + properties_to_be_deleted: [IMAGE_FIELD_KEY], + }) + } + setFieldValues((prev) => ({ ...prev, [IMAGE_FIELD_KEY]: "" })) + setImageRemoved(true) + reflectImageInPreview(null) + setImageNotice("removed") + } catch { + setImageError("Couldn't remove the image. Please try again.") + } finally { + setImageUploading(false) + } + }, [editingNode, imageUploading, reflectImageInPreview]) // ----- Compute mappings when type changes ----- const typeChanged = selectedType !== originalType && selectedType !== "" @@ -593,11 +742,27 @@ export function EditNodeModal() { } } + // Build the post-save node so the preview reflects every change without a + // reload. node_data already excludes image_url (managed out of band), so + // re-apply the current image_url from fieldValues here. + const updatedProps: Record = { ...editingNode.properties, ...node_data } + for (const k of properties_to_be_deleted) delete updatedProps[k] + const imgVal = (fieldValues[IMAGE_FIELD_KEY] ?? "").trim() + if (imgVal) updatedProps[IMAGE_FIELD_KEY] = imgVal + else delete updatedProps[IMAGE_FIELD_KEY] + const updatedNode = { + ...editingNode, + node_type: selectedType, + properties: updatedProps, + // name is hoisted to the node's top level by the serializer; mirror it. + ...(typeof updatedProps.name === "string" ? { name: updatedProps.name } : {}), + } as GraphNode + if (isMocksEnabled()) { console.log("[EditNodeModal] mock save", { node_data, properties_to_be_deleted }) close() clearSelection() - setSelectedNode(editingNode) + setSelectedNode(updatedNode) return } @@ -611,7 +776,7 @@ export function EditNodeModal() { close() clearSelection() - setSelectedNode(editingNode) + setSelectedNode(updatedNode) } catch (err: unknown) { const msg = err instanceof Error ? err.message : "An unexpected error occurred. Please try again." @@ -671,34 +836,37 @@ export function EditNodeModal() { /> + {/* Image — its own section, collapsed by default. Persists immediately + (independent of Save), so it lives apart from the property fields. */} + setImageSectionOpen((v) => !v)} + onPickFile={(file) => handleImageUpload(IMAGE_FIELD_KEY, file)} + onRemove={handleImageRemove} + disabled={saving} + /> + {/* Phase A: schema-driven fields */} - {renderFields.length > 0 && ( + {propertyFields.length > 0 && (

Properties

- {renderFields.map((field) => ( + {propertyFields.map((field) => (
- {field.key === IMAGE_FIELD_KEY ? ( - handleImageUpload(field.key, file)} - onRemove={handleImageRemove} - disabled={saving} - /> - ) : ( - - setFieldValues((prev) => ({ ...prev, [key]: val })) - } - /> - )} + + setFieldValues((prev) => ({ ...prev, [key]: val })) + } + /> {isRequiredUnmet(field.key) && (