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

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 f0238fc625
55 changed files with 11962 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<title>Photos</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+1761
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "photos-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^5.4.11"
}
}
+81
View File
@@ -0,0 +1,81 @@
import { useEffect, useState } from 'react'
import { Link, Route, Routes } from 'react-router-dom'
import { api } from './api'
import AlbumsPage from './pages/AlbumsPage'
import AlbumPage from './pages/AlbumPage'
import SharePage from './pages/SharePage'
function AdminLayout({ children }) {
const [me, setMe] = useState(undefined) // undefined=loading, null=logged out
useEffect(() => {
api('/api/me')
.then(setMe)
.catch(() => setMe(null))
}, [])
if (me === undefined) return <div className="center-page">Loading</div>
if (me === null) {
const authError = new URLSearchParams(window.location.search).get('auth_error')
return (
<div className="center-page">
<div className="login-card">
<h1>Photos</h1>
<p>Photographer sign-in</p>
<a className="btn btn-primary" href="/api/auth/login">
Sign in
</a>
{authError && <p className="error">{authError}</p>}
</div>
</div>
)
}
return (
<>
<header className="topbar">
<Link to="/" className="brand">
Photos
</Link>
<span className="topbar-right">
<span className="muted">{me.email}</span>
<button
className="btn btn-ghost"
onClick={async () => {
await api('/api/auth/logout', { method: 'POST' })
setMe(null)
}}
>
Sign out
</button>
</span>
</header>
<main className="page">{children}</main>
</>
)
}
export default function App() {
return (
<Routes>
<Route path="/s/:token" element={<SharePage />} />
<Route
path="/"
element={
<AdminLayout>
<AlbumsPage />
</AdminLayout>
}
/>
<Route
path="/albums/:id"
element={
<AdminLayout>
<AlbumPage />
</AdminLayout>
}
/>
<Route path="*" element={<div className="center-page">Not found</div>} />
</Routes>
)
}
+100
View File
@@ -0,0 +1,100 @@
export async function api(path, opts = {}) {
const { body, ...rest } = opts
const res = await fetch(path, {
...rest,
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
let message = res.statusText
try {
message = (await res.json()).error || message
} catch {
/* not json */
}
const err = new Error(message)
err.status = res.status
throw err
}
if (res.status === 204) return null
return res.json()
}
// Image URL with a cache-buster tied to the last processing run, so
// reprocessed photos bypass the long-lived immutable browser cache.
// Auth rides on cookies (session or share-access), never in the URL.
export function imgUrl(photo, size) {
const version = photo.processed_at ? `?v=${encodeURIComponent(photo.processed_at)}` : ''
return `/api/img/${photo.id}/${size}${version}`
}
// Trigger a browser-native download from a POST endpoint (e.g. zip streams)
// via a hidden form — fetch+blob would buffer the whole file in memory.
// Targets a hidden iframe so an error response can't navigate away from the
// app (which would lose selection/rating state); errors surface as an alert.
export function postDownload(url, ids = '') {
let frame = document.getElementById('download-frame')
if (!frame) {
frame = document.createElement('iframe')
frame.id = 'download-frame'
frame.name = 'download-frame'
frame.style.display = 'none'
document.body.appendChild(frame)
}
frame.onload = () => {
// load only fires when the response rendered (i.e. an error body);
// successful attachment downloads never trigger it.
let message = 'download failed'
try {
const text = frame.contentDocument?.body?.textContent
if (!text) return
try {
message = JSON.parse(text).error || message
} catch {
/* not json */
}
} catch {
return
}
alert(`Download failed: ${message}`)
}
const form = document.createElement('form')
form.method = 'POST'
form.action = url
form.target = 'download-frame'
form.style.display = 'none'
const input = document.createElement('input')
input.type = 'hidden'
input.name = 'ids'
input.value = ids
form.appendChild(input)
document.body.appendChild(form)
form.submit()
form.remove()
}
export function uploadFile(url, file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('POST', url)
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total)
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText))
} else {
let message = `upload failed (${xhr.status})`
try {
message = JSON.parse(xhr.responseText).error || message
} catch {
/* not json */
}
reject(new Error(message))
}
}
xhr.onerror = () => reject(new Error('network error during upload'))
xhr.send(file)
})
}
+42
View File
@@ -0,0 +1,42 @@
import { imgUrl } from '../api'
// Justified gallery: rows are built with flexbox, each tile's flex-grow is
// proportional to its aspect ratio so rows fill the container edge to edge.
// When `selected`/`onToggleSelect` are provided, tiles get a select checkmark;
// selection state lives in the parent so it survives lightbox open/close.
export default function Gallery({ photos, onOpen, overlay, selected, onToggleSelect }) {
if (photos.length === 0) return null
const selecting = selected && selected.size > 0
return (
<div className={`gallery${selecting ? ' selecting' : ''}`}>
{photos.map((p, i) => {
const ar = p.width && p.height ? p.width / p.height : 1.5
const isSelected = selected ? selected.has(p.id) : false
return (
<div
key={p.id}
className={`g-item${isSelected ? ' selected' : ''}`}
style={{ '--ar': ar }}
onClick={() => onOpen && onOpen(i)}
>
<img src={imgUrl(p, 'thumb')} loading="lazy" alt={p.filename} />
{onToggleSelect && (
<button
className="g-check"
title={isSelected ? 'Deselect' : 'Select'}
onClick={(e) => {
e.stopPropagation()
onToggleSelect(p.id)
}}
>
</button>
)}
{overlay && overlay(p)}
</div>
)
})}
<div className="g-spacer" />
</div>
)
}
+72
View File
@@ -0,0 +1,72 @@
import { useEffect } from 'react'
import { imgUrl } from '../api'
export default function Lightbox({ photos, index, onClose, onNav, footer }) {
const photo = photos[index]
useEffect(() => {
const onKey = (e) => {
if (e.key === 'Escape') onClose()
if (e.key === 'ArrowRight' && index < photos.length - 1) onNav(index + 1)
if (e.key === 'ArrowLeft' && index > 0) onNav(index - 1)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [index, photos.length, onClose, onNav])
useEffect(() => {
document.body.style.overflow = 'hidden'
return () => {
document.body.style.overflow = ''
}
}, [])
if (!photo) return null
return (
<div className="lightbox" onClick={onClose}>
<div className="lb-top" onClick={(e) => e.stopPropagation()}>
<span className="lb-name">{photo.filename}</span>
<span className="lb-count">
{index + 1} / {photos.length}
</span>
<button className="lb-btn" onClick={onClose} title="Close (Esc)">
</button>
</div>
<button
className="lb-nav lb-prev"
disabled={index === 0}
onClick={(e) => {
e.stopPropagation()
onNav(index - 1)
}}
>
</button>
<div className="lb-stage">
<img
className="lb-img"
src={imgUrl(photo, 'preview')}
alt={photo.filename}
onClick={(e) => e.stopPropagation()}
/>
</div>
<button
className="lb-nav lb-next"
disabled={index === photos.length - 1}
onClick={(e) => {
e.stopPropagation()
onNav(index + 1)
}}
>
</button>
{footer && (
<div className="lb-footer" onClick={(e) => e.stopPropagation()}>
{footer(photo)}
</div>
)}
</div>
)
}
+52
View File
@@ -0,0 +1,52 @@
export function fmtBytes(bytes) {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.min(Math.floor(Math.log2(bytes) / 10), units.length - 1)
const value = bytes / 2 ** (10 * i)
return `${value >= 100 || i === 0 ? Math.round(value) : value.toFixed(1)} ${units[i]}`
}
export default function SelectionBar({
count,
total,
selectedBytes,
totalBytes,
onSelectAll,
onClear,
onDownload,
onDownloadAll,
}) {
if (total === 0) return null
if (count === 0) {
return (
<div className="select-bar">
<span className="select-count">
{total} photo{total === 1 ? '' : 's'} · {fmtBytes(totalBytes)}
</span>
<button className="btn btn-primary" onClick={onDownloadAll}>
Download all as ZIP
</button>
</div>
)
}
return (
<div className="select-bar">
<span className="select-count">
{count} of {total} selected
</span>
{count < total && (
<button className="btn" onClick={onSelectAll}>
Select all
</button>
)}
<button className="btn" onClick={onClear}>
Clear
</button>
<button className="btn btn-primary" onClick={onDownload}>
Download {count} as ZIP ({fmtBytes(selectedBytes)})
</button>
</div>
)
}
+30
View File
@@ -0,0 +1,30 @@
import { useState } from 'react'
export default function Stars({ value = 0, onChange, small }) {
const [hover, setHover] = useState(0)
const shown = hover || value || 0
return (
<span
className={`stars${small ? ' stars-small' : ''}${onChange ? '' : ' stars-readonly'}`}
onMouseLeave={() => setHover(0)}
>
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
className={n <= shown ? 'star filled' : 'star'}
disabled={!onChange}
onMouseEnter={() => onChange && setHover(n)}
onClick={(e) => {
e.stopPropagation()
// Clicking the current rating clears it.
onChange(n === value ? 0 : n)
}}
title={onChange ? `${n} star${n > 1 ? 's' : ''}` : undefined}
>
</button>
))}
</span>
)
}
+41
View File
@@ -0,0 +1,41 @@
import { useState } from 'react'
export default function TagEditor({ tags, onChange }) {
const [input, setInput] = useState('')
const add = () => {
const tag = input.trim().toLowerCase()
setInput('')
if (tag && !tags.includes(tag)) onChange([...tags, tag])
}
return (
<div className="tag-editor">
{tags.map((tag) => (
<span key={tag} className="chip">
{tag}
<button
type="button"
onClick={() => onChange(tags.filter((t) => t !== tag))}
title="Remove tag"
>
×
</button>
</span>
))}
<input
value={input}
placeholder="add tag…"
maxLength={40}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
add()
}
}}
onBlur={add}
/>
</div>
)
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './styles.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)
+459
View File
@@ -0,0 +1,459 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { api, postDownload, uploadFile } from '../api'
import Gallery from '../components/Gallery'
import Lightbox from '../components/Lightbox'
import SelectionBar from '../components/SelectionBar'
import Stars from '../components/Stars'
import useSelection from '../useSelection'
const UPLOAD_CONCURRENCY = 3
function UploadZone({ albumId, onUploaded }) {
const [queue, setQueue] = useState([])
const [dragging, setDragging] = useState(false)
const inputRef = useRef(null)
const running = useRef(0)
const pending = useRef([])
const lastRefresh = useRef(0)
// Refresh the album at most every 5s during a bulk upload (the processing
// poll keeps it fresh anyway), plus once when the queue drains.
const refresh = useCallback(() => {
const drained = running.current === 0 && pending.current.length === 0
if (drained || Date.now() - lastRefresh.current > 5000) {
lastRefresh.current = Date.now()
onUploaded()
}
}, [onUploaded])
const pump = useCallback(() => {
while (running.current < UPLOAD_CONCURRENCY && pending.current.length > 0) {
const item = pending.current.shift()
running.current += 1
setQueue((q) =>
q.map((x) => (x.key === item.key ? { ...x, status: 'uploading' } : x)),
)
const url = `/api/albums/${albumId}/photos?filename=${encodeURIComponent(item.file.name)}`
uploadFile(url, item.file, (p) =>
setQueue((q) => q.map((x) => (x.key === item.key ? { ...x, progress: p } : x))),
)
.then(() =>
setQueue((q) =>
q.map((x) => (x.key === item.key ? { ...x, status: 'done', progress: 1 } : x)),
),
)
.catch((e) =>
setQueue((q) =>
q.map((x) => (x.key === item.key ? { ...x, status: 'error', error: e.message } : x)),
),
)
.finally(() => {
running.current -= 1
refresh()
pump()
})
}
}, [albumId, refresh])
const addFiles = (files) => {
const items = [...files].map((file, i) => ({
key: `${Date.now()}-${i}-${file.name}`,
file,
status: 'queued',
progress: 0,
}))
if (items.length === 0) return
setQueue((q) => [...q.filter((x) => x.status !== 'done'), ...items])
pending.current.push(...items)
pump()
}
return (
<div
className={`upload-zone${dragging ? ' dragging' : ''}`}
onDragOver={(e) => {
e.preventDefault()
setDragging(true)
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault()
setDragging(false)
addFiles(e.dataTransfer.files)
}}
onClick={() => inputRef.current?.click()}
>
<input
ref={inputRef}
type="file"
multiple
hidden
onChange={(e) => {
addFiles(e.target.files)
e.target.value = ''
}}
/>
<p>Drop RAWs or JPGs here, or click to select</p>
{queue.length > 0 && (
<ul className="upload-list" onClick={(e) => e.stopPropagation()}>
{queue.map((item) => (
<li key={item.key} className={`upload-item ${item.status}`}>
<span className="upload-name">{item.file.name}</span>
{item.status === 'error' ? (
<span className="error">{item.error}</span>
) : (
<progress value={item.progress} max="1" />
)}
</li>
))}
</ul>
)}
</div>
)
}
function SharesPanel({ albumId }) {
const [shares, setShares] = useState([])
const [form, setForm] = useState({ label: '', password: '', allow_download: true, expires_at: '' })
const [error, setError] = useState(null)
const [copied, setCopied] = useState(null)
const load = useCallback(
() => api(`/api/albums/${albumId}/shares`).then(setShares).catch((e) => setError(e.message)),
[albumId],
)
useEffect(() => {
load()
}, [load])
const create = async (e) => {
e.preventDefault()
try {
await api(`/api/albums/${albumId}/shares`, {
method: 'POST',
body: {
label: form.label,
password: form.password || null,
allow_download: form.allow_download,
// End of the chosen day in the photographer's local timezone —
// date-only strings would parse as UTC midnight and expire a day early.
expires_at: form.expires_at
? new Date(`${form.expires_at}T23:59:59`).toISOString()
: null,
},
})
setForm({ label: '', password: '', allow_download: true, expires_at: '' })
setError(null)
load()
} catch (e) {
setError(e.message)
}
}
const copy = async (share) => {
await navigator.clipboard.writeText(share.url)
setCopied(share.id)
setTimeout(() => setCopied(null), 1500)
}
return (
<section className="panel">
<h2>Client links</h2>
{shares.length === 0 && <p className="muted">No links yet.</p>}
{shares.map((s) => (
<div key={s.id} className="share-row">
<div className="share-info">
<strong>
{s.label || 'unnamed link'}
{s.locked && <span className="error"> locked (too many wrong passwords)</span>}
</strong>
<span className="muted">
{s.has_password ? '🔒 password' : 'no password'}
{' · '}
{s.allow_download ? 'downloads on' : 'downloads off'}
{s.expires_at
? ` · expires ${new Date(s.expires_at).toLocaleDateString()}`
: ' · never expires'}
{' · '}
{s.rating_count} ratings, {s.tag_count} tags
</span>
</div>
<div className="row">
{s.locked && (
<button
className="btn"
onClick={async () => {
await api(`/api/shares/${s.id}/reset-lock`, { method: 'POST' })
load()
}}
>
Unlock
</button>
)}
<button className="btn" onClick={() => copy(s)}>
{copied === s.id ? 'Copied!' : 'Copy link'}
</button>
<button
className="btn btn-danger"
onClick={async () => {
if (!confirm(`Delete link "${s.label || s.token}"? Client ratings and tags from this link are removed too.`)) return
await api(`/api/shares/${s.id}`, { method: 'DELETE' })
load()
}}
>
Delete
</button>
</div>
</div>
))}
<form className="share-form" onSubmit={create}>
<input
placeholder="Label (e.g. client name)"
value={form.label}
onChange={(e) => setForm({ ...form, label: e.target.value })}
/>
<input
placeholder="Password (optional)"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
<label className="row field-label">
<span className="muted">Expires</span>
<input
type="date"
value={form.expires_at}
onChange={(e) => setForm({ ...form, expires_at: e.target.value })}
/>
{form.expires_at ? (
<button
type="button"
className="btn btn-ghost"
title="Remove expiry — link never expires"
onClick={() => setForm({ ...form, expires_at: '' })}
>
</button>
) : (
<span className="muted">never</span>
)}
</label>
<label className="row">
<input
type="checkbox"
checked={form.allow_download}
onChange={(e) => setForm({ ...form, allow_download: e.target.checked })}
/>
allow downloads
</label>
<button className="btn btn-primary" type="submit">
Create link
</button>
</form>
{error && <p className="error">{error}</p>}
</section>
)
}
export default function AlbumPage() {
const { id } = useParams()
const navigate = useNavigate()
const [detail, setDetail] = useState(null)
const [error, setError] = useState(null)
// Track the open photo by id, not index — the polling refetch can reorder
// the array underneath an open lightbox.
const [lightboxId, setLightboxId] = useState(null)
const load = useCallback(
() => api(`/api/albums/${id}`).then(setDetail).catch((e) => setError(e.message)),
[id],
)
useEffect(() => {
load()
}, [load])
// Poll while any photo is still being processed by the workers.
const hasPending = detail?.photos.some((p) => p.status === 'uploaded' || p.status === 'processing')
useEffect(() => {
if (!hasPending) return
const t = setInterval(load, 4000)
return () => clearInterval(t)
}, [hasPending, load])
const ready = (detail?.photos ?? []).filter((p) => p.status === 'ready')
const { selected, toggle, selectAll, clear, selectedBytes, totalBytes } = useSelection(ready)
const lightboxIndex = ready.findIndex((p) => p.id === lightboxId)
// If the open photo leaves the ready list (deleted elsewhere, reprocess),
// close for good — otherwise the lightbox would pop back open when the
// photo returns to ready.
useEffect(() => {
if (lightboxId && lightboxIndex < 0) setLightboxId(null)
}, [lightboxId, lightboxIndex])
if (error) return <p className="error">{error}</p>
if (!detail) return <p className="muted">Loading</p>
const { album, photos, feedback } = detail
const notReady = photos.filter((p) => p.status !== 'ready')
const avgRating = (photoId) => {
const ratings = feedback[photoId]?.ratings || []
if (ratings.length === 0) return null
return ratings.reduce((sum, r) => sum + r.rating, 0) / ratings.length
}
const rename = async () => {
const name = prompt('Album name', album.name)
if (name && name.trim()) {
await api(`/api/albums/${id}`, { method: 'PATCH', body: { name: name.trim() } })
load()
}
}
const removeAlbum = async () => {
if (!confirm(`Delete album "${album.name}" and all ${photos.length} photos? This cannot be undone.`)) return
await api(`/api/albums/${id}`, { method: 'DELETE' })
navigate('/')
}
const removePhoto = async (photoId) => {
if (!confirm('Delete this photo?')) return
setLightboxId(null)
await api(`/api/photos/${photoId}`, { method: 'DELETE' })
load()
}
return (
<>
<div className="page-head">
<h1>
<Link to="/" className="muted">
Albums /
</Link>{' '}
{album.name}
</h1>
<div className="row">
<button className="btn" onClick={rename}>
Rename
</button>
<button className="btn btn-danger" onClick={removeAlbum}>
Delete album
</button>
</div>
</div>
<UploadZone albumId={id} onUploaded={load} />
{notReady.length > 0 && (
<section className="panel">
<h2>Processing</h2>
<ul className="pending-list">
{notReady.map((p) => (
<li key={p.id}>
<span className="upload-name">{p.filename}</span>
{p.status === 'error' ? (
<span className="row">
<span className="error">{p.error || 'failed'}</span>
<button
className="btn"
onClick={() => api(`/api/photos/${p.id}/reprocess`, { method: 'POST' }).then(load)}
>
Retry
</button>
<button className="btn btn-danger" onClick={() => removePhoto(p.id)}>
Delete
</button>
</span>
) : (
<span className="muted">{p.status}</span>
)}
</li>
))}
</ul>
</section>
)}
<Gallery
photos={ready}
onOpen={(i) => setLightboxId(ready[i].id)}
selected={selected}
onToggleSelect={toggle}
overlay={(p) => {
const avg = avgRating(p.id)
const tagCount = feedback[p.id]?.tags.length || 0
if (avg === null && tagCount === 0) return null
return (
<div className="g-overlay">
{avg !== null && <span> {avg.toFixed(1)}</span>}
{tagCount > 0 && <span># {tagCount}</span>}
</div>
)
}}
/>
{ready.length === 0 && notReady.length === 0 && (
<p className="muted">No photos yet drop some above.</p>
)}
<SelectionBar
count={selected.size}
total={ready.length}
selectedBytes={selectedBytes}
totalBytes={totalBytes}
onSelectAll={selectAll}
onClear={clear}
onDownload={() => postDownload(`/api/albums/${id}/zip`, [...selected].join(','))}
onDownloadAll={() => postDownload(`/api/albums/${id}/zip`)}
/>
{lightboxIndex >= 0 && (
<Lightbox
photos={ready}
index={lightboxIndex}
onClose={() => setLightboxId(null)}
onNav={(i) => setLightboxId(ready[i].id)}
footer={(p) => {
const fb = feedback[p.id] || { ratings: [], tags: [] }
return (
<div className="admin-footer">
<div className="feedback">
{fb.ratings.length === 0 && fb.tags.length === 0 && (
<span className="muted">No client feedback yet</span>
)}
{fb.ratings.map((r, i) => (
<span key={`r${i}`} className="feedback-item">
{r.share_label || 'client'}: <Stars value={r.rating} small />
</span>
))}
{fb.tags.map((t, i) => (
<span key={`t${i}`} className="chip">
{t.tag} <em className="muted">({t.share_label || 'client'})</em>
</span>
))}
</div>
<div className="row">
<label className="select-toggle">
<input
type="checkbox"
checked={selected.has(p.id)}
onChange={() => toggle(p.id)}
/>
select
</label>
<a className="btn" href={`/api/photos/${p.id}/original`}>
Download original
</a>
<button className="btn btn-danger" onClick={() => removePhoto(p.id)}>
Delete
</button>
</div>
</div>
)
}}
/>
)}
<SharesPanel albumId={id} />
</>
)
}
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { api, imgUrl } from '../api'
export default function AlbumsPage() {
const [albums, setAlbums] = useState(null)
const [name, setName] = useState('')
const [error, setError] = useState(null)
const load = () => api('/api/albums').then(setAlbums).catch((e) => setError(e.message))
useEffect(() => {
load()
}, [])
const create = async (e) => {
e.preventDefault()
if (!name.trim()) return
try {
await api('/api/albums', { method: 'POST', body: { name: name.trim() } })
setName('')
load()
} catch (e) {
setError(e.message)
}
}
return (
<>
<div className="page-head">
<h1>Albums</h1>
<form className="row" onSubmit={create}>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="New album name"
/>
<button className="btn btn-primary" type="submit">
Create
</button>
</form>
</div>
{error && <p className="error">{error}</p>}
{albums === null ? (
<p className="muted">Loading</p>
) : albums.length === 0 ? (
<p className="muted">No albums yet create one above.</p>
) : (
<div className="album-grid">
{albums.map((a) => (
<Link key={a.id} to={`/albums/${a.id}`} className="album-card">
<div className="album-cover">
{a.cover_photo_id ? (
<img
src={imgUrl({ id: a.cover_photo_id, processed_at: a.cover_processed_at }, 'thumb')}
loading="lazy"
alt=""
/>
) : (
<div className="album-cover-empty"></div>
)}
</div>
<div className="album-meta">
<strong>{a.name}</strong>
<span className="muted">
{a.photo_count} photo{a.photo_count === 1 ? '' : 's'}
</span>
</div>
</Link>
))}
</div>
)}
</>
)
}
+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>
)}
/>
)}
</>
)
}
+570
View File
@@ -0,0 +1,570 @@
* {
box-sizing: border-box;
}
:root {
--bg: #101216;
--panel: #191c22;
--panel-2: #22262e;
--text: #e8e6e1;
--muted: #9a978f;
--accent: #d9a441;
--danger: #e5645a;
--radius: 8px;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-size: 15px;
line-height: 1.5;
}
h1 {
font-size: 1.4rem;
font-weight: 600;
margin: 0;
}
h1 a {
text-decoration: none;
}
h2 {
font-size: 1rem;
font-weight: 600;
margin: 0 0 0.75rem;
}
a {
color: var(--text);
}
.muted {
color: var(--muted);
font-weight: 400;
}
.error {
color: var(--danger);
}
.page {
max-width: 1400px;
margin: 0 auto;
padding: 1rem 1.25rem 4rem;
}
.center-page {
min-height: 80vh;
display: flex;
align-items: center;
justify-content: center;
color: var(--muted);
}
.page-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin: 1rem 0 1.25rem;
}
.row {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* top bar */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 1.25rem;
border-bottom: 1px solid var(--panel-2);
}
.brand {
font-weight: 700;
font-size: 1rem;
text-decoration: none;
color: var(--accent);
}
.topbar-right {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* inputs & buttons */
input {
background: var(--panel);
border: 1px solid var(--panel-2);
color: var(--text);
border-radius: var(--radius);
padding: 0.45rem 0.7rem;
font-size: 0.95rem;
}
input:focus {
outline: 1px solid var(--accent);
}
.btn {
display: inline-block;
background: var(--panel-2);
color: var(--text);
border: none;
border-radius: var(--radius);
padding: 0.45rem 0.9rem;
font-size: 0.9rem;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
}
.btn:hover {
filter: brightness(1.15);
}
.btn-primary {
background: var(--accent);
color: #1a1408;
font-weight: 600;
}
.btn-danger {
background: transparent;
color: var(--danger);
border: 1px solid var(--danger);
}
.btn-ghost {
background: transparent;
color: var(--muted);
}
/* login */
.login-card {
background: var(--panel);
border-radius: 12px;
padding: 2.5rem 3rem;
text-align: center;
display: flex;
flex-direction: column;
gap: 0.9rem;
color: var(--text);
}
/* album grid */
.album-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1rem;
}
.album-card {
background: var(--panel);
border-radius: var(--radius);
overflow: hidden;
text-decoration: none;
transition: transform 0.1s;
}
.album-card:hover {
transform: translateY(-2px);
}
.album-cover {
aspect-ratio: 3 / 2;
background: var(--panel-2);
}
.album-cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.album-cover-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--muted);
}
.album-meta {
padding: 0.6rem 0.8rem;
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.5rem;
}
/* justified gallery */
.gallery {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 1rem 0;
}
.g-item {
position: relative;
height: 240px;
flex-grow: calc(var(--ar) * 100);
flex-basis: calc(var(--ar) * 240px);
border-radius: 4px;
overflow: hidden;
cursor: pointer;
background: var(--panel);
}
.g-item img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.g-spacer {
flex-grow: 1000000;
flex-basis: 0;
height: 0;
}
.g-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
gap: 0.6rem;
padding: 0.35rem 0.55rem;
font-size: 0.8rem;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.75));
color: #ffd97a;
}
/* selection */
.g-check {
position: absolute;
top: 8px;
left: 8px;
width: 26px;
height: 26px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.85);
background: rgba(0, 0, 0, 0.35);
color: transparent;
font-size: 0.85rem;
line-height: 1;
cursor: pointer;
opacity: 0;
transition: opacity 0.12s;
}
.g-item:hover .g-check,
.gallery.selecting .g-check,
.g-item.selected .g-check {
opacity: 1;
}
.g-item.selected .g-check {
background: var(--accent);
border-color: var(--accent);
color: #1a1408;
}
.g-item.selected img {
outline: 3px solid var(--accent);
outline-offset: -3px;
}
.select-bar {
position: fixed;
bottom: 1.25rem;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 0.75rem;
background: var(--panel);
border: 1px solid var(--panel-2);
border-radius: 999px;
padding: 0.5rem 1rem;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
z-index: 50;
}
.select-count {
font-size: 0.9rem;
white-space: nowrap;
}
.select-toggle {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.9rem;
color: var(--muted);
cursor: pointer;
}
.select-toggle input {
accent-color: var(--accent);
width: 16px;
height: 16px;
}
/* upload zone */
.upload-zone {
border: 2px dashed var(--panel-2);
border-radius: var(--radius);
padding: 1.25rem;
text-align: center;
color: var(--muted);
cursor: pointer;
margin-bottom: 1rem;
}
.upload-zone.dragging {
border-color: var(--accent);
color: var(--accent);
}
.upload-zone p {
margin: 0;
}
.upload-list {
list-style: none;
margin: 1rem 0 0;
padding: 0;
text-align: left;
max-height: 220px;
overflow-y: auto;
cursor: default;
}
.upload-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.2rem 0;
font-size: 0.85rem;
}
.upload-item.done {
color: var(--muted);
}
.upload-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
progress {
width: 160px;
accent-color: var(--accent);
}
/* panels */
.panel {
background: var(--panel);
border-radius: var(--radius);
padding: 1rem 1.25rem;
margin: 1.5rem 0;
}
.pending-list {
list-style: none;
margin: 0;
padding: 0;
}
.pending-list li {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.25rem 0;
font-size: 0.9rem;
}
/* shares */
.share-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--panel-2);
}
.share-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.share-info .muted {
font-size: 0.82rem;
}
.share-form {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-top: 1rem;
}
.share-form label {
font-size: 0.85rem;
color: var(--muted);
}
.field-label {
gap: 0.4rem;
}
.field-label .btn-ghost {
padding: 0.2rem 0.4rem;
}
/* share (client) page */
.share-head {
text-align: center;
padding: 2.5rem 1rem 0.5rem;
}
.share-head h1 {
font-size: 1.8rem;
font-weight: 300;
}
.share-head p {
margin: 0.4rem 0 0;
}
/* lightbox */
.lightbox {
position: fixed;
inset: 0;
background: rgba(8, 9, 11, 0.96);
z-index: 100;
display: flex;
flex-direction: column;
}
.lb-top {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.6rem 1rem;
color: var(--muted);
font-size: 0.85rem;
}
.lb-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lb-btn {
background: none;
border: none;
color: var(--text);
font-size: 1.1rem;
cursor: pointer;
}
.lb-stage {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0 3.5rem;
}
.lb-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
cursor: default;
}
.lb-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(255, 255, 255, 0.06);
border: none;
color: var(--text);
font-size: 2rem;
line-height: 1;
padding: 0.6rem 0.8rem;
border-radius: 50%;
cursor: pointer;
z-index: 101;
}
.lb-nav:disabled {
opacity: 0.25;
cursor: default;
}
.lb-prev {
left: 0.75rem;
}
.lb-next {
right: 0.75rem;
}
.lb-footer {
padding: 0.7rem 1rem 1rem;
}
.client-footer,
.admin-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 1.25rem;
flex-wrap: wrap;
}
.feedback {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
font-size: 0.85rem;
}
.feedback-item {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
/* stars */
.stars {
display: inline-flex;
}
.star {
background: none;
border: none;
font-size: 1.5rem;
line-height: 1;
color: #4a4a45;
cursor: pointer;
padding: 0 0.1rem;
}
.star.filled {
color: var(--accent);
}
.stars-small .star {
font-size: 0.95rem;
cursor: default;
}
.stars-readonly .star {
cursor: default;
}
/* tags */
.tag-editor {
display: inline-flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.tag-editor input {
width: 110px;
padding: 0.3rem 0.5rem;
font-size: 0.85rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: var(--panel-2);
border-radius: 999px;
padding: 0.15rem 0.6rem;
font-size: 0.82rem;
}
.chip button {
background: none;
border: none;
color: var(--muted);
cursor: pointer;
font-size: 0.95rem;
padding: 0;
}
@media (max-width: 700px) {
.g-item {
height: 160px;
flex-basis: calc(var(--ar) * 160px);
}
.lb-stage {
padding: 0 0.5rem;
}
.login-card {
padding: 2rem 1.5rem;
margin: 0 1rem;
}
}
+32
View File
@@ -0,0 +1,32 @@
import { useEffect, useState } from 'react'
// Multi-select over a photo list. Selection lives here (not in the gallery)
// so it survives lightbox open/close, and is pruned automatically when
// photos disappear from the list (deletes, polling refreshes).
export default function useSelection(photos) {
const [selected, setSelected] = useState(() => new Set())
useEffect(() => {
setSelected((prev) => {
if (prev.size === 0) return prev
const valid = new Set(photos.map((p) => p.id))
const next = new Set([...prev].filter((id) => valid.has(id)))
return next.size === prev.size ? prev : next
})
}, [photos])
const toggle = (photoId) =>
setSelected((prev) => {
const next = new Set(prev)
if (next.has(photoId)) next.delete(photoId)
else next.add(photoId)
return next
})
const selectAll = () => setSelected(new Set(photos.map((p) => p.id)))
const clear = () => setSelected(new Set())
const selectedBytes = photos.reduce((sum, p) => sum + (selected.has(p.id) ? p.size_bytes : 0), 0)
const totalBytes = photos.reduce((sum, p) => sum + p.size_bytes, 0)
return { selected, toggle, selectAll, clear, selectedBytes, totalBytes }
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8080',
},
},
})