// project-page.jsx — single project: info panel + reorderable/resizable gallery. // Exposes window.ProjectPage const { useState: usePPState, useRef: usePPRef, useEffect: usePPEffect } = React; // Parse [text](url) markdown links inline function RichText({ text }) { if (!text) return null; const parts = []; const re = /\[([^\]]+)\]\(([^)]+)\)/g; let last = 0, m; while ((m = re.exec(text)) !== null) { if (m.index > last) parts.push(text.slice(last, m.index)); parts.push( {m[1]} ); last = m.index + m[0].length; } if (last < text.length) parts.push(text.slice(last)); return {parts}; } // Compact thumbnail grid for quick reordering (shown in info panel when unlocked) function ReorderGrid({ order, gallery, onReorder }) { const [dragFrom, setDragFrom] = usePPState(null); const [dragOver, setDragOver] = usePPState(null); const onDrop = (e, idx) => { e.preventDefault(); if (dragFrom === null || dragFrom === idx) { setDragFrom(null); setDragOver(null); return; } const next = [...order]; const [moved] = next.splice(dragFrom, 1); next.splice(idx, 0, moved); onReorder(next); setDragFrom(null); setDragOver(null); }; return (
DRAG TO REORDER
{order.map((origIdx, dispIdx) => { const item = gallery[origIdx]; const isImg = item && item.type === 'image'; return (
setDragFrom(dispIdx)} onDragOver={(e) => { e.preventDefault(); setDragOver(dispIdx); }} onDragLeave={() => setDragOver(null)} onDrop={(e) => onDrop(e, dispIdx)} onDragEnd={() => { setDragFrom(null); setDragOver(null); }} style={{ width: 54, height: 40, cursor: 'grab', position: 'relative', overflow: 'hidden', borderRadius: 3, flexShrink: 0, opacity: dragFrom === dispIdx ? 0.25 : 1, outline: dragOver === dispIdx && dragFrom !== dispIdx ? '2px solid rgba(255,255,255,0.8)' : '1px solid rgba(255,255,255,0.12)', }}> {isImg ? :
{item && item.type === 'vimeo' ? 'vimeo' : 'vid'}
}
{dispIdx + 1}
); })}
); } // Resize drag handle (bottom-right of each gallery item) function ResizeHandle({ onMouseDown }) { return (
{ e.currentTarget.style.background = 'rgba(255,255,255,0.5)'; }} onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.22)'; }} >
); } function ProjectInfo({ p, top, locked, order, onReorder, sizes, onStartResize, galleryRef, mobile = false }) { const linksKey = 'project-links-' + p.id; const [extraLinks, setExtraLinks] = usePPState(() => { try { return JSON.parse(localStorage.getItem(linksKey)) || []; } catch (_) { return []; } }); const [linkLabel, setLinkLabel] = usePPState(''); const [linkUrl, setLinkUrl] = usePPState(''); const saveLinks = (next) => { setExtraLinks(next); try { localStorage.setItem(linksKey, JSON.stringify(next)); } catch (_) {} }; const addLink = () => { if (!linkUrl.trim()) return; saveLinks([...extraLinks, { label: linkLabel.trim() || linkUrl.trim(), url: linkUrl.trim() }]); setLinkLabel(''); setLinkUrl(''); }; const removeLink = (i) => saveLinks(extraLinks.filter((_, j) => j !== i)); const inputStyle = { background: 'rgba(255,255,255,0.07)', border: '1px solid rgba(255,255,255,0.18)', borderRadius: 4, color: '#fff', fontSize: 11, padding: '4px 8px', width: '100%', fontFamily: FONT, outline: 'none', marginBottom: 4, }; return (
{p.title}
{p.year}
{p.medium && (
[{p.medium}]
)} {p.client && (
Client:
)} {p.desc && (
)} {/* Project-data links */} {p.links && p.links.length > 0 && (
{p.links.map((l, i) => (
{l.label || l.url}
))}
)} {/* Author-added extra links */} {extraLinks.length > 0 && (
{extraLinks.map((l, i) => (
{l.label} {!locked && ( removeLink(i)} style={{ cursor: 'pointer', opacity: 0.4, fontSize: 11, userSelect: 'none', flexShrink: 0 }}>× )}
))}
)} {/* Authoring tools (shown when unlocked) */} {!locked && (
ADD LINK
setLinkLabel(e.target.value)} placeholder="Label" style={inputStyle} /> setLinkUrl(e.target.value)} placeholder="https://…" style={inputStyle} onKeyDown={(e) => e.key === 'Enter' && addLink()} />
{p.gallery && p.gallery.length > 1 && (
REORDER IMAGES
)}
)}
); } function ProjectPage({ p, locationStyle, locked, mobile = false }) { const orderKey = 'gallery-order-' + p.id; const sizesKey = 'gallery-widths-' + p.id; const [order, setOrder] = usePPState(() => { try { const saved = JSON.parse(localStorage.getItem(orderKey)); if (Array.isArray(saved) && saved.length === p.gallery.length) return saved; } catch (_) {} return p.gallery.map((_, i) => i); }); const [sizes, setSizes] = usePPState(() => { try { return JSON.parse(localStorage.getItem(sizesKey)) || {}; } catch (_) { return {}; } }); const [dragFrom, setDragFrom] = usePPState(null); const [dragOver, setDragOver] = usePPState(null); const galleryRef = usePPRef(null); // Eased (smooth) vertical wheel scrolling for the gallery usePPEffect(() => { let targetY = window.scrollY, animating = false, raf = 0; const maxY = () => Math.max(0, document.documentElement.scrollHeight - window.innerHeight); const tick = () => { const cur = window.scrollY; const next = cur + (targetY - cur) * 0.13; if (Math.abs(targetY - cur) < 0.5) { window.scrollTo(0, targetY); animating = false; return; } window.scrollTo(0, next); raf = requestAnimationFrame(tick); }; const onWheel = (e) => { if (e.ctrlKey) return; // let pinch-zoom through if (!animating) targetY = window.scrollY; targetY = Math.max(0, Math.min(maxY(), targetY + e.deltaY)); e.preventDefault(); if (!animating) { animating = true; raf = requestAnimationFrame(tick); } }; window.addEventListener('wheel', onWheel, { passive: false }); return () => { window.removeEventListener('wheel', onWheel); cancelAnimationFrame(raf); }; }, [p.id]); const gallery = order.map((i) => p.gallery[i]); if (mobile) { return (
{gallery.map((item, displayIdx) => { const origIdx = order[displayIdx]; return (
); })}
); } const handleReorder = (next) => { setOrder(next); try { localStorage.setItem(orderKey, JSON.stringify(next)); } catch (_) {} }; const onDrop = (e, idx) => { e.preventDefault(); if (dragFrom === null || dragFrom === idx) { setDragFrom(null); setDragOver(null); return; } const next = [...order]; const [moved] = next.splice(dragFrom, 1); next.splice(idx, 0, moved); handleReorder(next); setDragFrom(null); setDragOver(null); }; const startResize = (origIdx, e) => { e.stopPropagation(); e.preventDefault(); const container = galleryRef.current; if (!container) return; const containerW = container.offsetWidth; const startX = e.clientX; const startPct = sizes[origIdx] != null ? sizes[origIdx] : 100; const onMove = (me) => { const dx = me.clientX - startX; const newPct = Math.max(15, Math.min(100, startPct + (dx / containerW) * 100)); const next = { ...sizes, [origIdx]: Math.round(newPct) }; setSizes(next); try { localStorage.setItem(sizesKey, JSON.stringify(next)); } catch (_) {} }; const onUp = () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); }; return (
{gallery.map((item, displayIdx) => { const origIdx = order[displayIdx]; const widthPct = sizes[origIdx] != null ? sizes[origIdx] : 100; return (
setDragFrom(displayIdx) : undefined} onDragOver={!locked ? (e) => { e.preventDefault(); setDragOver(displayIdx); } : undefined} onDragLeave={!locked ? () => setDragOver(null) : undefined} onDrop={!locked ? (e) => onDrop(e, displayIdx) : undefined} onDragEnd={!locked ? () => { setDragFrom(null); setDragOver(null); } : undefined} style={{ marginBottom: 14, width: widthPct + '%', position: 'relative', cursor: !locked ? (dragFrom !== null ? 'grabbing' : 'grab') : 'default', opacity: !locked && dragFrom === displayIdx ? 0.35 : 1, outline: !locked && dragOver === displayIdx && dragFrom !== null && dragFrom !== displayIdx ? '1px solid rgba(255,255,255,0.45)' : '1px solid transparent', transition: 'opacity 0.15s ease, outline-color 0.1s ease', }} > {!locked && startResize(origIdx, e)} />}
); })}
); } window.ProjectPage = ProjectPage;