Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 58 additions & 9 deletions components/GraphView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ export default function GraphView({ className, seedEntityId }: GraphViewProps) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
const isMobile = dimensions.width < 600;
const hasRealDimensionsRef = useRef(false);
const [hoveredNode, setHoveredNode] = useState<SimNode | null>(null);
const [hiddenTypes, setHiddenTypes] = useState<Set<EntityType>>(new Set());
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand Down Expand Up @@ -128,6 +130,7 @@ export default function GraphView({ className, seedEntityId }: GraphViewProps) {
canUndo,
canRedo,
commitEdge,
commitEdges,
removeNode,
undo,
redo,
Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -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<HTMLDivElement>) => {
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 ───────────────────────
Expand Down Expand Up @@ -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
Expand Down
117 changes: 48 additions & 69 deletions components/ImageUpload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Mode>('upload');
const [showUrlInput, setShowUrlInput] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState('');
const [dragOver, setDragOver] = useState(false);
Expand Down Expand Up @@ -82,7 +80,6 @@ export default function ImageUpload({ value, onChange }: ImageUploadProps) {
const handleFileChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
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]);

Expand All @@ -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 (
<div>
Expand Down Expand Up @@ -123,76 +120,58 @@ export default function ImageUpload({ value, onChange }: ImageUploadProps) {
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Image</label>

{/* Mode toggle */}
<div className="flex gap-1 mb-2">
<button
type="button"
onClick={() => { setMode('upload'); setError(''); }}
className={`text-xs px-2.5 py-1 rounded transition-colors cursor-pointer ${
mode === 'upload'
? 'bg-blue-600/30 text-blue-300 border border-blue-500/40'
: 'bg-white/5 text-gray-400 border border-white/10 hover:bg-white/10'
}`}
>
Upload
</button>
<button
type="button"
onClick={() => { setMode('url'); setError(''); }}
className={`text-xs px-2.5 py-1 rounded transition-colors cursor-pointer ${
mode === 'url'
? 'bg-blue-600/30 text-blue-300 border border-blue-500/40'
: 'bg-white/5 text-gray-400 border border-white/10 hover:bg-white/10'
}`}
>
URL
</button>
</div>

{mode === 'upload' ? (
<>
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => 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 ? (
<div className="flex items-center justify-center gap-2 text-sm text-gray-400">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Uploading...
</div>
) : (
<div className="text-sm text-gray-400">
<p>Drop an image here or click to browse</p>
<p className="text-xs text-gray-500 mt-1">JPEG, PNG, WebP, GIF &middot; max 5 MB</p>
</div>
)}
{/* Drop zone / file picker */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => 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 ? (
<div className="flex items-center justify-center gap-2 text-sm text-gray-400">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Uploading...
</div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
onChange={handleFileChange}
className="hidden"
/>
</>
) : (
) : (
<div className="text-sm text-gray-400">
<p>Drop an image here or click to browse</p>
<p className="text-xs text-gray-500 mt-1">JPEG, PNG, WebP, GIF &middot; max 5 MB</p>
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
onChange={handleFileChange}
className="hidden"
/>

{/* URL fallback */}
{showUrlInput ? (
<input
type="url"
value={value}
onChange={e => 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
/>
) : (
<button
type="button"
onClick={() => setShowUrlInput(true)}
className="mt-1.5 text-xs text-gray-500 hover:text-gray-400 transition-colors cursor-pointer"
>
or paste image URL
</button>
)}

{error && (
Expand Down
68 changes: 62 additions & 6 deletions components/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,20 +17,78 @@ const NAV_ITEMS = [

export default function Navbar() {
const pathname = usePathname();
const [mobileOpen, setMobileOpen] = useState(false);
const navRef = useRef<HTMLElement>(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 (
<nav className="border-b border-white/10 bg-black/50 backdrop-blur-sm sticky top-0 z-50">
<nav ref={navRef} className="border-b border-white/10 bg-black/50 backdrop-blur-sm sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<Link href="/" className="text-xl font-bold text-white hover:text-blue-400 transition-colors">
melb.tech
</Link>

<div className="flex items-center gap-1">
{/* Desktop nav links (hidden on mobile) */}
<div className="hidden md:flex items-center gap-1">
{NAV_ITEMS.map(item => (
<Link
key={item.href}
href={item.href}
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
pathname.startsWith(item.href)
? 'bg-white/10 text-white'
: 'text-gray-400 hover:text-white hover:bg-white/5'
}`}
>
{item.label}
</Link>
))}
</div>

{/* UserMenu — always visible, single instance */}
<div className="md:ml-2 md:border-l md:border-white/10 md:pl-2">
<UserMenu />
</div>

{/* Mobile menu toggle (hidden on desktop) */}
<button
onClick={() => setMobileOpen(!mobileOpen)}
className="md:hidden p-2 rounded-md text-gray-400 hover:text-white hover:bg-white/5 transition-colors cursor-pointer"
aria-label="Toggle menu"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<circle cx="4" cy="10" r="2" />
<circle cx="10" cy="10" r="2" />
<circle cx="16" cy="10" r="2" />
</svg>
</button>
</div>
</div>
</div>

{/* Mobile dropdown */}
{mobileOpen && (
<div className="md:hidden border-t border-white/10 bg-black/80 backdrop-blur-sm">
<div className="px-4 py-3 space-y-1">
{NAV_ITEMS.map(item => (
<Link
key={item.href}
href={item.href}
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
onClick={() => setMobileOpen(false)}
className={`block px-3 py-2 rounded-md text-sm font-medium transition-colors ${
pathname.startsWith(item.href)
? 'bg-white/10 text-white'
: 'text-gray-400 hover:text-white hover:bg-white/5'
Expand All @@ -38,12 +97,9 @@ export default function Navbar() {
{item.label}
</Link>
))}
<div className="ml-2 border-l border-white/10 pl-2">
<UserMenu />
</div>
</div>
</div>
</div>
)}
</nav>
);
}
12 changes: 11 additions & 1 deletion components/UserMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,17 @@ export default function UserMenu() {
}

if (loading) {
return <div className="w-8 h-8" />;
// 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 (
<Link
href="/login"
className="px-3 py-2 rounded-md text-sm font-medium text-gray-400 hover:text-white hover:bg-white/5 transition-colors"
>
Sign In
</Link>
);
}

if (!user) {
Expand Down
Loading