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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user