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,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>
|
||||
Generated
+1761
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user