// 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 countries = new Set(albums.map((a) => a.country).filter(Boolean)).size;
const avatar = albums.find((a) => a.coverUrl)?.coverUrl;
return (
{!avatar && ◐}
{albums.length}albums
{totalPhotos}photos
{countries}countries
{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}
{[a.country, [a.month, a.year].filter(Boolean).join(" "), a.photos.length + " photo" + (a.photos.length === 1 ? "" : "s")]
.filter(Boolean).join(" · ")}
))}
);
}
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, 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 &&
◐
}
{[album.country, [album.month, album.year].filter(Boolean).join(" ")].filter(Boolean).join(" · ") || "New album"}
{album.title}
{album.subtitle &&
{album.subtitle}
}
{album.caption &&
{album.caption}
}
{album.photos.length}photos
{album.year || "—"}year
{album.country || "—"}country
{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 }) {
const startX = uR(0);
const [offset, setOffset] = uS(0);
const [dragging, setDragging] = uS(false);
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]);
if (idx == null || !album) return null;
const photo = album.photos[idx];
if (!photo) return null;
const onStart = (e) => {
const t = e.touches ? e.touches[0] : e;
startX.current = t.clientX;
setDragging(true);
};
const onMove = (e) => {
if (!dragging) return;
const t = e.touches ? e.touches[0] : e;
setOffset(t.clientX - startX.current);
};
const onEnd = () => {
setDragging(false);
if (offset > 60 && idx > 0) onChange(idx - 1);
else if (offset < -60 && idx < album.photos.length - 1) onChange(idx + 1);
setOffset(0);
};
return (
{String(idx + 1).padStart(2, "0")} / {String(album.photos.length).padStart(2, "0")}
{canEdit ? (
) :
}
{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,
});