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:
@@ -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>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user