// 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) => (
))}
)}
{/* 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 && (
{p.gallery && p.gallery.length > 1 && (
)}
)}
);
}
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;