const { useState: mS, useEffect: mE, useMemo: mM, useCallback: mC } = React; // ─── Router ─────────────────────────────────────────────────── function parseRoute(pathname) { const p = pathname.replace(/\/+$/, "") || "/"; if (p === "/login") return { name: "login" }; if (p === "/signup") return { name: "signup" }; const m = p.match(/^\/u\/([^/]+)$/); if (m) return { name: "profile", username: decodeURIComponent(m[1]) }; return { name: "index" }; } function navigate(to) { window.history.pushState({}, "", to); window.dispatchEvent(new PopStateEvent("popstate")); } // ─── Photos shape helper ───────────────────────────────────── function normalizeAlbums(raw) { return raw.map((a) => { // src = 800px webp thumbnail (used by the grid — tiny, fast). // display = 2000px webp (used by the photo viewer and the album cover — crisp on retina, ~333KB avg). // full = untouched original (download / fallback only). const photos = (a.photos || []).map((p) => ({ ...p, src: p.thumbUrl || p.url, display: p.displayUrl || p.url, full: p.url, })); const coverPhoto = a.coverPhotoId ? photos.find((p) => p.id === a.coverPhotoId) : photos[0]; return { ...a, photos, coverUrl: coverPhoto?.display || coverPhoto?.src || null }; }); } // ─── Top-level app ──────────────────────────────────────────── function PassageRoot() { const [route, setRoute] = mS(() => parseRoute(window.location.pathname)); const [me, setMe] = mS(undefined); // undefined = loading, null = logged out, object = logged in mE(() => { const onPop = () => setRoute(parseRoute(window.location.pathname)); window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); }, []); mE(() => { API.me().then(setMe).catch(() => setMe(null)); }, []); if (me === undefined) return ; // Index route: send logged-in users to their gallery, else to login. if (route.name === "index") { if (me) { navigate("/u/" + me.username); return ; } return { setMe(u); navigate("/u/" + u.username); }} onSwitch={(m) => navigate("/" + m)} />; } if (route.name === "login") return { setMe(u); navigate("/u/" + u.username); }} onSwitch={(m) => navigate("/" + m)} />; if (route.name === "signup") return { setMe(u); navigate("/u/" + u.username); }} onSwitch={(m) => navigate("/" + m)} />; if (route.name === "profile") { return ( ); } return ; } function Splash() { return ( ◐ ); } // ─── Profile page ───────────────────────────────────────────── function ProfilePage({ username, me, setMe }) { const [profileUser, setProfileUser] = mS(null); const [albums, setAlbums] = mS([]); const [loading, setLoading] = mS(true); const [notFound, setNotFound] = mS(false); const [view, setView] = mS({ kind: "list", tab: "albums" }); const [viewer, setViewer] = mS(null); const [sheet, setSheet] = mS(null); // null | {kind:'new'|'edit', album?} const [profileSheet, setProfileSheet] = mS(false); const [busy, setBusy] = mS(false); const [uploading, setUploading] = mS(null); // null | { done, total } const canEdit = !!(me && profileUser && me.username === profileUser.username); async function refresh() { setLoading(true); try { const data = await API.profile(username); setProfileUser(data.user); setAlbums(normalizeAlbums(data.albums)); setNotFound(false); } catch (e) { setNotFound(true); } finally { setLoading(false); } } mE(() => { refresh(); /* eslint-disable-next-line */ }, [username]); if (loading && !profileUser) return ; if (notFound) return ( ◐} /> ◐ No user @{username} This gallery doesn't exist. navigate("/")}>Back ); const openAlbum = (id) => { setView({ kind: "album", id }); window.scrollTo(0, 0); }; const back = () => { setView({ kind: "list", tab: "albums" }); window.scrollTo(0, 0); }; const setTab = (t) => setView({ kind: "list", tab: t }); const openPhoto = (albumId, idx) => setViewer({ albumId, idx }); const closePhoto = () => setViewer(null); const viewerAlbum = viewer ? albums.find((a) => a.id === viewer.albumId) : null; const currentAlbum = view.kind === "album" ? albums.find((a) => a.id === view.id) : null; async function createAlbum(data) { setBusy(true); try { const a = await API.createAlbum(data); const fresh = normalizeAlbums([a]); setAlbums((prev) => [...fresh, ...prev]); setSheet(null); openAlbum(a.id); } finally { setBusy(false); } } async function editAlbum(data) { if (!currentAlbum) return; setBusy(true); try { const a = await API.updateAlbum(currentAlbum.id, data); setAlbums((prev) => prev.map((x) => x.id === a.id ? normalizeAlbums([{ ...a, photos: currentAlbum.photos.map((p) => ({ ...p, url: p.src })) }])[0] : x)); setSheet(null); } finally { setBusy(false); } } async function addPhotos(albumId, files) { setUploading({ done: 0, total: files.length }); const { errors } = await API.uploadPhotos(albumId, files, { onPhoto: (photo) => { const mapped = { ...photo, src: photo.thumbUrl || photo.url, display: photo.displayUrl || photo.url, full: photo.url, }; setAlbums((prev) => prev.map((a) => { if (a.id !== albumId) return a; const photos = [...a.photos, mapped]; const coverUrl = a.coverUrl || photos[0]?.display || photos[0]?.src || null; const coverPhotoId = a.coverPhotoId || photos[0]?.id || null; return { ...a, photos, coverUrl, coverPhotoId }; })); }, onProgress: ({ done, total }) => setUploading({ done, total }), }); setUploading(null); if (errors.length) { const sample = errors.slice(0, 3).map((e) => `${e.file.name}: ${e.error}`).join("\n"); const more = errors.length > 3 ? `\n…and ${errors.length - 3} more` : ""; alert(`${errors.length} of ${files.length} failed. You can re-select just those and try again.\n\n${sample}${more}`); } } async function deletePhoto(albumId, idx) { const album = albums.find((a) => a.id === albumId); if (!album) return; const photo = album.photos[idx]; if (!photo || !confirm("Delete this photo?")) return; await API.deletePhoto(photo.id); setAlbums((prev) => prev.map((a) => { if (a.id !== albumId) return a; const photos = a.photos.filter((p) => p.id !== photo.id); const wasCover = a.coverPhotoId === photo.id; const coverPhotoId = wasCover ? (photos[0]?.id || null) : a.coverPhotoId; const coverPhoto = coverPhotoId ? photos.find((p) => p.id === coverPhotoId) : null; const coverUrl = coverPhoto ? (coverPhoto.display || coverPhoto.src) : null; return { ...a, photos, coverPhotoId, coverUrl }; })); setViewer((v) => { if (!v || v.albumId !== albumId) return v; const nextLen = album.photos.length - 1; if (nextLen <= 0) return null; return { ...v, idx: Math.min(v.idx, nextLen - 1) }; }); } async function setCover(albumId, idx) { const album = albums.find((a) => a.id === albumId); if (!album) return; const photo = album.photos[idx]; if (!photo || album.coverPhotoId === photo.id) return; try { await API.setCover(albumId, photo.id); } catch (e) { alert("Couldn't set cover: " + (e.message || e)); return; } setAlbums((prev) => prev.map((a) => a.id === albumId ? { ...a, coverPhotoId: photo.id, coverUrl: photo.display || photo.src } : a )); } async function deleteAlbum(id) { if (!confirm("Delete this album and all its photos?")) return; await API.deleteAlbum(id); setAlbums((prev) => prev.filter((a) => a.id !== id)); back(); } async function saveProfile(patch) { setBusy(true); try { const u = await API.updateMe(patch); setMe(u); setProfileUser(u); setProfileSheet(false); } finally { setBusy(false); } } async function logout() { await API.logout(); setMe(null); navigate("/login"); } const title = view.kind === "album" ? (currentAlbum?.title || "Album") : `@${profileUser.username}`; const right = ( {canEdit && view.kind === "list" && ( setSheet({ kind: "new" })}> )} {!me && ( navigate("/login")}>Log in )} {me && ( )} ); return ( {view.kind === "list" && ( <> setProfileSheet(true)} onLogout={logout} /> {view.tab === "albums" && setSheet({ kind: "new" })} canEdit={canEdit} />} {view.tab === "grid" && setSheet({ kind: "new" })} canEdit={canEdit} />} > )} {view.kind === "album" && currentAlbum && ( addPhotos(currentAlbum.id, files)} onDeleteAlbum={() => deleteAlbum(currentAlbum.id)} onEditAlbum={() => setSheet({ kind: "edit", album: currentAlbum })} uploading={uploading} /> )} {viewer && viewerAlbum && ( setViewer({ ...viewer, idx: i })} onDelete={(i) => deletePhoto(viewer.albumId, i)} onSetCover={(i) => setCover(viewer.albumId, i)} /> )} {canEdit && view.kind === "list" && albums.length > 0 && ( setSheet({ kind: "new" })} label="New album" /> )} {sheet && ( setSheet(null)} onSave={sheet.kind === "edit" ? editAlbum : createAlbum} busy={busy} /> )} {profileSheet && ( setProfileSheet(false)} onSave={saveProfile} busy={busy} /> )} ); } ReactDOM.createRoot(document.getElementById("root")).render();