// Passage — UI components.
const { useState: uS, useRef: uR, useEffect: uE, useMemo: uM } = React;
function MobileHeader({ view, onBack, title, right, leftOverride }) {
if (view === "viewer") return null;
return (
{leftOverride !== undefined ? leftOverride : (
view === "album" ? (
) : ◐
)}
{title}
{right}
);
}
function ProfileStrip({ profileUser, albums, canEdit, onEdit, onLogout }) {
const totalPhotos = albums.reduce((n, a) => n + a.photos.length, 0);
const avatar = albums.find((a) => a.coverUrl)?.coverUrl;
return (
{!avatar && ◐}
{albums.length}albums
{totalPhotos}photos
{profileUser.display || profileUser.username}
@{profileUser.username}
{profileUser.bio && {profileUser.bio}
}
{canEdit && (
)}
);
}
function TabStrip({ tab, setTab, tabs }) {
return (
);
}
function AlbumsList({ albums, onOpen, onNew, canEdit }) {
if (albums.length === 0) {
return (
◐
No albums yet
{canEdit
? "Create your first album to start collecting photos from your trips."
: "This gallery is still empty."}
{canEdit &&
}
);
}
return (
{albums.map((a, i) => (
onOpen(a.id)}>
{!a.coverUrl && ◐}
{String(i + 1).padStart(2, "0")}
{a.title}
{(() => {
const when = [a.month, a.year].filter(Boolean).join(" ");
const n = a.photos.length;
const parts = [];
if (a.country) parts.push({a.country});
if (when) parts.push({when});
parts.push({n} {n === 1 ? "photo" : "photos"});
return parts.flatMap((p, i) => i === 0 ? [p] : [" · ", p]);
})()}
))}
);
}
function AllPhotosGrid({ albums, onOpenPhoto, onNew, canEdit }) {
const all = [];
albums.forEach((a) => a.photos.forEach((p, idx) => all.push({ albumId: a.id, photoIdx: idx, src: p.src, full: p.full, title: a.title })));
if (all.length === 0) {
return (
◐
No photos yet
{canEdit ? "Photos you add to any album show up here." : "Nothing here yet."}
{canEdit &&
}
);
}
return (
{all.map((p, i) => (
))}
);
}
function AlbumView({ album, canEdit, onOpenPhoto, onAddPhotos, onDeleteAlbum, onEditAlbum, uploading }) {
const inputRef = uR(null);
const pick = () => inputRef.current && inputRef.current.click();
const onFiles = (e) => {
const files = Array.from(e.target.files || []);
if (files.length) onAddPhotos(files);
e.target.value = "";
};
return (
{!album.coverUrl &&
◐
}
{(() => {
const when = [album.month, album.year].filter(Boolean).join(" ");
if (!album.country && !when) return "New album";
const parts = [];
if (album.country) parts.push({album.country});
if (when) parts.push({when});
return parts.flatMap((p, i) => i === 0 ? [p] : [" · ", p]);
})()}
{album.title}
{album.subtitle &&
{album.subtitle}
}
{album.caption &&
{album.caption}
}
{album.photos.length}photos
{album.year || "—"}year
{album.country || "—"}area
{canEdit && (
)}
{album.photos.length === 0 ? (
{canEdit ? 'Tap "Add photos" to pick from your Photos library.' : "No photos in this album yet."}
) : (
{album.photos.map((p, i) => (
))}
)}
);
}
function PhotoViewer({ album, idx, canEdit, onClose, onChange, onDelete, onSetCover }) {
const stageRef = uR(null);
const startX = uR(0);
const startY = uR(0);
const lastX = uR(0);
const lastT = uR(0);
const velocity = uR(0);
const axisLocked = uR(null); // 'x' | 'y' | null
const [stageW, setStageW] = uS(0);
const [offset, setOffset] = uS(0);
const [dragging, setDragging] = uS(false);
const [settling, setSettling] = uS(null); // null | +1 (next) | -1 (prev)
uE(() => {
if (!stageRef.current) return;
const el = stageRef.current;
const update = () => setStageW(el.clientWidth);
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
uE(() => {
const onKey = (e) => {
if (e.key === "Escape") onClose();
else if (e.key === "ArrowLeft" && idx > 0) onChange(idx - 1);
else if (e.key === "ArrowRight" && idx < album.photos.length - 1) onChange(idx + 1);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [idx, album, onChange, onClose]);
// Warm adjacent photos so swipes feel instant.
uE(() => {
if (!album) return;
for (const d of [-1, 1]) {
const n = album.photos[idx + d];
if (n) { const im = new Image(); im.src = n.display || n.full || n.src; }
}
}, [album, idx]);
if (idx == null || !album) return null;
const photo = album.photos[idx];
if (!photo) return null;
const prev = album.photos[idx - 1];
const next = album.photos[idx + 1];
const onStart = (e) => {
if (settling) return;
const t = e.touches ? e.touches[0] : e;
startX.current = t.clientX;
startY.current = t.clientY;
lastX.current = t.clientX;
lastT.current = performance.now();
velocity.current = 0;
axisLocked.current = null;
setDragging(true);
};
const onMove = (e) => {
if (!dragging) return;
const t = e.touches ? e.touches[0] : e;
const dx = t.clientX - startX.current;
const dy = t.clientY - startY.current;
// Lock axis once intent is clear (helps vertical scrolling on edge cases).
if (axisLocked.current == null && (Math.abs(dx) > 8 || Math.abs(dy) > 8)) {
axisLocked.current = Math.abs(dx) > Math.abs(dy) ? "x" : "y";
}
if (axisLocked.current === "y") return;
// Track velocity for fling detection.
const now = performance.now();
const dt = Math.max(1, now - lastT.current);
velocity.current = (t.clientX - lastX.current) / dt; // px/ms
lastX.current = t.clientX;
lastT.current = now;
// Rubber band at boundaries.
let capped = dx;
if (dx > 0 && !prev) capped = dx * 0.25;
if (dx < 0 && !next) capped = dx * 0.25;
setOffset(capped);
};
const onEnd = () => {
if (!dragging) return;
setDragging(false);
const threshold = Math.max(60, stageW * 0.18);
const fling = Math.abs(velocity.current) > 0.6 && Math.abs(offset) > 24;
const goNext = (offset < -threshold || (fling && velocity.current < 0)) && next;
const goPrev = (offset > threshold || (fling && velocity.current > 0)) && prev;
if (goNext) { setSettling(1); setOffset(0); }
else if (goPrev) { setSettling(-1); setOffset(0); }
else setOffset(0);
};
const onTransitionEnd = (e) => {
if (e.propertyName !== "transform") return;
if (settling != null) {
const dir = settling;
setSettling(null);
onChange(idx + dir);
}
};
// Strip transform: base is -stageW (centers the current slot). Settling
// animates to neighbour edge; drag adds px offset. Using percent for
// layout so mount-time stageW=0 still positions the strip correctly.
let tx;
if (settling === 1) tx = "-66.6667%";
else if (settling === -1) tx = "0%";
else tx = `calc(-33.3333% + ${offset}px)`;
return (
{String(idx + 1).padStart(2, "0")} / {String(album.photos.length).padStart(2, "0")}
{album.coverPhotoId === photo.id && · cover}
{canEdit ? (
) :
}
{prev &&

{ if (prev.full && e.target.src !== prev.full) e.target.src = prev.full; }} />}

{ if (photo.full && e.target.src !== photo.full) e.target.src = photo.full; }} />
{next &&

{ if (next.full && e.target.src !== next.full) e.target.src = next.full; }} />}
{album.title}
{photo.caption &&
{photo.caption}
}
{album.photos.map((_, i) => (
))}
);
}
function AlbumSheet({ open, initial, onClose, onSave, busy }) {
const [title, setTitle] = uS("");
const [subtitle, setSubtitle] = uS("");
const [country, setCountry] = uS("");
const [month, setMonth] = uS("");
const [year, setYear] = uS("");
const [caption, setCaption] = uS("");
const [err, setErr] = uS("");
uE(() => {
if (!open) return;
setTitle(initial?.title || "");
setSubtitle(initial?.subtitle || "");
setCountry(initial?.country || "");
setMonth(initial?.month || "");
setYear(initial?.year ? String(initial.year) : String(new Date().getFullYear()));
setCaption(initial?.caption || "");
setErr("");
}, [open, initial]);
if (!open) return null;
const submit = async (e) => {
e && e.preventDefault && e.preventDefault();
if (!title.trim()) return;
setErr("");
try {
await onSave({
title: title.trim(),
subtitle: subtitle.trim(),
country: country.trim(),
month: month.trim(),
year: year ? Number(year) : null,
caption: caption.trim(),
});
} catch (e) { setErr(e.message || "Error"); }
};
return (
);
}
function ProfileSheet({ open, initial, onClose, onSave, busy }) {
const [display, setDisplay] = uS("");
const [bio, setBio] = uS("");
const [err, setErr] = uS("");
uE(() => {
if (!open) return;
setDisplay(initial?.display || "");
setBio(initial?.bio || "");
setErr("");
}, [open, initial]);
if (!open) return null;
const submit = async (e) => {
e && e.preventDefault && e.preventDefault();
setErr("");
try { await onSave({ display: display.trim(), bio: bio.trim() }); }
catch (e) { setErr(e.message || "Error"); }
};
return (
);
}
function Fab({ onClick, label }) {
return (
);
}
function AuthPage({ mode, onDone, onSwitch }) {
const [username, setUsername] = uS("");
const [password, setPassword] = uS("");
const [busy, setBusy] = uS(false);
const [err, setErr] = uS("");
const submit = async (e) => {
e.preventDefault();
setBusy(true); setErr("");
try {
const user = mode === "signup"
? await API.signup(username.trim().toLowerCase(), password)
: await API.login(username.trim().toLowerCase(), password);
onDone(user);
} catch (e) { setErr(e.message || "Error"); setBusy(false); }
};
return (
);
}
Object.assign(window, {
MobileHeader, ProfileStrip, TabStrip, AlbumsList, AllPhotosGrid,
AlbumView, PhotoViewer, AlbumSheet, ProfileSheet, Fab, AuthPage,
});