- {flash.text}
+ {culling.flash && (
+
+ {culling.flash.text}
)}
>
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 6929a26..c2a6618 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -10,9 +10,16 @@
--muted: #9a978f;
--accent: #d9a441;
--danger: #e5645a;
+ --accept: #7ac97a;
--radius: 8px;
}
+html {
+ /* Reserve the scrollbar gutter — content width must not shift when a
+ filter change toggles the scrollbar. */
+ scrollbar-gutter: stable;
+}
+
body {
margin: 0;
background: var(--bg);
@@ -105,6 +112,11 @@ input {
input:focus {
outline: 1px solid var(--accent);
}
+input[type='checkbox'] {
+ accent-color: var(--accent);
+ width: 16px;
+ height: 16px;
+}
.btn {
display: inline-block;
background: var(--panel-2);
@@ -158,10 +170,9 @@ input:focus {
border-radius: var(--radius);
overflow: hidden;
text-decoration: none;
- transition: transform 0.1s;
}
-.album-card:hover {
- transform: translateY(-2px);
+.album-card:hover .album-cover img {
+ filter: brightness(1.08);
}
.album-cover {
aspect-ratio: 3 / 2;
@@ -188,41 +199,41 @@ input:focus {
gap: 0.5rem;
}
-/* justified gallery */
+/* photo grid (Lightroom-style fixed cells) */
.gallery {
- display: flex;
- flex-direction: column;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 6px;
margin: 1rem 0;
}
-.g-row {
- display: flex;
- gap: 6px;
-}
.g-item {
position: relative;
- flex: none;
+ aspect-ratio: 1 / 1;
border-radius: 4px;
overflow: hidden;
cursor: pointer;
background: var(--panel);
+ content-visibility: auto;
+ contain-intrinsic-size: 300px;
}
.g-item img {
width: 100%;
height: 100%;
- object-fit: cover;
+ padding: 8px;
+ object-fit: contain;
display: block;
}
.g-overlay {
position: absolute;
- bottom: 0;
- left: 0;
- right: 0;
- display: flex;
+ bottom: 6px;
+ left: 6px;
+ display: inline-flex;
+ align-items: center;
gap: 0.6rem;
- padding: 0.35rem 0.55rem;
+ padding: 0.2rem 0.55rem;
font-size: 0.8rem;
- background: linear-gradient(transparent, rgba(0, 0, 0, 0.75));
+ background: rgba(0, 0, 0, 0.55);
+ border-radius: 6px;
color: #ffd97a;
}
@@ -257,6 +268,10 @@ input:focus {
outline: 3px solid var(--accent);
outline-offset: -3px;
}
+.g-item.focused {
+ outline: 2px solid var(--text);
+ outline-offset: -2px;
+}
.select-bar {
position: fixed;
bottom: 1.25rem;
@@ -284,41 +299,8 @@ input:focus {
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-summary {
- display: flex;
- flex-direction: column;
- gap: 0.3rem;
- margin-top: 1rem;
- cursor: default;
- font-size: 0.9rem;
-}
-.upload-total {
- width: 100%;
- accent-color: var(--accent);
-}
+/* upload */
.upload-list {
list-style: none;
margin: 1rem 0 0;
@@ -349,6 +331,121 @@ progress {
accent-color: var(--accent);
}
+/* fullscreen drag target */
+.drop-overlay {
+ position: fixed;
+ inset: 10px;
+ z-index: 200;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(16, 18, 22, 0.82);
+ border: 3px dashed var(--accent);
+ border-radius: 12px;
+ color: var(--accent);
+ font-size: 1.4rem;
+ pointer-events: none;
+}
+
+/* activity overlay: uploads + processing, bottom right */
+.activity {
+ position: fixed;
+ right: 1rem;
+ bottom: calc(1rem + env(safe-area-inset-bottom));
+ width: min(340px, calc(100vw - 2rem));
+ background: var(--panel);
+ border: 1px solid var(--panel-2);
+ border-radius: var(--radius);
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
+ z-index: 60;
+ font-size: 0.85rem;
+}
+.activity-head {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ background: none;
+ border: none;
+ color: var(--text);
+ padding: 0.55rem 0.8rem;
+ font-size: 0.85rem;
+ cursor: pointer;
+}
+.activity-clear {
+ color: var(--muted);
+ padding: 0 0.2rem;
+}
+.activity-total {
+ display: block;
+ width: calc(100% - 1.6rem);
+ margin: 0 0.8rem 0.5rem;
+}
+.activity-body {
+ border-top: 1px solid var(--panel-2);
+ padding: 0.5rem 0.8rem 0.7rem;
+ max-height: 40vh;
+ overflow-y: auto;
+}
+.activity-body .upload-list {
+ margin: 0.25rem 0 0;
+ max-height: none;
+}
+.activity-body progress {
+ width: 110px;
+}
+.activity-section {
+ margin-top: 0.4rem;
+ font-size: 0.8rem;
+}
+.activity-section:first-child {
+ margin-top: 0;
+}
+
+/* modal */
+.modal {
+ position: fixed;
+ inset: 0;
+ z-index: 90;
+ background: rgba(8, 9, 11, 0.7);
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding: 8vh 1rem 1rem;
+}
+.modal-card {
+ background: var(--panel);
+ border: 1px solid var(--panel-2);
+ border-radius: 12px;
+ width: min(760px, 100%);
+ max-height: 80vh;
+ overflow-y: auto;
+ padding: 1rem 1.25rem 1.25rem;
+}
+.modal-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 0.5rem;
+}
+.modal-head h2 {
+ margin: 0;
+}
+.modal-foot {
+ margin-top: 1rem;
+}
+.xmp-source {
+ display: flex;
+ gap: 0.5rem;
+ padding: 0.3rem 0;
+ color: var(--text);
+}
+.btn-disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+
/* panels */
.panel {
background: var(--panel);
@@ -387,14 +484,72 @@ progress {
.share-info .muted {
font-size: 0.82rem;
}
-.share-form {
+.share-stats {
+ display: inline-flex;
+ gap: 0.3rem;
+}
+.share-feedback {
+ margin-top: 0.35rem;
+}
+.feedback-strip {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
+ margin: 0 0 0.75rem;
+}
+.client-fb-btn {
+ cursor: pointer;
+ font-size: inherit;
+ color: var(--text);
+}
+.client-fb-btn.active {
+ border-color: var(--accent);
+ background: var(--panel-2);
+}
+.chip-accept {
+ background: color-mix(in srgb, var(--accept) 18%, var(--panel-2));
+}
+.chip-reject {
+ background: color-mix(in srgb, var(--danger) 18%, var(--panel-2));
+}
+.share-url {
+ background: none;
+ border: none;
+ padding: 0;
+ margin-top: 0.15rem;
+ color: var(--muted);
+ font-size: 0.82rem;
+ cursor: pointer;
+ text-align: left;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 100%;
+}
+.share-url:hover {
+ color: var(--accent);
+}
+.share-form {
+ display: grid;
+ grid-template-columns: max-content minmax(0, 320px);
+ gap: 0.6rem 1rem;
align-items: center;
margin-top: 1rem;
+ border-top: 1px solid var(--panel-2);
+ padding-top: 0.9rem;
}
-.share-form label {
+.share-form-title {
+ grid-column: 1 / -1;
+ font-size: 0.95rem;
+ font-weight: 600;
+ margin: 0;
+}
+.share-form .field {
+ display: contents;
+ font-size: 0.85rem;
+}
+.share-form-full {
+ grid-column: 1 / -1;
font-size: 0.85rem;
color: var(--muted);
}
@@ -574,14 +729,49 @@ progress {
.lb-footer {
padding: 0.7rem 1rem 1rem;
}
-.client-footer,
-.admin-footer {
+.client-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 1.25rem;
flex-wrap: wrap;
}
+/* Two rows: things you do (own vote + photo actions), things you read
+ (per-client feedback pills). */
+.admin-footer {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.6rem;
+}
+.admin-actions {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 1.25rem;
+ flex-wrap: wrap;
+}
+.client-fb {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ background: var(--panel);
+ border: 1px solid var(--panel-2);
+ border-radius: 999px;
+ padding: 0.15rem 0.7rem;
+}
+.client-fb-verdict {
+ font-size: 1.1rem;
+ line-height: 1;
+}
+.client-fb-accept {
+ border-color: color-mix(in srgb, var(--accept) 60%, transparent);
+ background: color-mix(in srgb, var(--accept) 16%, var(--panel));
+}
+.client-fb-reject {
+ border-color: color-mix(in srgb, var(--danger) 60%, transparent);
+ background: color-mix(in srgb, var(--danger) 16%, var(--panel));
+}
.feedback {
display: flex;
align-items: center;
@@ -589,10 +779,10 @@ progress {
flex-wrap: wrap;
font-size: 0.85rem;
}
-.feedback-item {
+.own-feedback {
display: inline-flex;
align-items: center;
- gap: 0.3rem;
+ gap: 0.5rem;
}
/* stars */
@@ -809,6 +999,9 @@ kbd {
}
@media (max-width: 700px) {
+ .gallery {
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+ }
.lb-slide {
padding: 0 0.5rem;
}
@@ -851,10 +1044,13 @@ kbd {
row-gap: 0.5rem;
}
.share-form {
- flex-direction: column;
- align-items: stretch;
+ grid-template-columns: 1fr;
}
- .share-form > input {
+ .share-form .field {
+ display: block;
+ }
+ .share-form .field > input {
width: 100%;
+ margin-top: 0.2rem;
}
}
diff --git a/frontend/src/useCulling.js b/frontend/src/useCulling.js
new file mode 100644
index 0000000..7dfd730
--- /dev/null
+++ b/frontend/src/useCulling.js
@@ -0,0 +1,86 @@
+import { useEffect, useRef, useState } from 'react'
+import { ACCEPT_GLYPH, REJECT_GLYPH } from './components/Thumbs'
+
+// Shared culling interaction for a lightbox over a (possibly filtered) photo
+// list: the keyboard table (P/X/U, stars, select), swipe gestures, the
+// action toast, and the modal fade-out after voting the last photo.
+export default function useCulling({
+ visible,
+ lightbox,
+ setVerdict,
+ setRating,
+ toggle,
+ canSelect = true,
+}) {
+ const [flash, setFlash] = useState(null)
+ const flashSeq = useRef(0)
+ const flashTimer = useRef()
+ const showFlash = (text) => {
+ flashSeq.current += 1
+ setFlash({ text, key: flashSeq.current })
+ clearTimeout(flashTimer.current)
+ flashTimer.current = setTimeout(() => setFlash(null), 1400)
+ }
+ useEffect(() => () => clearTimeout(flashTimer.current), [])
+
+ const [fading, setFading] = useState(false)
+ useEffect(() => {
+ if (lightbox.index < 0) setFading(false)
+ }, [lightbox.index])
+
+ // Keyboard votes stay on the photo (the footer controls show the result);
+ // only touch gestures advance, where the fly-off animation carries the
+ // context.
+ const vote = (photo, verdict) => {
+ setVerdict(photo, verdict)
+ showFlash(
+ verdict === 'accept' ? `${ACCEPT_GLYPH} ${photo.filename}` : `${REJECT_GLYPH} ${photo.filename}`,
+ )
+ }
+
+ const voteAndAdvance = (photo, verdict) => {
+ const next = visible[lightbox.index + 1]
+ setVerdict(photo, verdict)
+ showFlash(
+ verdict === 'accept'
+ ? `${ACCEPT_GLYPH} ${photo.filename}`
+ : verdict === 'reject'
+ ? `${REJECT_GLYPH} ${photo.filename}`
+ : `↺ ${photo.filename} cleared`,
+ )
+ if (next) lightbox.show(next.id)
+ else setFading(true)
+ }
+
+ const keyActions = [
+ { keys: ['p'], help: ['P', 'accept'], run: (p) => vote(p, 'accept') },
+ { keys: ['x'], help: ['X', 'reject'], run: (p) => vote(p, 'reject') },
+ { keys: ['u'], help: ['U', 'clear accept / reject'], run: (p) => setVerdict(p, null) },
+ {
+ keys: ['1', '2', '3', '4', '5'],
+ help: ['1–5', 'star rating'],
+ run: (p, key) => setRating(p, Number(key)),
+ },
+ { keys: ['0'], help: ['0', 'clear star rating'], run: (p) => setRating(p, 0) },
+ ...(canSelect
+ ? [{ keys: ['s'], help: ['S', 'select for download'], run: (p) => toggle(p.id) }]
+ : []),
+ ]
+
+ return {
+ flash,
+ keyActions,
+ lightboxProps: {
+ actions: keyActions,
+ gestures: {
+ up: (p) => voteAndAdvance(p, 'accept'),
+ down: (p) => voteAndAdvance(p, 'reject'),
+ },
+ closing: fading,
+ onClosed: () => {
+ setFading(false)
+ lightbox.close()
+ },
+ },
+ }
+}
diff --git a/frontend/src/useEscape.js b/frontend/src/useEscape.js
new file mode 100644
index 0000000..2341171
--- /dev/null
+++ b/frontend/src/useEscape.js
@@ -0,0 +1,22 @@
+import { useEffect } from 'react'
+
+export const isTypingTarget = (el) =>
+ el.tagName === 'TEXTAREA' ||
+ el.isContentEditable ||
+ (el.tagName === 'INPUT' && !['checkbox', 'radio', 'button'].includes(el.type))
+
+// Escape closes — unless focus is in a text field, where it only leaves it.
+export default function useEscape(onClose) {
+ useEffect(() => {
+ const handler = (e) => {
+ if (e.key !== 'Escape') return
+ if (isTypingTarget(e.target)) {
+ e.target.blur()
+ return
+ }
+ onClose()
+ }
+ window.addEventListener('keydown', handler)
+ return () => window.removeEventListener('keydown', handler)
+ }, [onClose])
+}
diff --git a/frontend/src/useLightbox.js b/frontend/src/useLightbox.js
index 2b511c9..aae15d5 100644
--- a/frontend/src/useLightbox.js
+++ b/frontend/src/useLightbox.js
@@ -1,23 +1,40 @@
-import { useEffect, useState } from 'react'
+import { useEffect, useMemo, useRef, useState } from 'react'
-// Lightbox state tracked by photo id, not index — the list can reorder
-// (polling refetch) or shrink (filter change, delete) underneath an open
-// lightbox. When the open photo leaves the list, close for good — otherwise
-// the lightbox would pop back open when the photo returns to the list.
+// Lightbox state tracked by photo id, not index — the list can reorder or
+// shrink underneath an open lightbox. While open, the visible list is FROZEN
+// to the ids present at open time (mapped to live photo objects, so votes
+// still update the open photo): voting a photo out of the active filter must
+// not yank it from under the viewer. Filters re-apply on close. If the open
+// photo disappears entirely (deleted), the lightbox closes for good.
export default function useLightbox(photos) {
const [openId, setOpenId] = useState(null)
- const index = openId ? photos.findIndex((p) => p.id === openId) : -1
+ const frozen = useRef(null)
+
+ const byId = useMemo(() => new Map(photos.map((p) => [p.id, p])), [photos])
+ const view =
+ openId && frozen.current ? frozen.current.map((id) => byId.get(id)).filter(Boolean) : photos
+ const index = openId ? view.findIndex((p) => p.id === openId) : -1
useEffect(() => {
- if (openId && index < 0) setOpenId(null)
+ if (openId && index < 0) {
+ frozen.current = null
+ setOpenId(null)
+ }
}, [openId, index])
+ const open = (id) => {
+ if (!frozen.current) frozen.current = photos.map((p) => p.id)
+ setOpenId(id)
+ }
+
return {
index,
- // Bounds-safe: callers may hold a stale index (the list can shrink
- // between render and dispatch).
- openAt: (i) => photos[i] && setOpenId(photos[i].id),
- show: (id) => setOpenId(id),
- close: () => setOpenId(null),
+ view,
+ openAt: (i) => view[i] && open(view[i].id),
+ show: (id) => open(id),
+ close: () => {
+ frozen.current = null
+ setOpenId(null)
+ },
}
}
diff --git a/migrations/0005_owner_feedback.sql b/migrations/0005_owner_feedback.sql
new file mode 100644
index 0000000..96b77ea
--- /dev/null
+++ b/migrations/0005_owner_feedback.sql
@@ -0,0 +1,4 @@
+-- The photographer's own verdict/rating. One opinion per photo, so these
+-- live on the photo itself rather than in the per-link feedback tables.
+alter table photos add column owner_rating int check (owner_rating between 1 and 5);
+alter table photos add column owner_verdict text check (owner_verdict in ('accept', 'reject'));
diff --git a/src/models.rs b/src/models.rs
index 999c8d8..5eb1c0b 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -66,6 +66,62 @@ impl Verdict {
}
}
+/// The one place feedback-aggregation policy lives (UI filters and XMP
+/// export must agree): the rating is the MAX across everyone, any accept
+/// wins over rejects, and the owner's feedback counts like a client's.
+#[derive(Debug, Clone, Copy, Default, Serialize)]
+pub struct FeedbackAggregate {
+ pub top_rating: Option
,
+ pub accepts: i32,
+ pub rejects: i32,
+}
+
+impl FeedbackAggregate {
+ pub fn add_rating(&mut self, rating: i32) {
+ self.top_rating = Some(self.top_rating.map_or(rating, |top| top.max(rating)));
+ }
+
+ pub fn add_verdict(&mut self, verdict: &str) {
+ if verdict == "accept" {
+ self.accepts += 1;
+ } else {
+ self.rejects += 1;
+ }
+ }
+
+ pub fn add_owner(&mut self, rating: Option, verdict: Option<&str>) {
+ if let Some(rating) = rating {
+ self.add_rating(rating);
+ }
+ if let Some(verdict) = verdict {
+ self.add_verdict(verdict);
+ }
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.top_rating.is_none() && self.accepts == 0 && self.rejects == 0
+ }
+
+ pub fn merge(&mut self, other: &FeedbackAggregate) {
+ if let Some(rating) = other.top_rating {
+ self.add_rating(rating);
+ }
+ self.accepts += other.accepts;
+ self.rejects += other.rejects;
+ }
+
+ /// XMP color label: any accept wins, otherwise any reject.
+ pub fn label(&self) -> Option<&'static str> {
+ if self.accepts > 0 {
+ Some("Green")
+ } else if self.rejects > 0 {
+ Some("Red")
+ } else {
+ None
+ }
+ }
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JobKind {
ProcessPhoto,
@@ -134,6 +190,8 @@ pub struct Photo {
pub height: Option,
pub taken_at: Option>,
pub processed_at: Option>,
+ pub owner_rating: Option,
+ pub owner_verdict: Option,
#[serde(skip_serializing)]
pub sha256: Option,
#[serde(skip_serializing)]
diff --git a/src/routes/albums.rs b/src/routes/albums.rs
index 0261226..6de5ed3 100644
--- a/src/routes/albums.rs
+++ b/src/routes/albums.rs
@@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
-use crate::models::{Album, Photo, PhotoStatus};
+use crate::models::{Album, FeedbackAggregate, Photo, PhotoStatus};
use crate::state::AppState;
#[derive(Serialize, sqlx::FromRow)]
@@ -69,18 +69,21 @@ pub async fn create(
#[derive(Serialize)]
pub struct ShareRating {
+ pub share_id: Uuid,
pub share_label: String,
pub rating: i32,
}
#[derive(Serialize)]
pub struct ShareTag {
+ pub share_id: Uuid,
pub share_label: String,
pub tag: String,
}
#[derive(Serialize)]
pub struct ShareVerdict {
+ pub share_id: Uuid,
pub share_label: String,
pub verdict: String,
}
@@ -92,24 +95,42 @@ pub struct PhotoFeedback {
pub tags: Vec,
}
-async fn feedback_rows(
+pub(super) const RATING_ROWS: &str = "select r.photo_id, s.id, s.label, r.rating
+ from ratings r join shares s on s.id = r.share_id
+ where s.album_id = $1 and ($2::uuid[] is null or r.share_id = any($2))";
+pub(super) const VERDICT_ROWS: &str = "select v.photo_id, s.id, s.label, v.verdict
+ from verdicts v join shares s on s.id = v.share_id
+ where s.album_id = $1 and ($2::uuid[] is null or v.share_id = any($2))";
+pub(super) const TAG_ROWS: &str = "select t.photo_id, s.id, s.label, t.tag
+ from tags t join shares s on s.id = t.share_id
+ where s.album_id = $1 and ($2::uuid[] is null or t.share_id = any($2))
+ order by t.created_at";
+
+/// One feedback query: (photo_id, share id, share label, value) rows for an
+/// album, optionally narrowed to a single share ($2).
+pub(super) async fn feedback_rows(
db: &sqlx::PgPool,
sql: &str,
album_id: Uuid,
-) -> Result, sqlx::Error>
+ shares: Option<&[Uuid]>,
+) -> Result, sqlx::Error>
where
- (Uuid, String, T): for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow> + Send + Unpin,
+ (Uuid, Uuid, String, T): for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow> + Send + Unpin,
{
- sqlx::query_as(sql).bind(album_id).fetch_all(db).await
+ sqlx::query_as(sql)
+ .bind(album_id)
+ .bind(shares)
+ .fetch_all(db)
+ .await
}
fn fold_feedback(
feedback: &mut HashMap,
- rows: Vec<(Uuid, String, T)>,
- push: impl Fn(&mut PhotoFeedback, String, T),
+ rows: Vec<(Uuid, Uuid, String, T)>,
+ push: impl Fn(&mut PhotoFeedback, Uuid, String, T),
) {
- for (photo_id, share_label, value) in rows {
- push(feedback.entry(photo_id).or_default(), share_label, value);
+ for (photo_id, share_id, share_label, value) in rows {
+ push(feedback.entry(photo_id).or_default(), share_id, share_label, value);
}
}
@@ -118,6 +139,7 @@ pub struct AlbumDetail {
pub album: Album,
pub photos: Vec,
pub feedback: HashMap,
+ pub aggregates: HashMap,
}
pub async fn get_one(
@@ -136,57 +158,85 @@ pub async fn get_one(
.fetch_all(&state.db)
.await?;
- // The three feedback kinds are independent (photo_id, share label, value)
+ // The three feedback kinds are independent (photo_id, share, value)
// queries — run them concurrently and fold with one shared shape.
let (ratings, verdicts, tags) = tokio::try_join!(
- feedback_rows::(
- &state.db,
- "select r.photo_id, s.label, r.rating
- from ratings r join shares s on s.id = r.share_id
- where s.album_id = $1",
- album_id,
- ),
- feedback_rows::(
- &state.db,
- "select v.photo_id, s.label, v.verdict
- from verdicts v join shares s on s.id = v.share_id
- where s.album_id = $1",
- album_id,
- ),
- feedback_rows::(
- &state.db,
- "select t.photo_id, s.label, t.tag
- from tags t join shares s on s.id = t.share_id
- where s.album_id = $1
- order by t.created_at",
- album_id,
- ),
+ feedback_rows::(&state.db, RATING_ROWS, album_id, None),
+ feedback_rows::(&state.db, VERDICT_ROWS, album_id, None),
+ feedback_rows::(&state.db, TAG_ROWS, album_id, None),
)?;
+ let mut aggregates: HashMap = HashMap::new();
+ for (photo_id, _, _, rating) in &ratings {
+ aggregates.entry(*photo_id).or_default().add_rating(*rating);
+ }
+ for (photo_id, _, _, verdict) in &verdicts {
+ aggregates.entry(*photo_id).or_default().add_verdict(verdict);
+ }
+ for photo in &photos {
+ if photo.owner_rating.is_some() || photo.owner_verdict.is_some() {
+ aggregates
+ .entry(photo.id)
+ .or_default()
+ .add_owner(photo.owner_rating, photo.owner_verdict.as_deref());
+ }
+ }
+
let mut feedback: HashMap = HashMap::new();
- fold_feedback(&mut feedback, ratings, |f, share_label, rating| {
+ fold_feedback(&mut feedback, ratings, |f, share_id, share_label, rating| {
f.ratings.push(ShareRating {
+ share_id,
share_label,
rating,
})
});
- fold_feedback(&mut feedback, verdicts, |f, share_label, verdict| {
+ fold_feedback(&mut feedback, verdicts, |f, share_id, share_label, verdict| {
f.verdicts.push(ShareVerdict {
+ share_id,
share_label,
verdict,
})
});
- fold_feedback(&mut feedback, tags, |f, share_label, tag| {
- f.tags.push(ShareTag { share_label, tag })
+ fold_feedback(&mut feedback, tags, |f, share_id, share_label, tag| {
+ f.tags.push(ShareTag {
+ share_id,
+ share_label,
+ tag,
+ })
});
Ok(Json(AlbumDetail {
album,
photos,
feedback,
+ aggregates,
}))
}
+#[derive(Serialize, sqlx::FromRow)]
+pub struct PendingPhoto {
+ pub id: Uuid,
+ pub filename: String,
+ pub status: String,
+ pub error: Option,
+}
+
+pub async fn pending(
+ State(state): State,
+ Path(album_id): Path,
+) -> ApiResult> {
+ let pending: Vec = sqlx::query_as(
+ "select id, filename, status, error from photos
+ where album_id = $1 and status != $2
+ order by created_at",
+ )
+ .bind(album_id)
+ .bind(PhotoStatus::Ready.as_str())
+ .fetch_all(&state.db)
+ .await?;
+ Ok(Json(serde_json::json!({ "pending": pending })))
+}
+
#[derive(Deserialize)]
pub struct UpdateAlbum {
name: Option,
diff --git a/src/routes/mod.rs b/src/routes/mod.rs
index 294e0a4..90bc7bb 100644
--- a/src/routes/mod.rs
+++ b/src/routes/mod.rs
@@ -3,6 +3,7 @@ pub mod client;
pub mod images;
pub mod photos;
pub mod shares;
+pub mod xmp;
pub mod zip;
use axum::extract::{DefaultBodyLimit, Request, State};
@@ -77,9 +78,13 @@ pub fn router(state: &AppState) -> Router {
get(shares::list).post(shares::create),
)
.route("/api/albums/{id}/zip", post(zip::album_zip))
+ .route("/api/albums/{id}/xmp", get(xmp::album_xmp))
+ .route("/api/albums/{id}/pending", get(albums::pending))
.route("/api/albums/{id}/photos/by-hash/{sha256}", get(photos::by_hash))
.route("/api/photos/delete", post(photos::delete_many))
.route("/api/photos/{id}", delete(photos::delete))
+ .route("/api/photos/{id}/rating", put(photos::set_owner_rating))
+ .route("/api/photos/{id}/verdict", put(photos::set_owner_verdict))
.route("/api/photos/{id}/reprocess", post(photos::reprocess))
.route(
"/api/shares/{id}",
diff --git a/src/routes/photos.rs b/src/routes/photos.rs
index d056874..836f019 100644
--- a/src/routes/photos.rs
+++ b/src/routes/photos.rs
@@ -11,7 +11,7 @@ use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
use crate::jobs;
-use crate::models::{JobKind, Photo, PhotoStatus};
+use crate::models::{FeedbackAggregate, JobKind, Photo, PhotoStatus, Verdict};
use crate::s3;
use crate::state::AppState;
@@ -252,6 +252,86 @@ pub async fn delete_many(
Ok(Json(serde_json::json!({ "ok": true, "deleted": deleted })))
}
+async fn feedback_aggregate(
+ db: &sqlx::PgPool,
+ photo_id: Uuid,
+) -> Result {
+ let (top, accepts, rejects, owner_rating, owner_verdict): (
+ Option,
+ i64,
+ i64,
+ Option,
+ Option,
+ ) = sqlx::query_as(
+ "select
+ (select max(r.rating) from ratings r where r.photo_id = p.id),
+ (select count(*) from verdicts v where v.photo_id = p.id and v.verdict = 'accept'),
+ (select count(*) from verdicts v where v.photo_id = p.id and v.verdict = 'reject'),
+ p.owner_rating, p.owner_verdict
+ from photos p where p.id = $1",
+ )
+ .bind(photo_id)
+ .fetch_one(db)
+ .await?;
+ let mut agg = FeedbackAggregate {
+ top_rating: None,
+ accepts: accepts as i32,
+ rejects: rejects as i32,
+ };
+ if let Some(top) = top {
+ agg.add_rating(top);
+ }
+ agg.add_owner(owner_rating, owner_verdict.as_deref());
+ Ok(agg)
+}
+
+#[derive(Deserialize)]
+pub struct OwnerRatingBody {
+ rating: i32,
+}
+
+pub async fn set_owner_rating(
+ State(state): State,
+ Path(photo_id): Path,
+ Json(body): Json,
+) -> ApiResult> {
+ if !(0..=5).contains(&body.rating) {
+ return Err(ApiError::bad_request("rating must be between 0 and 5"));
+ }
+ let updated = sqlx::query("update photos set owner_rating = $2 where id = $1")
+ .bind(photo_id)
+ .bind((body.rating > 0).then_some(body.rating))
+ .execute(&state.db)
+ .await?;
+ if updated.rows_affected() == 0 {
+ return Err(ApiError::not_found());
+ }
+ let aggregate = feedback_aggregate(&state.db, photo_id).await?;
+ Ok(Json(serde_json::json!({ "ok": true, "aggregate": aggregate })))
+}
+
+#[derive(Deserialize)]
+pub struct OwnerVerdictBody {
+ verdict: Option,
+}
+
+pub async fn set_owner_verdict(
+ State(state): State,
+ Path(photo_id): Path,
+ Json(body): Json,
+) -> ApiResult> {
+ let updated = sqlx::query("update photos set owner_verdict = $2 where id = $1")
+ .bind(photo_id)
+ .bind(body.verdict.map(Verdict::as_str))
+ .execute(&state.db)
+ .await?;
+ if updated.rows_affected() == 0 {
+ return Err(ApiError::not_found());
+ }
+ let aggregate = feedback_aggregate(&state.db, photo_id).await?;
+ Ok(Json(serde_json::json!({ "ok": true, "aggregate": aggregate })))
+}
+
pub async fn reprocess(
State(state): State,
Path(photo_id): Path,
diff --git a/src/routes/xmp.rs b/src/routes/xmp.rs
new file mode 100644
index 0000000..3b2b703
--- /dev/null
+++ b/src/routes/xmp.rs
@@ -0,0 +1,252 @@
+use std::collections::{BTreeMap, BTreeSet, HashMap};
+
+use axum::extract::{Path, Query, State};
+use axum::http::header;
+use axum::response::{IntoResponse, Response};
+use chrono::Utc;
+use serde::Deserialize;
+use uuid::Uuid;
+
+use crate::error::{ApiError, ApiResult};
+use crate::models::{FeedbackAggregate, PhotoStatus};
+use crate::state::AppState;
+
+#[derive(Deserialize)]
+pub struct XmpQuery {
+ /// Comma-separated share ids; absent = all shares.
+ shares: Option,
+ /// Include the photographer's own feedback; defaults to true only when
+ /// no share filter is given.
+ own: Option,
+}
+
+#[derive(Default)]
+struct Sidecar {
+ agg: FeedbackAggregate,
+ tags: BTreeSet,
+}
+
+/// ZIP of XMP sidecars, one per photo that has feedback from the selected
+/// sources — drop them next to the RAWs and Capture One / Lightroom pick up
+/// rating, color label and keywords on sync.
+pub async fn album_xmp(
+ State(state): State,
+ Path(album_id): Path,
+ Query(query): Query,
+) -> ApiResult {
+ let album: Option<(String,)> = sqlx::query_as("select name from albums where id = $1")
+ .bind(album_id)
+ .fetch_optional(&state.db)
+ .await?;
+ let Some((album_name,)) = album else {
+ return Err(ApiError::not_found());
+ };
+
+ let share_filter: Option> = match &query.shares {
+ None => None,
+ Some(raw) => Some(
+ raw.split(',')
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .map(|s| {
+ Uuid::parse_str(s)
+ .map_err(|_| ApiError::bad_request(format!("invalid share id: {s}")))
+ })
+ .collect::, _>>()
+ .map(|mut ids| {
+ ids.sort();
+ ids.dedup();
+ ids
+ })?,
+ ),
+ };
+ let include_own = query.own.unwrap_or(share_filter.is_none());
+ if let Some(ids) = &share_filter {
+ if ids.is_empty() && !include_own {
+ return Err(ApiError::bad_request("select at least one feedback source"));
+ }
+ if !ids.is_empty() {
+ let (found,): (i64,) =
+ sqlx::query_as("select count(*) from shares where album_id = $1 and id = any($2)")
+ .bind(album_id)
+ .bind(ids)
+ .fetch_one(&state.db)
+ .await?;
+ if found as usize != ids.len() {
+ return Err(ApiError::not_found());
+ }
+ }
+ }
+
+ let photos: Vec<(Uuid, String, Option, Option)> = sqlx::query_as(
+ "select id, filename, owner_rating, owner_verdict
+ from photos where album_id = $1 and status = $2 order by filename",
+ )
+ .bind(album_id)
+ .bind(PhotoStatus::Ready.as_str())
+ .fetch_all(&state.db)
+ .await?;
+
+ let shares = share_filter.as_deref();
+ let (ratings, verdicts, tags) = tokio::try_join!(
+ super::albums::feedback_rows::(&state.db, super::albums::RATING_ROWS, album_id, shares),
+ super::albums::feedback_rows::(&state.db, super::albums::VERDICT_ROWS, album_id, shares),
+ super::albums::feedback_rows::(&state.db, super::albums::TAG_ROWS, album_id, shares),
+ )?;
+
+ let mut feedback: HashMap = HashMap::new();
+ for (photo_id, _, _, rating) in ratings {
+ feedback.entry(photo_id).or_default().agg.add_rating(rating);
+ }
+ for (photo_id, _, _, verdict) in verdicts {
+ feedback.entry(photo_id).or_default().agg.add_verdict(&verdict);
+ }
+ for (photo_id, _, _, tag) in tags {
+ feedback.entry(photo_id).or_default().tags.insert(tag);
+ }
+
+ // One sidecar per BASENAME: a RAW+JPEG pair shares its .xmp, so their
+ // feedback is merged — a disambiguated "name-2.xmp" would never be
+ // matched by any editor and its feedback silently lost.
+ let mut sidecars: BTreeMap = BTreeMap::new();
+ for (photo_id, filename, owner_rating, owner_verdict) in &photos {
+ let mut f = feedback.remove(photo_id).unwrap_or_default();
+ if include_own {
+ f.agg.add_owner(*owner_rating, owner_verdict.as_deref());
+ }
+ if f.agg.is_empty() && f.tags.is_empty() {
+ continue;
+ }
+ let base = filename.rsplit_once('.').map(|(b, _)| b).unwrap_or(filename);
+ let entry = sidecars.entry(format!("{base}.xmp")).or_default();
+ entry.agg.merge(&f.agg);
+ entry.tags.extend(f.tags);
+ }
+ let entries: Vec<(String, Vec)> = sidecars
+ .into_iter()
+ .map(|(name, f)| {
+ (
+ name,
+ xmp_document(
+ f.agg.top_rating.map(|r| r.clamp(1, 5)),
+ f.agg.label(),
+ &f.tags,
+ )
+ .into_bytes(),
+ )
+ })
+ .collect();
+
+ let zip = build_stored_zip(&entries);
+ let safe_name: String = album_name
+ .chars()
+ .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' })
+ .collect();
+ Ok((
+ [
+ (header::CONTENT_TYPE, "application/zip".to_string()),
+ (
+ header::CONTENT_DISPOSITION,
+ format!("attachment; filename=\"{safe_name}-xmp.zip\""),
+ ),
+ ],
+ zip,
+ )
+ .into_response())
+}
+
+fn escape_xml(value: &str) -> String {
+ value
+ .replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+}
+
+fn xmp_document(rating: Option, label: Option<&str>, tags: &BTreeSet) -> String {
+ let mut attrs = String::new();
+ if let Some(rating) = rating {
+ attrs.push_str(&format!(" xmp:Rating=\"{rating}\""));
+ }
+ if let Some(label) = label {
+ attrs.push_str(&format!(" xmp:Label=\"{label}\""));
+ }
+ let subject = if tags.is_empty() {
+ String::new()
+ } else {
+ let items: String = tags
+ .iter()
+ .map(|t| format!("{}", escape_xml(t)))
+ .collect();
+ format!("{items}")
+ };
+ format!(
+ "\n\
+ \n \
+ \n \
+ {subject}\n \
+ \n\
+ \n\
+ "
+ )
+}
+
+/// Minimal stored (uncompressed) in-memory ZIP — the entries are a handful
+/// of small text files, the streaming writer in zip.rs is for S3 payloads.
+fn build_stored_zip(entries: &[(String, Vec)]) -> Vec {
+ let (dos_time, dos_date) = super::zip::dos_datetime(Utc::now());
+ let mut out: Vec = Vec::new();
+ let mut central: Vec = Vec::new();
+ for (name, data) in entries {
+ let offset = out.len() as u32;
+ let crc = crc32fast::hash(data);
+ let name_bytes = name.as_bytes();
+ let size = data.len() as u32;
+
+ out.extend_from_slice(&0x04034b50u32.to_le_bytes());
+ out.extend_from_slice(&20u16.to_le_bytes());
+ out.extend_from_slice(&0x0800u16.to_le_bytes()); // UTF-8 names
+ out.extend_from_slice(&0u16.to_le_bytes()); // stored
+ out.extend_from_slice(&dos_time.to_le_bytes());
+ out.extend_from_slice(&dos_date.to_le_bytes());
+ out.extend_from_slice(&crc.to_le_bytes());
+ out.extend_from_slice(&size.to_le_bytes());
+ out.extend_from_slice(&size.to_le_bytes());
+ out.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
+ out.extend_from_slice(&0u16.to_le_bytes());
+ out.extend_from_slice(name_bytes);
+ out.extend_from_slice(data);
+
+ central.extend_from_slice(&0x02014b50u32.to_le_bytes());
+ central.extend_from_slice(&20u16.to_le_bytes());
+ central.extend_from_slice(&20u16.to_le_bytes());
+ central.extend_from_slice(&0x0800u16.to_le_bytes());
+ central.extend_from_slice(&0u16.to_le_bytes());
+ central.extend_from_slice(&dos_time.to_le_bytes());
+ central.extend_from_slice(&dos_date.to_le_bytes());
+ central.extend_from_slice(&crc.to_le_bytes());
+ central.extend_from_slice(&size.to_le_bytes());
+ central.extend_from_slice(&size.to_le_bytes());
+ central.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
+ central.extend_from_slice(&0u16.to_le_bytes());
+ central.extend_from_slice(&0u16.to_le_bytes());
+ central.extend_from_slice(&0u16.to_le_bytes());
+ central.extend_from_slice(&0u16.to_le_bytes());
+ central.extend_from_slice(&0u32.to_le_bytes());
+ central.extend_from_slice(&offset.to_le_bytes());
+ central.extend_from_slice(name_bytes);
+ }
+ let cd_offset = out.len() as u32;
+ out.extend_from_slice(¢ral);
+ let count = entries.len() as u16;
+ out.extend_from_slice(&0x06054b50u32.to_le_bytes());
+ out.extend_from_slice(&0u16.to_le_bytes());
+ out.extend_from_slice(&0u16.to_le_bytes());
+ out.extend_from_slice(&count.to_le_bytes());
+ out.extend_from_slice(&count.to_le_bytes());
+ out.extend_from_slice(&(central.len() as u32).to_le_bytes());
+ out.extend_from_slice(&cd_offset.to_le_bytes());
+ out.extend_from_slice(&0u16.to_le_bytes());
+ out
+}
diff --git a/src/routes/zip.rs b/src/routes/zip.rs
index 9088404..1d84408 100644
--- a/src/routes/zip.rs
+++ b/src/routes/zip.rs
@@ -122,7 +122,7 @@ fn unique_entry_name(used: &mut HashSet, filename: &str) -> String {
/// MS-DOS timestamp (2-second resolution, no timezone; years 1980+ only —
/// callers clamp earlier dates).
-fn dos_datetime(t: DateTime) -> (u16, u16) {
+pub(super) fn dos_datetime(t: DateTime) -> (u16, u16) {
let time = ((t.hour() as u16) << 11) | ((t.minute() as u16) << 5) | (t.second() as u16 / 2);
let date = (((t.year() - 1980) as u16) << 9) | ((t.month() as u16) << 5) | (t.day() as u16);
(time, date)