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:
2026-09-18 19:17:38 +07:00
parent 4057566a14
commit 07fbadcdc5
10 changed files with 558 additions and 150 deletions
+76 -22
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api, type Photo, type PhotoSlot, type User } from './api';
import { api, type Photo, type PhotoSlot, type Rating, type User } from './api';
import { useI18n } from './i18n/I18nProvider';
import { ACCENTS, FONT_SETS, useTheme, type FontSetId } from './theme/ThemeProvider';
import { AuthModal } from './ui/AuthModal';
@@ -72,12 +72,17 @@ interface Look {
meta: string;
photo: string;
filter: string;
// The subject this frame is rated under: `look:<TAG>` for a built-in look,
// `photo:<id>` for a contribution. Filled in where the reel is assembled.
rateKey: string;
}
// 35mm reel: eight looks over six sample negatives, each with its own CSS
// emulation. ponytail: CSS filters, not the CanvasKit pipeline — a landing page
// does not need a 12MP render; swap in real graded stills when we have them.
const REEL: Look[] = [
// The built-in looks carry no `rateKey` of their own — the reel stamps it from
// the tag when it is assembled, so the literal cannot drift from the key form.
const REEL: Omit<Look, 'rateKey'>[] = [
{ tag: '#KODAK_PORTRA_400', title: { en: 'Golden Hour Portrait', vi: 'Chân dung giờ vàng' }, meta: 'ISO 400 · GRAIN 35 · WARMTH +18', photo: SAMPLE(1015), filter: 'sepia(.16) saturate(1.18) contrast(1.04) brightness(1.04)' },
{ tag: '#FUJI_CLASSIC_CHROME', title: { en: 'Quiet Street, 7am', vi: 'Phố vắng, 7 giờ sáng' }, meta: 'ISO 200 · GRAIN 20 · WARMTH -6', photo: SAMPLE(1025), filter: 'saturate(.78) contrast(1.14) hue-rotate(-6deg)' },
{ tag: '#CYBER_NEON_88', title: { en: 'Neon Crossing', vi: 'Ngã tư neon' }, meta: 'ISO 1600 · GRAIN 45 · WARMTH +4', photo: SAMPLE(1035), filter: 'saturate(1.7) contrast(1.25) hue-rotate(268deg)' },
@@ -163,6 +168,7 @@ export function Landing() {
const [toast, setToast] = useState<string | null>(null);
const [user, setUser] = useState<User | null>(null);
const [photos, setPhotos] = useState<Photo[]>([]);
const [ratings, setRatings] = useState<Record<string, Rating>>({});
// Sign up / log in happens in a dialog on this page, not by a trip to the
// studio: the visitor keeps their scroll position and lands back on the reel.
const [auth, setAuth] = useState<'login' | 'signup' | null>(() => {
@@ -189,6 +195,7 @@ export function Landing() {
// everybody sees the result on the next load. A guest is sent to sign up.
const reload = useCallback(() => {
api.listPhotos().then((r) => setPhotos(r.photos)).catch(() => undefined);
api.ratings().then((r) => setRatings(r.ratings)).catch(() => undefined);
return api.me().then((r) => {
setUser(r.user);
return r.user;
@@ -212,11 +219,25 @@ export function Landing() {
}
}, [showToast, t]);
// One vote. The visitor may change their mind: the API upserts under the same
// address hash, so a second click replaces the first rather than stacking. A
// vote that never lands is silently dropped — it is not worth a toast.
const rate = useCallback(async (key: string, stars: number) => {
try {
const r = await api.rate(key, stars);
setRatings((prev) => ({ ...prev, [r.key]: r.rating }));
} catch {
// Offline or rate-limited: the row keeps the score it already had.
}
}, []);
// Contributed stills join the reel after the six built-in looks. Every frame
// is repeated once, so the marquee's -50% translation still loops seamlessly.
const reel = useMemo<Look[]>(
() => [
...REEL,
// A built-in look is rated under its own tag, so the eight samples carry
// scores just like a contributed frame does.
...REEL.map((l) => ({ ...l, rateKey: `look:${l.tag.replace(/^#/, '')}` })),
// The labels the uploader's studio stored — the tagline, the title and
// the ISO/grain line. A contribution from before those existed keeps the
// plain community defaults.
@@ -226,6 +247,7 @@ export function Landing() {
meta: p.meta ?? `#${p.id}`,
photo: api.photoUrl(p.id),
filter: 'none',
rateKey: `photo:${p.id}`,
})),
],
[photos],
@@ -571,25 +593,57 @@ export function Landing() {
/>
</div>
<div className="lp-track">
{[...reel, ...reel].map((look, i) => (
<article className="lp-frame" key={`${look.tag}-${i}`}>
<div className="lp-perf" aria-hidden="true">
<i /><i /><i /><i /><i /><i />
</div>
<div className="lp-shot">
<Shot src={look.photo} alt={c(look.title)} filter={look.filter} />
<span className="lp-grain" aria-hidden="true" />
<span className="lp-tagline">{look.tag}</span>
</div>
<div className="lp-frame-meta">
<b>{c(look.title)}</b>
<span>{look.meta}</span>
</div>
<div className="lp-perf" aria-hidden="true">
<i /><i /><i /><i /><i /><i />
</div>
</article>
))}
{[...reel, ...reel].map((look, i) => {
const score = ratings[look.rateKey] ?? { avg: 0, n: 0, mine: 0 };
return (
<article className="lp-frame" key={`${look.tag}-${i}`}>
<div className="lp-perf" aria-hidden="true">
<i /><i /><i /><i /><i /><i />
</div>
<div className="lp-shot">
<Shot src={look.photo} alt={c(look.title)} filter={look.filter} />
<span className="lp-grain" aria-hidden="true" />
<span className="lp-tagline">{look.tag}</span>
</div>
<div className="lp-frame-meta">
<b>{c(look.title)}</b>
<span>{look.meta}</span>
{/* The tally first, then the row that changes it: a visitor
reads the score, then casts one of their own. Clicking
another star replaces the vote instead of adding one. */}
<div className="lp-rate" data-key={`lp-rate-${look.rateKey}`}>
<b className="lp-rate-index" data-key={`lp-rate-index-${look.rateKey}`}>
{score.avg.toFixed(1)}
</b>
<span
className="lp-stars"
role="group"
aria-label={c({ en: 'Rate this frame', vi: 'Đánh giá khung này' })}
>
{[1, 2, 3, 4, 5].map((n) => (
<button
type="button"
key={n}
className={`lp-star${score.mine >= n ? ' on' : ''}`}
data-key={`lp-star-${look.rateKey}-${n}`}
aria-label={`${n}/5`}
onClick={() => void rate(look.rateKey, n)}
>
★
</button>
))}
</span>
<span className="lp-rate-count" data-key={`lp-rate-count-${look.rateKey}`}>
({score.n})
</span>
</div>
</div>
<div className="lp-perf" aria-hidden="true">
<i /><i /><i /><i /><i /><i />
</div>
</article>
);
})}
</div>
</section>