Initial release: self-hosted client photo gallery
ci / docker (push) Successful in 13s

Rust (axum + sqlx) API and worker sharing a Postgres-backed job queue
(SKIP LOCKED, heartbeat, reaper, typed statuses), S3 storage with derived
keys and a fully private bucket, OIDC photographer login with per-request
allowlist checks, client share links with argon2 passwords and lockout,
cookie-based image authorization with sliding expiry, hand-rolled
spec-compliant streaming ZIP downloads with exact Content-Length,
React + Vite gallery frontend, single Docker image, Helm chart for
external S3 + Postgres, and Gitea CI.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-17 13:12:42 +02:00
co-authored by Claude
commit 16d2a56a78
55 changed files with 11962 additions and 0 deletions
+169
View File
@@ -0,0 +1,169 @@
import { useCallback, useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { api, postDownload } from '../api'
import Gallery from '../components/Gallery'
import Lightbox from '../components/Lightbox'
import SelectionBar from '../components/SelectionBar'
import Stars from '../components/Stars'
import TagEditor from '../components/TagEditor'
import useSelection from '../useSelection'
export default function SharePage() {
const { token } = useParams()
const [view, setView] = useState(null)
const [error, setError] = useState(null)
const [password, setPassword] = useState('')
const [unlockError, setUnlockError] = useState(null)
const [lightbox, setLightbox] = useState(-1)
const { selected, toggle, selectAll, clear, selectedBytes, totalBytes } = useSelection(
view?.photos ?? [],
)
const load = useCallback(
() => api(`/api/share/${token}`).then(setView).catch((e) => setError(e.message)),
[token],
)
useEffect(() => {
load()
}, [load])
const unlock = async (e) => {
e.preventDefault()
try {
await api(`/api/share/${token}/unlock`, { method: 'POST', body: { password } })
setUnlockError(null)
load()
} catch (err) {
setUnlockError(err.status === 401 ? 'Wrong password' : err.message)
}
}
const patchPhoto = (photoId, patch) => {
setView((v) => ({
...v,
photos: v.photos.map((p) => (p.id === photoId ? { ...p, ...patch } : p)),
}))
}
const setRating = async (photo, rating) => {
patchPhoto(photo.id, { my_rating: rating || null })
try {
await api(`/api/share/${token}/photos/${photo.id}/rating`, {
method: 'PUT',
body: { rating },
})
} catch {
load()
}
}
const setTags = async (photo, tags) => {
patchPhoto(photo.id, { my_tags: tags })
try {
await api(`/api/share/${token}/photos/${photo.id}/tags`, {
method: 'PUT',
body: { tags },
})
} catch {
load()
}
}
if (error) return <div className="center-page">{error}</div>
if (!view) return <div className="center-page">Loading</div>
if (view.locked) {
return (
<div className="center-page">
<form className="login-card" onSubmit={unlock}>
<h1>{view.album_name}</h1>
<p className="muted">This gallery is password protected.</p>
<input
type="password"
autoFocus
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button className="btn btn-primary" type="submit">
Open gallery
</button>
{unlockError && <p className="error">{unlockError}</p>}
</form>
</div>
)
}
return (
<>
<header className="share-head">
<h1>{view.album_name}</h1>
{view.album_description && <p className="muted">{view.album_description}</p>}
<p className="muted">
{view.photos.length} photo{view.photos.length === 1 ? '' : 's'} · click a photo to view,
rate and tag
</p>
</header>
<main className="page">
<Gallery
photos={view.photos}
onOpen={setLightbox}
selected={view.allow_download ? selected : undefined}
onToggleSelect={view.allow_download ? toggle : undefined}
overlay={(p) =>
p.my_rating || p.my_tags.length > 0 ? (
<div className="g-overlay">
{p.my_rating && <span> {p.my_rating}</span>}
{p.my_tags.length > 0 && <span># {p.my_tags.length}</span>}
</div>
) : null
}
/>
{view.photos.length === 0 && (
<p className="center-page muted">Nothing here yet check back soon.</p>
)}
</main>
{view.allow_download && (
<SelectionBar
count={selected.size}
total={view.photos.length}
selectedBytes={selectedBytes}
totalBytes={totalBytes}
onSelectAll={selectAll}
onClear={clear}
onDownload={() => postDownload(`/api/share/${token}/zip`, [...selected].join(','))}
onDownloadAll={() => postDownload(`/api/share/${token}/zip`)}
/>
)}
{lightbox >= 0 && (
<Lightbox
photos={view.photos}
index={lightbox}
onClose={() => setLightbox(-1)}
onNav={setLightbox}
footer={(p) => (
<div className="client-footer">
<Stars value={p.my_rating || 0} onChange={(r) => setRating(p, r)} />
<TagEditor tags={p.my_tags} onChange={(tags) => setTags(p, tags)} />
{view.allow_download && (
<label className="select-toggle">
<input
type="checkbox"
checked={selected.has(p.id)}
onChange={() => toggle(p.id)}
/>
select
</label>
)}
{view.allow_download && (
<a className="btn" href={`/api/photos/${p.id}/original`}>
Download original
</a>
)}
</div>
)}
/>
)}
</>
)
}