Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5512aa7e5 | ||
|
|
002e51f36e |
Generated
+1
@@ -2574,6 +2574,7 @@ dependencies = [
|
|||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
|||||||
+2
-1
@@ -37,7 +37,8 @@ tempfile = "3"
|
|||||||
time = "0.3"
|
time = "0.3"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tokio-util = { version = "0.7", features = ["io"] }
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
tower-http = { version = "0.6", features = ["fs", "trace"] }
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
tower-http = { version = "0.6", features = ["fs", "set-header", "trace"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
|
|||||||
@@ -112,8 +112,8 @@ The prebuilt image is at `git.draic.info/nils/photos` (single image contains
|
|||||||
`server`, `worker`, and the built frontend). To build your own instead:
|
`server`, `worker`, and the built frontend). To build your own instead:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker build -t registry.example.com/you/photos:0.4.0 .
|
docker build -t registry.example.com/you/photos:0.4.2 .
|
||||||
docker push registry.example.com/you/photos:0.4.0
|
docker push registry.example.com/you/photos:0.4.2
|
||||||
```
|
```
|
||||||
|
|
||||||
Install the chart, pointing it at your existing Postgres and S3:
|
Install the chart, pointing it at your existing Postgres and S3:
|
||||||
@@ -121,7 +121,7 @@ Install the chart, pointing it at your existing Postgres and S3:
|
|||||||
```sh
|
```sh
|
||||||
helm install photos deploy/chart \
|
helm install photos deploy/chart \
|
||||||
--set image.repository=git.draic.info/nils/photos \
|
--set image.repository=git.draic.info/nils/photos \
|
||||||
--set image.tag=0.4.0 \
|
--set image.tag=0.4.2 \
|
||||||
--set publicUrl=https://photos.example.com \
|
--set publicUrl=https://photos.example.com \
|
||||||
--set ingress.host=photos.example.com \
|
--set ingress.host=photos.example.com \
|
||||||
--set config.oidcIssuer=https://auth.example.com \
|
--set config.oidcIssuer=https://auth.example.com \
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { api, postDownload, sha256Hex, uploadFile } from '../api'
|
import { api, postDownload, sha256Hex, uploadFile } from '../api'
|
||||||
import Gallery from '../components/Gallery'
|
import Gallery from '../components/Gallery'
|
||||||
@@ -18,6 +18,16 @@ function fmtEta(seconds) {
|
|||||||
|
|
||||||
const UPLOAD_CONCURRENCY = 3
|
const UPLOAD_CONCURRENCY = 3
|
||||||
|
|
||||||
|
const FEEDBACK_FILTERS = [
|
||||||
|
{ key: 'all', label: 'All' },
|
||||||
|
{ key: 'accept', label: '👍' },
|
||||||
|
{ key: 'reject', label: '👎' },
|
||||||
|
{ key: 'undecided', label: 'Undecided' },
|
||||||
|
{ key: 'star3', label: '★ 3+' },
|
||||||
|
{ key: 'star4', label: '★ 4+' },
|
||||||
|
{ key: 'star5', label: '★ 5' },
|
||||||
|
]
|
||||||
|
|
||||||
// Expiry convention, in one place for the create form and the row editor:
|
// Expiry convention, in one place for the create form and the row editor:
|
||||||
// end of the chosen day in the photographer's local timezone — date-only
|
// end of the chosen day in the photographer's local timezone — date-only
|
||||||
// strings would parse as UTC midnight and expire a day early.
|
// strings would parse as UTC midnight and expire a day early.
|
||||||
@@ -414,14 +424,7 @@ export default function AlbumPage() {
|
|||||||
}, [hasPending, load])
|
}, [hasPending, load])
|
||||||
|
|
||||||
const ready = (detail?.photos ?? []).filter((p) => p.status === 'ready')
|
const ready = (detail?.photos ?? []).filter((p) => p.status === 'ready')
|
||||||
const { selected, toggle, selectAll, clear, selectedBytes, totalBytes } = useSelection(ready)
|
const feedback = detail?.feedback ?? {}
|
||||||
const lightbox = useLightbox(ready)
|
|
||||||
|
|
||||||
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 avgRating = (photoId) => {
|
||||||
const ratings = feedback[photoId]?.ratings || []
|
const ratings = feedback[photoId]?.ratings || []
|
||||||
@@ -429,6 +432,45 @@ export default function AlbumPage() {
|
|||||||
return ratings.reduce((sum, r) => sum + r.rating, 0) / ratings.length
|
return ratings.reduce((sum, r) => sum + r.rating, 0) / ratings.length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter over client feedback: verdicts, or minimum average star rating.
|
||||||
|
const [filter, setFilter] = useState('all')
|
||||||
|
const matchesFilter = (p, key) => {
|
||||||
|
if (key === 'all') return true
|
||||||
|
const verdicts = feedback[p.id]?.verdicts || []
|
||||||
|
if (key === 'accept') return verdicts.some((v) => v.verdict === 'accept')
|
||||||
|
if (key === 'reject') return verdicts.some((v) => v.verdict === 'reject')
|
||||||
|
if (key === 'undecided') return verdicts.length === 0
|
||||||
|
return (avgRating(p.id) ?? 0) >= Number(key.slice(4))
|
||||||
|
}
|
||||||
|
const shown = ready.filter((p) => matchesFilter(p, filter))
|
||||||
|
const filterCounts = useMemo(() => {
|
||||||
|
const counts = { all: ready.length, accept: 0, reject: 0, undecided: 0, star3: 0, star4: 0, star5: 0 }
|
||||||
|
for (const p of ready) {
|
||||||
|
const verdicts = feedback[p.id]?.verdicts || []
|
||||||
|
if (verdicts.some((v) => v.verdict === 'accept')) counts.accept += 1
|
||||||
|
if (verdicts.some((v) => v.verdict === 'reject')) counts.reject += 1
|
||||||
|
if (verdicts.length === 0) counts.undecided += 1
|
||||||
|
const avg = avgRating(p.id) ?? 0
|
||||||
|
if (avg >= 3) counts.star3 += 1
|
||||||
|
if (avg >= 4) counts.star4 += 1
|
||||||
|
if (avg >= 5) counts.star5 += 1
|
||||||
|
}
|
||||||
|
return counts
|
||||||
|
}, [detail])
|
||||||
|
|
||||||
|
// Selection spans all ready photos; the bar and bulk actions cover only
|
||||||
|
// the current view, like on the share page.
|
||||||
|
const { selected, toggle, selectAll, clear } = useSelection(ready)
|
||||||
|
const shownSelected = shown.filter((p) => selected.has(p.id))
|
||||||
|
const sumBytes = (list) => list.reduce((sum, p) => sum + p.size_bytes, 0)
|
||||||
|
const lightbox = useLightbox(shown)
|
||||||
|
|
||||||
|
if (error) return <p className="error">{error}</p>
|
||||||
|
if (!detail) return <p className="muted">Loading…</p>
|
||||||
|
|
||||||
|
const { album, photos } = detail
|
||||||
|
const notReady = photos.filter((p) => p.status !== 'ready')
|
||||||
|
|
||||||
const rename = async () => {
|
const rename = async () => {
|
||||||
const name = prompt('Album name', album.name)
|
const name = prompt('Album name', album.name)
|
||||||
if (name && name.trim()) {
|
if (name && name.trim()) {
|
||||||
@@ -451,10 +493,11 @@ export default function AlbumPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const removeSelected = async () => {
|
const removeSelected = async () => {
|
||||||
if (!confirm(`Delete ${selected.size} selected photo${selected.size === 1 ? '' : 's'}? This cannot be undone.`))
|
const ids = shownSelected.map((p) => p.id)
|
||||||
|
if (!confirm(`Delete ${ids.length} selected photo${ids.length === 1 ? '' : 's'}? This cannot be undone.`))
|
||||||
return
|
return
|
||||||
try {
|
try {
|
||||||
await api('/api/photos/delete', { method: 'POST', body: { ids: [...selected] } })
|
await api('/api/photos/delete', { method: 'POST', body: { ids } })
|
||||||
clear()
|
clear()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(`Delete failed: ${e.message}`)
|
alert(`Delete failed: ${e.message}`)
|
||||||
@@ -512,11 +555,25 @@ export default function AlbumPage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{ready.length > 0 && (
|
||||||
|
<div className="filter-bar filter-bar-admin">
|
||||||
|
{FEEDBACK_FILTERS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.key}
|
||||||
|
className={`filter-chip${filter === f.key ? ' active' : ''}`}
|
||||||
|
onClick={() => setFilter(f.key)}
|
||||||
|
>
|
||||||
|
{f.label} {filterCounts[f.key]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Gallery
|
<Gallery
|
||||||
photos={ready}
|
photos={shown}
|
||||||
onOpen={lightbox.openAt}
|
onOpen={lightbox.openAt}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
onToggleSelect={toggle}
|
onToggleSelect={(photoId, shift) => toggle(photoId, shift, shown)}
|
||||||
overlay={(p) => {
|
overlay={(p) => {
|
||||||
const avg = avgRating(p.id)
|
const avg = avgRating(p.id)
|
||||||
const tagCount = feedback[p.id]?.tags.length || 0
|
const tagCount = feedback[p.id]?.tags.length || 0
|
||||||
@@ -537,22 +594,29 @@ export default function AlbumPage() {
|
|||||||
{ready.length === 0 && notReady.length === 0 && (
|
{ready.length === 0 && notReady.length === 0 && (
|
||||||
<p className="muted">No photos yet — drop some above.</p>
|
<p className="muted">No photos yet — drop some above.</p>
|
||||||
)}
|
)}
|
||||||
|
{ready.length > 0 && shown.length === 0 && (
|
||||||
|
<p className="muted">No photos match this filter.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<SelectionBar
|
<SelectionBar
|
||||||
count={selected.size}
|
count={shownSelected.length}
|
||||||
total={ready.length}
|
total={shown.length}
|
||||||
selectedBytes={selectedBytes}
|
selectedBytes={sumBytes(shownSelected)}
|
||||||
totalBytes={totalBytes}
|
totalBytes={sumBytes(shown)}
|
||||||
onSelectAll={() => selectAll(ready)}
|
onSelectAll={() => selectAll(shown)}
|
||||||
onClear={clear}
|
onClear={clear}
|
||||||
onDownload={() => postDownload(`/api/albums/${id}/zip`, [...selected].join(','))}
|
onDownload={() =>
|
||||||
onDownloadAll={() => postDownload(`/api/albums/${id}/zip`)}
|
postDownload(`/api/albums/${id}/zip`, shownSelected.map((p) => p.id).join(','))
|
||||||
|
}
|
||||||
|
onDownloadAll={() =>
|
||||||
|
postDownload(`/api/albums/${id}/zip`, filter === 'all' ? '' : shown.map((p) => p.id).join(','))
|
||||||
|
}
|
||||||
onDelete={removeSelected}
|
onDelete={removeSelected}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{lightbox.index >= 0 && (
|
{lightbox.index >= 0 && (
|
||||||
<Lightbox
|
<Lightbox
|
||||||
photos={ready}
|
photos={shown}
|
||||||
index={lightbox.index}
|
index={lightbox.index}
|
||||||
onClose={lightbox.close}
|
onClose={lightbox.close}
|
||||||
onNav={lightbox.openAt}
|
onNav={lightbox.openAt}
|
||||||
|
|||||||
@@ -671,6 +671,10 @@ progress {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
.filter-bar-admin {
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* transient action feedback (keyboard votes that navigate away) */
|
/* transient action feedback (keyboard votes that navigate away) */
|
||||||
.action-flash {
|
.action-flash {
|
||||||
@@ -819,4 +823,22 @@ kbd {
|
|||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
bottom: calc(0.75rem + env(safe-area-inset-bottom));
|
bottom: calc(0.75rem + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
|
/* Share rows: info and controls stack instead of fighting for one line. */
|
||||||
|
.share-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
}
|
||||||
|
.share-row .row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.share-form {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.share-form > input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-3
@@ -1,4 +1,8 @@
|
|||||||
|
use axum::http::{header, HeaderValue};
|
||||||
|
use tower::ServiceBuilder;
|
||||||
|
use tower_http::services::fs::ServeFileSystemResponseBody;
|
||||||
use tower_http::services::{ServeDir, ServeFile};
|
use tower_http::services::{ServeDir, ServeFile};
|
||||||
|
use tower_http::set_header::SetResponseHeaderLayer;
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
@@ -17,11 +21,30 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let state = AppState::new(config).await?;
|
let state = AppState::new(config).await?;
|
||||||
|
|
||||||
let static_dir = state.config.static_dir.clone();
|
let static_dir = state.config.static_dir.clone();
|
||||||
let index = std::path::Path::new(&static_dir).join("index.html");
|
let static_root = std::path::Path::new(&static_dir);
|
||||||
// .fallback (not .not_found_service) so SPA routes get index.html with a 200
|
// Hashed assets cache forever — except 404s, which would outlive the next deploy.
|
||||||
let spa = ServeDir::new(&static_dir).fallback(ServeFile::new(index));
|
let assets = ServiceBuilder::new()
|
||||||
|
.map_response(|mut response: axum::http::Response<ServeFileSystemResponseBody>| {
|
||||||
|
if response.status().is_success() {
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
HeaderValue::from_static("public, max-age=31536000, immutable"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
response
|
||||||
|
})
|
||||||
|
.service(ServeDir::new(static_root.join("assets")));
|
||||||
|
// index.html revalidates every load (it names the asset hashes);
|
||||||
|
// .fallback (not .not_found_service) so SPA routes get it with a 200.
|
||||||
|
let spa = ServiceBuilder::new()
|
||||||
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
HeaderValue::from_static("no-cache"),
|
||||||
|
))
|
||||||
|
.service(ServeDir::new(static_root).fallback(ServeFile::new(static_root.join("index.html"))));
|
||||||
|
|
||||||
let app = photos::routes::router(&state)
|
let app = photos::routes::router(&state)
|
||||||
|
.nest_service("/assets", assets)
|
||||||
.fallback_service(spa)
|
.fallback_service(spa)
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.with_state(state.clone());
|
.with_state(state.clone());
|
||||||
|
|||||||
Reference in New Issue
Block a user