diff --git a/components/GraphView.tsx b/components/GraphView.tsx index 8afd3eb..87e5fc9 100644 --- a/components/GraphView.tsx +++ b/components/GraphView.tsx @@ -101,6 +101,8 @@ export default function GraphView({ className, seedEntityId }: GraphViewProps) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [dimensions, setDimensions] = useState({ width: 800, height: 600 }); + const isMobile = dimensions.width < 600; + const hasRealDimensionsRef = useRef(false); const [hoveredNode, setHoveredNode] = useState(null); const [hiddenTypes, setHiddenTypes] = useState>(new Set()); const clickTimerRef = useRef | null>(null); @@ -128,6 +130,7 @@ export default function GraphView({ className, seedEntityId }: GraphViewProps) { canUndo, canRedo, commitEdge, + commitEdges, removeNode, undo, redo, @@ -196,14 +199,30 @@ export default function GraphView({ className, seedEntityId }: GraphViewProps) { return () => observer.disconnect(); }, []); - // Configure d3-force: stronger repulsion + longer links = more spread out + // Re-zoom to fit when dimensions change (fixes mobile race condition where + // zoomToFit runs at default 800x600 before ResizeObserver corrects to actual size) + useEffect(() => { + if (hasRealDimensionsRef.current) return; + // Skip the initial render (default dimensions haven't been corrected yet) + if (dimensions.width === 800 && dimensions.height === 600) return; + hasRealDimensionsRef.current = true; + // First real dimensions from ResizeObserver — schedule zoomToFit + const timer = setTimeout(() => { + graphRef.current?.zoomToFit(300, 40); + }, 500); + return () => clearTimeout(timer); + }, [dimensions]); + + // Configure d3-force: responsive parameters (tighter on mobile) useEffect(() => { const fg = graphRef.current; if (!fg) return; - fg.d3Force('charge')?.strength(-1500); - fg.d3Force('link')?.distance(220); - fg.d3Force('center')?.strength(0.015); - }, []); + fg.d3Force('charge')?.strength(isMobile ? -600 : -1500); + fg.d3Force('link')?.distance(isMobile ? 100 : 220); + fg.d3Force('center')?.strength(isMobile ? 0.05 : 0.015); + // Reheat simulation so new parameters take effect + fg.d3ReheatSimulation(); + }, [isMobile]); // Custom wheel handling: two-finger scroll → pan, pinch → zoom. // d3-zoom's built-in wheel handler is disabled via enableZoomInteraction={false}, @@ -520,18 +539,48 @@ export default function GraphView({ className, seedEntityId }: GraphViewProps) { [isExploring, stubs, getStubEndpoint], ); - // Pointer down: commit edge if hovering a stub + // Pointer down: commit edge if hovering a stub. + // Auto-discovers all edges between the new node and existing committed nodes. const handlePointerDown = useCallback( (e: React.PointerEvent) => { if (!hoveredStubRef.current) return; // Only handle left click if (e.button !== 0) return; e.stopPropagation(); - commitEdge(hoveredStubRef.current.edgeKey); + + const stub = hoveredStubRef.current; + const newNodeId = stub.targetNodeId; + + // Find all edges connecting the new node to already-committed nodes + const allEdgeKeys = [stub.edgeKey]; + const existingNodeIds = committedNodeIds; + + if (graphData) { + for (const link of graphData.links) { + const src = linkNodeId(link.source); + const tgt = linkNodeId(link.target); + const key = makeEdgeKey(src, tgt, link.rel_type); + if (key === stub.edgeKey) continue; // already included + if (committedEdges.has(key)) continue; // already committed + + // Edge connects new node to an existing committed node? + if ((src === newNodeId && existingNodeIds.has(tgt)) || + (tgt === newNodeId && existingNodeIds.has(src))) { + allEdgeKeys.push(key); + } + } + } + + if (allEdgeKeys.length === 1) { + commitEdge(stub.edgeKey); + } else { + commitEdges(allEdgeKeys); + } + hoveredStubRef.current = null; setHoveredStub(null); }, - [commitEdge], + [commitEdge, commitEdges, committedNodeIds, committedEdges, graphData], ); // ─── onRenderFramePost: draw stubs as canvas overlay ─────────────────────── @@ -858,7 +907,7 @@ export default function GraphView({ className, seedEntityId }: GraphViewProps) { cooldownTicks={200} minZoom={0.3} maxZoom={5} - onEngineStop={() => graphRef.current?.zoomToFit(400, 60)} + onEngineStop={() => graphRef.current?.zoomToFit(400, isMobile ? 20 : 60)} enableZoomInteraction={false} enablePanInteraction={true} onRenderFramePost={onRenderFramePost as any} // eslint-disable-line @typescript-eslint/no-explicit-any diff --git a/components/ImageUpload.tsx b/components/ImageUpload.tsx index f2b38df..219ccc2 100644 --- a/components/ImageUpload.tsx +++ b/components/ImageUpload.tsx @@ -5,15 +5,13 @@ import { useState, useRef, useCallback, type DragEvent, type ChangeEvent } from const ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']); const MAX_SIZE = 5 * 1024 * 1024; // 5 MB -type Mode = 'upload' | 'url'; - interface ImageUploadProps { value: string; onChange: (url: string) => void; } export default function ImageUpload({ value, onChange }: ImageUploadProps) { - const [mode, setMode] = useState('upload'); + const [showUrlInput, setShowUrlInput] = useState(false); const [uploading, setUploading] = useState(false); const [error, setError] = useState(''); const [dragOver, setDragOver] = useState(false); @@ -82,7 +80,6 @@ export default function ImageUpload({ value, onChange }: ImageUploadProps) { const handleFileChange = useCallback((e: ChangeEvent) => { const file = e.target.files?.[0]; if (file) uploadFile(file); - // Reset so the same file can be re-selected if (fileInputRef.current) fileInputRef.current.value = ''; }, [uploadFile]); @@ -91,7 +88,7 @@ export default function ImageUpload({ value, onChange }: ImageUploadProps) { setError(''); }, [onChange]); - // If there's already a value, show the preview + // Preview mode: show the current image if (value) { return (
@@ -123,76 +120,58 @@ export default function ImageUpload({ value, onChange }: ImageUploadProps) {
- {/* Mode toggle */} -
- - -
- - {mode === 'upload' ? ( - <> -
fileInputRef.current?.click()} - className={`border-2 border-dashed rounded-lg px-4 py-6 text-center cursor-pointer transition-colors ${ - dragOver - ? 'border-blue-500 bg-blue-500/10' - : 'border-white/15 hover:border-white/30 bg-white/5' - }`} - > - {uploading ? ( -
- - - - - Uploading... -
- ) : ( -
-

Drop an image here or click to browse

-

JPEG, PNG, WebP, GIF · max 5 MB

-
- )} + {/* Drop zone / file picker */} +
fileInputRef.current?.click()} + className={`border-2 border-dashed rounded-lg px-4 py-6 text-center cursor-pointer transition-colors ${ + dragOver + ? 'border-blue-500 bg-blue-500/10' + : 'border-white/15 hover:border-white/30 bg-white/5' + }`} + > + {uploading ? ( +
+ + + + + Uploading...
- - - ) : ( + ) : ( +
+

Drop an image here or click to browse

+

JPEG, PNG, WebP, GIF · max 5 MB

+
+ )} +
+ + + {/* URL fallback */} + {showUrlInput ? ( onChange(e.target.value)} - className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/50 text-sm" + className="mt-2 w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/50 text-sm" placeholder="https://..." + autoFocus /> + ) : ( + )} {error && ( diff --git a/components/Navbar.tsx b/components/Navbar.tsx index 78b536d..4fd9053 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useState, useEffect, useRef } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import UserMenu from './UserMenu'; @@ -16,20 +17,78 @@ const NAV_ITEMS = [ export default function Navbar() { const pathname = usePathname(); + const [mobileOpen, setMobileOpen] = useState(false); + const navRef = useRef(null); + + // Close mobile dropdown on outside click + useEffect(() => { + if (!mobileOpen) return; + function handleClick(e: MouseEvent) { + if (navRef.current && !navRef.current.contains(e.target as Node)) { + setMobileOpen(false); + } + } + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [mobileOpen]); return ( -
+ )} ); } diff --git a/components/UserMenu.tsx b/components/UserMenu.tsx index b504031..169bd3c 100644 --- a/components/UserMenu.tsx +++ b/components/UserMenu.tsx @@ -67,7 +67,17 @@ export default function UserMenu() { } if (loading) { - return
; + // Render a real Sign In link during loading/SSR so it's clickable + // before JavaScript hydrates. If the user IS logged in, this briefly + // flashes before switching to the user menu — acceptable trade-off. + return ( + + Sign In + + ); } if (!user) { diff --git a/lib/hooks/useSubgraphExploration.ts b/lib/hooks/useSubgraphExploration.ts index 3b840c8..b651194 100644 --- a/lib/hooks/useSubgraphExploration.ts +++ b/lib/hooks/useSubgraphExploration.ts @@ -6,6 +6,7 @@ import { useState, useCallback, useEffect, useRef } from 'react'; type Action = | { type: 'commit-edge'; edgeKey: string } + | { type: 'commit-edges'; edgeKeys: string[] } | { type: 'remove-node'; nodeId: string; removedEdges: string[] }; const MAX_STACK = 50; @@ -17,6 +18,7 @@ export interface SubgraphExplorationState { canUndo: boolean; canRedo: boolean; commitEdge: (edgeKey: string) => void; + commitEdges: (edgeKeys: string[]) => void; removeNode: (nodeId: string) => void; undo: () => void; redo: () => void; @@ -66,6 +68,27 @@ export function useSubgraphExploration(): SubgraphExplorationState { [bumpStack, setEdges], ); + // Batch commit: adds multiple edges as a single undo action. + // Used when a new node joins the subgraph and has multiple edges to existing nodes. + const commitEdges = useCallback( + (edgeKeys: string[]) => { + const newKeys = edgeKeys.filter((k) => !committedEdgesRef.current.has(k)); + if (newKeys.length === 0) return; + + const nextEdges = new Set(committedEdgesRef.current); + for (const k of newKeys) nextEdges.add(k); + setEdges(nextEdges); + + undoStackRef.current.push({ type: 'commit-edges', edgeKeys: newKeys }); + if (undoStackRef.current.length > MAX_STACK) { + undoStackRef.current.shift(); + } + redoStackRef.current = []; + bumpStack(); + }, + [bumpStack, setEdges], + ); + const removeNode = useCallback( (nodeId: string) => { // Remove all committed edges touching this node @@ -101,6 +124,10 @@ export function useSubgraphExploration(): SubgraphExplorationState { const nextEdges = new Set(committedEdgesRef.current); nextEdges.delete(action.edgeKey); setEdges(nextEdges); + } else if (action.type === 'commit-edges') { + const nextEdges = new Set(committedEdgesRef.current); + for (const k of action.edgeKeys) nextEdges.delete(k); + setEdges(nextEdges); } else { // action.type === 'remove-node' — restore all removed edges const nextEdges = new Set(committedEdgesRef.current); @@ -122,6 +149,10 @@ export function useSubgraphExploration(): SubgraphExplorationState { const nextEdges = new Set(committedEdgesRef.current); nextEdges.add(action.edgeKey); setEdges(nextEdges); + } else if (action.type === 'commit-edges') { + const nextEdges = new Set(committedEdgesRef.current); + for (const k of action.edgeKeys) nextEdges.add(k); + setEdges(nextEdges); } else { // action.type === 'remove-node' — re-remove edges const nextEdges = new Set(committedEdgesRef.current); @@ -197,6 +228,7 @@ export function useSubgraphExploration(): SubgraphExplorationState { canUndo: undoStackRef.current.length > 0, canRedo: redoStackRef.current.length > 0, commitEdge, + commitEdges, removeNode, undo, redo,