web: star ratings on the reel, and PICTURES becomes an album browser
The landing strip now carries a score: each look and each contributed frame shows an average, five stars the visitor can press, and how many votes it has. Votes are keyed photo:<id> or look:<TAG> and one visitor has one vote per key, so pressing a second star moves a score instead of stacking one. The API is public and rate-limited; look: scores survive a cleared pool, photo: scores are pruned with their photo. PICTURES was four destination rows; it is now an album per uploader with a search box, a recipe/rating/newest sort, a minimum-star filter, a big preview and a filmstrip of thumbnails. Deleting and slot picking still live in the big box.
This commit is contained in:
+229
-110
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api, type AdminPhoto, type AdminUser, type PhotoSlot } from './api';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api, type AdminPhoto, type AdminUser, type PhotoSlot, type Rating } from './api';
|
||||
import { useI18n } from './i18n/I18nProvider';
|
||||
import { Profile } from './ui/Profile';
|
||||
import { QrCode } from './ui/QrCode';
|
||||
@@ -10,7 +10,9 @@ import type { MsgKey } from './i18n/vi';
|
||||
// the pane for the chosen section.
|
||||
// Profile — the signed-in account's own picture/email/password
|
||||
// User account — every account: its name, email, picture and contribution count
|
||||
// Pictures — every photo in the system, with the slot that shows it
|
||||
// Pictures — one album per user: click an album for its strip of
|
||||
// thumbnails, click a thumbnail for the full frame and the
|
||||
// landing sections it is allowed into
|
||||
// Stats — the visitor counter: views, clicks and their breakdowns
|
||||
// Close — leaves the frame and goes back to the landing page
|
||||
// The frame holds no privilege of its own — the API answers 403 unless the
|
||||
@@ -45,7 +47,15 @@ export function Admin() {
|
||||
const [photos, setPhotos] = useState<AdminPhoto[]>([]);
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [tab, setTab] = useState<Tab>('pictures');
|
||||
const [owner, setOwner] = useState<string | null>(null);
|
||||
// The album browser: which user's album is open ('all' is the opening view),
|
||||
// which frame is up in the big box, and the three filters over the album and
|
||||
// its strip.
|
||||
const [album, setAlbum] = useState<number | 'all'>('all');
|
||||
const [sel, setSel] = useState<number | null>(null);
|
||||
const [nameQuery, setNameQuery] = useState('');
|
||||
const [sort, setSort] = useState<'newest' | 'recipe' | 'rating'>('newest');
|
||||
const [minStars, setMinStars] = useState(0);
|
||||
const [ratings, setRatings] = useState<Record<string, Rating>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
const [picked, setPicked] = useState<number[]>([]);
|
||||
@@ -58,9 +68,10 @@ export function Admin() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [p, u] = await Promise.all([api.adminListPhotos(), api.adminListUsers()]);
|
||||
const [p, u, r] = await Promise.all([api.adminListPhotos(), api.adminListUsers(), api.ratings()]);
|
||||
setPhotos(p.photos);
|
||||
setUsers(u.users);
|
||||
setRatings(r.ratings);
|
||||
setState('ready');
|
||||
} catch {
|
||||
setState('forbidden');
|
||||
@@ -86,9 +97,10 @@ export function Admin() {
|
||||
};
|
||||
|
||||
const refreshPhotos = async () => {
|
||||
const [p, u] = await Promise.all([api.adminListPhotos(), api.adminListUsers()]);
|
||||
const [p, u, r] = await Promise.all([api.adminListPhotos(), api.adminListUsers(), api.ratings()]);
|
||||
setPhotos(p.photos);
|
||||
setUsers(u.users);
|
||||
setRatings(r.ratings);
|
||||
};
|
||||
|
||||
const upload = (file: File) =>
|
||||
@@ -129,9 +141,11 @@ export function Admin() {
|
||||
});
|
||||
};
|
||||
|
||||
const showOwner = (email: string) => {
|
||||
setOwner(email);
|
||||
// From the users table: open that account's album and put its newest frame up
|
||||
// in the big box, rather than filtering a flat pool of every upload.
|
||||
const showOwner = (userId: number) => {
|
||||
setTab('pictures');
|
||||
openAlbum(userId);
|
||||
};
|
||||
|
||||
// Block/unblock and remove/restore share one route; the list is reloaded
|
||||
@@ -168,7 +182,51 @@ export function Admin() {
|
||||
});
|
||||
};
|
||||
|
||||
const shown = owner ? photos.filter((p) => p.email === owner) : photos;
|
||||
// A frame's score, from the same public tally the landing draws. Whitespace
|
||||
// in the name box is not a filter, so `query` is trimmed once for both uses.
|
||||
const score = (p: AdminPhoto) => ratings[`photo:${p.id}`]?.avg ?? 0;
|
||||
const query = nameQuery.trim().toLowerCase();
|
||||
|
||||
// Albums: one per account that owns a photo, alphabetical so the column reads
|
||||
// as a list of names. The name box narrows the column, not the strip.
|
||||
const albums = useMemo(() => {
|
||||
const map = new Map<number, { userId: number; email: string; n: number }>();
|
||||
for (const p of photos) {
|
||||
const a = map.get(p.userId) ?? { userId: p.userId, email: p.email, n: 0 };
|
||||
a.n += 1;
|
||||
map.set(p.userId, a);
|
||||
}
|
||||
return [...map.values()].sort((a, b) => a.email.localeCompare(b.email));
|
||||
}, [photos]);
|
||||
const shownAlbums = query ? albums.filter((a) => a.email.toLowerCase().includes(query)) : albums;
|
||||
|
||||
// The open album's strip: the name filter still narrows the list, the rating
|
||||
// filter drops frames below the bar, and the sort orders what is left.
|
||||
const strip = useMemo(() => {
|
||||
const base = (album === 'all' ? photos : photos.filter((p) => p.userId === album)).filter(
|
||||
(p) => !query || p.email.toLowerCase().includes(query),
|
||||
);
|
||||
const list = minStars > 0 ? base.filter((p) => score(p) >= minStars) : base;
|
||||
const sorted = [...list];
|
||||
if (sort === 'recipe') sorted.sort((a, b) => (a.recipeName ?? '').localeCompare(b.recipeName ?? ''));
|
||||
else if (sort === 'rating') sorted.sort((a, b) => score(b) - score(a));
|
||||
else sorted.sort((a, b) => b.id - a.id);
|
||||
return sorted;
|
||||
// `score` reads `ratings`, which is in the deps.
|
||||
}, [photos, album, minStars, sort, query, ratings]);
|
||||
|
||||
// The frame in the big box. `sel` names it, but a stale id (the photo was just
|
||||
// deleted, or a filter moved on) falls back to the top of the strip so the box
|
||||
// is never pointlessly empty.
|
||||
const current = photos.find((p) => p.id === sel) ?? strip[0] ?? null;
|
||||
|
||||
// Opening an album puts its newest frame up, so the big box fills on the same
|
||||
// click that fills the strip.
|
||||
const openAlbum = (id: number | 'all') => {
|
||||
setAlbum(id);
|
||||
const first = photos.filter((p) => id === 'all' || p.userId === id).sort((a, b) => b.id - a.id)[0];
|
||||
setSel(first ? first.id : null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="adm">
|
||||
@@ -248,114 +306,175 @@ export function Admin() {
|
||||
<>
|
||||
<p className="hint adm-sub">{t('adm.subtitle')}</p>
|
||||
<p className="hint adm-sub">{t('adm.slotHint')}</p>
|
||||
{owner ? (
|
||||
<p className="hint adm-sub adm-filter">
|
||||
{owner}
|
||||
<button type="button" className="btn" data-key="adm-filter-all" onClick={() => setOwner(null)}>
|
||||
{t('adm.showAll')}
|
||||
</button>
|
||||
</p>
|
||||
) : null}
|
||||
{shown.length === 0 ? <p className="hint adm-note">{t('adm.empty')}</p> : null}
|
||||
{photos.length === 0 ? <p className="hint adm-note">{t('adm.empty')}</p> : null}
|
||||
<div className="adm-cols">
|
||||
{/* One album per account, plus ALL. The list scrolls on its
|
||||
own, so the strip beside it stays put however many
|
||||
accounts there are. */}
|
||||
<div className="adm-col">
|
||||
<h3 className="adm-row-title">{t('adm.pool')}</h3>
|
||||
<div className="adm-grid">
|
||||
{shown.map((p) => (
|
||||
<article className="adm-card" key={p.id} data-key={`adm-photo-${p.id}`}>
|
||||
<img src={api.photoUrl(p.id)} alt="" loading="lazy" />
|
||||
<div className="adm-meta">
|
||||
<b>#{p.id}</b>
|
||||
{/* The labels the uploader's studio stored, when it
|
||||
sent any — the same ones the reel draws. */}
|
||||
{p.tag ? <span>{p.tag}</span> : null}
|
||||
{p.title ? <span>{p.title}</span> : null}
|
||||
{p.meta ? <span>{p.meta}</span> : null}
|
||||
<span>{t('adm.owner')}: {p.email}</span>
|
||||
<span>{t('adm.uploaded')}: {new Date(p.createdAt).toLocaleString()}</span>
|
||||
<span>{t('adm.size')}: {Math.round(p.bytes / 1024)} KB · {p.mime}</span>
|
||||
</div>
|
||||
{/* One checkbox per destination, all three on one
|
||||
line: tick as many as the photo should appear in,
|
||||
untick the last one to take it off the landing. */}
|
||||
<div className="adm-choices">
|
||||
{DESTINATIONS.map((d) => (
|
||||
<label className="adm-choice" key={d.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-key={`adm-pick-${d.id}-${p.id}`}
|
||||
checked={p.slots.includes(d.id)}
|
||||
// A photo with no stored look has no file to
|
||||
// hand out, so the QR box would only ever draw
|
||||
// an empty card.
|
||||
disabled={busy || (d.id === 'qr' && !p.hasPreset)}
|
||||
title={d.id === 'qr' && !p.hasPreset ? t('adm.noPreset') : undefined}
|
||||
onChange={() =>
|
||||
void setSlots(
|
||||
p.id,
|
||||
p.slots.includes(d.id)
|
||||
? p.slots.filter((x) => x !== d.id)
|
||||
: [...p.slots, d.id],
|
||||
)
|
||||
}
|
||||
/>
|
||||
{t(d.key)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn adm-danger"
|
||||
data-key={`adm-del-${p.id}`}
|
||||
disabled={busy}
|
||||
onClick={() => void remove(p.id)}
|
||||
>
|
||||
{t('adm.delete')}
|
||||
</button>
|
||||
</article>
|
||||
<h3 className="adm-row-title">{t('adm.albums')}</h3>
|
||||
<input
|
||||
type="search"
|
||||
className="adm-input"
|
||||
data-key="adm-filter-name"
|
||||
placeholder={t('adm.filterName')}
|
||||
value={nameQuery}
|
||||
onChange={(e) => setNameQuery(e.target.value)}
|
||||
/>
|
||||
<div className="adm-filters">
|
||||
<select
|
||||
className="adm-input"
|
||||
data-key="adm-sort"
|
||||
aria-label={t('adm.sort')}
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as 'newest' | 'recipe' | 'rating')}
|
||||
>
|
||||
<option value="newest">{t('adm.sortNewest')}</option>
|
||||
<option value="recipe">{t('adm.sortRecipe')}</option>
|
||||
<option value="rating">{t('adm.sortRating')}</option>
|
||||
</select>
|
||||
<select
|
||||
className="adm-input"
|
||||
data-key="adm-filter-rating"
|
||||
aria-label={t('adm.rating')}
|
||||
value={String(minStars)}
|
||||
onChange={(e) => setMinStars(Number(e.target.value))}
|
||||
>
|
||||
<option value="0">{t('adm.ratingAll')}</option>
|
||||
{[5, 4, 3, 2, 1].map((n) => (
|
||||
<option key={n} value={String(n)}>
|
||||
{t('adm.ratingAtLeast', { n })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="adm-album-list" data-key="adm-albums">
|
||||
<button
|
||||
type="button"
|
||||
className={`adm-album${album === 'all' ? ' on' : ''}`}
|
||||
data-key="adm-album-all"
|
||||
onClick={() => openAlbum('all')}
|
||||
>
|
||||
<span className="adm-album-name">{t('adm.albumAll')}</span>
|
||||
<span className="adm-album-count">{photos.length}</span>
|
||||
</button>
|
||||
{shownAlbums.map((a) => (
|
||||
<button
|
||||
key={a.userId}
|
||||
type="button"
|
||||
className={`adm-album${album === a.userId ? ' on' : ''}`}
|
||||
data-key={`adm-album-${a.userId}`}
|
||||
onClick={() => openAlbum(a.userId)}
|
||||
>
|
||||
<span className="adm-album-name">{a.email}</span>
|
||||
<span className="adm-album-count">{a.n}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="adm-col">
|
||||
{DESTINATIONS.map((d) => {
|
||||
const inRow = shown.filter((p) => p.slots.includes(d.id));
|
||||
return (
|
||||
<section className="adm-row" key={d.id} data-key={`adm-row-${d.id}`}>
|
||||
<h3 className="adm-row-title">
|
||||
{t(d.key)}
|
||||
<span className="adm-row-count">{inRow.length}</span>
|
||||
</h3>
|
||||
{inRow.length === 0 ? (
|
||||
<p className="hint adm-row-empty">{t('adm.rowEmpty')}</p>
|
||||
) : (
|
||||
<div className="adm-grid">
|
||||
{inRow.map((p) => (
|
||||
<figure className="adm-thumb" key={p.id} data-key={`adm-thumb-${p.id}`}>
|
||||
<img src={api.photoUrl(p.id)} alt="" loading="lazy" />
|
||||
{/* The QR section is the one whose artefact is
|
||||
not the photo: it is the link that hands
|
||||
the look back to the app. A photo uploaded
|
||||
without a look has none to hand out. */}
|
||||
{d.id === 'qr' ? (
|
||||
<div className="adm-qr">
|
||||
{p.hasPreset ? (
|
||||
<QrCode
|
||||
url={new URL(api.presetUrl(p.id), window.location.origin).href}
|
||||
size={120}
|
||||
/>
|
||||
) : (
|
||||
<span>{t('adm.noPreset')}</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<figcaption>#{p.id}</figcaption>
|
||||
</figure>
|
||||
{/* The frame that is up: the full picture, its labels and
|
||||
the sections it may appear in — the same checkboxes the
|
||||
flat pool carried, now on the chosen frame. */}
|
||||
<section className="adm-big" data-key="adm-big">
|
||||
{current ? (
|
||||
<>
|
||||
<img className="adm-big-img" data-key="adm-big-img" src={api.photoUrl(current.id)} alt="" />
|
||||
<div className="adm-big-side">
|
||||
<div className="adm-meta">
|
||||
<b>#{current.id}</b>
|
||||
{current.tag ? <span>{current.tag}</span> : null}
|
||||
{current.title ? <span>{current.title}</span> : null}
|
||||
{current.meta ? <span>{current.meta}</span> : null}
|
||||
{current.recipeName ? (
|
||||
<span>
|
||||
{t('adm.recipe')}: {current.recipeName}
|
||||
</span>
|
||||
) : null}
|
||||
<span>
|
||||
{t('adm.rating')}: {score(current).toFixed(1)} (
|
||||
{ratings[`photo:${current.id}`]?.n ?? 0})
|
||||
</span>
|
||||
<span>
|
||||
{t('adm.owner')}: {current.email}
|
||||
</span>
|
||||
<span>
|
||||
{t('adm.uploaded')}: {new Date(current.createdAt).toLocaleString()}
|
||||
</span>
|
||||
<span>
|
||||
{t('adm.size')}: {Math.round(current.bytes / 1024)} KB · {current.mime}
|
||||
</span>
|
||||
</div>
|
||||
<div className="adm-choices">
|
||||
{DESTINATIONS.map((d) => (
|
||||
<label className="adm-choice" key={d.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-key={`adm-pick-${d.id}-${current.id}`}
|
||||
checked={current.slots.includes(d.id)}
|
||||
// A photo with no stored look has no file to
|
||||
// hand out, so the QR box would only ever
|
||||
// draw an empty card.
|
||||
disabled={busy || (d.id === 'qr' && !current.hasPreset)}
|
||||
title={d.id === 'qr' && !current.hasPreset ? t('adm.noPreset') : undefined}
|
||||
onChange={() =>
|
||||
void setSlots(
|
||||
current.id,
|
||||
current.slots.includes(d.id)
|
||||
? current.slots.filter((x) => x !== d.id)
|
||||
: [...current.slots, d.id],
|
||||
)
|
||||
}
|
||||
/>
|
||||
{t(d.key)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
{/* The QR section's real artefact is the code, not
|
||||
the photo, so it is drawn here when the frame is
|
||||
in that section and has a look to hand out. */}
|
||||
{current.hasPreset && current.slots.includes('qr') ? (
|
||||
<div className="adm-qr adm-qr-inline" data-key={`adm-qr-${current.id}`}>
|
||||
<QrCode
|
||||
url={new URL(api.presetUrl(current.id), window.location.origin).href}
|
||||
size={112}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="btn adm-danger"
|
||||
data-key={`adm-del-${current.id}`}
|
||||
disabled={busy}
|
||||
onClick={() => void remove(current.id)}
|
||||
>
|
||||
{t('adm.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="hint adm-row-empty">{t('adm.bigEmpty')}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* The album's strip: one thumbnail per frame, in the order
|
||||
the filters and the sort chose. */}
|
||||
<div className="adm-filmstrip" data-key="adm-filmstrip">
|
||||
{strip.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
className={`adm-thumb${current?.id === p.id ? ' on' : ''}`}
|
||||
data-key={`adm-thumb-${p.id}`}
|
||||
onClick={() => setSel(p.id)}
|
||||
>
|
||||
<img src={api.photoUrl(p.id)} alt="" loading="lazy" />
|
||||
<span className="adm-thumb-cap">#{p.id}</span>
|
||||
</button>
|
||||
))}
|
||||
{strip.length === 0 ? <p className="hint adm-row-empty">{t('adm.empty')}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -458,7 +577,7 @@ export function Admin() {
|
||||
type="button"
|
||||
className="btn"
|
||||
data-key={`adm-user-photos-${u.id}`}
|
||||
onClick={() => showOwner(u.email)}
|
||||
onClick={() => showOwner(u.id)}
|
||||
>
|
||||
{t('adm.viewPhotos')}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user