diff --git a/docker/backend/src/db.ts b/docker/backend/src/db.ts index 01c17a1..bd47fd8 100644 --- a/docker/backend/src/db.ts +++ b/docker/backend/src/db.ts @@ -799,6 +799,31 @@ export function rateLook(key: string, visitor: string, stars: number): void { ).run(key, visitor, stars, new Date().toISOString()); } +// What a window of votes left on one photo — the landing's award column reads +// the best of these. `at` is the window's most recent vote, the last tie-break. +export type PhotoTally = { id: number; avg: number; n: number; at: string }; + +// The best-rated consenting photos a window of votes names, most deserving +// first. Only `photo:` subjects count: a vote on a built-in look is not a +// contribution and has no frame to show. The key carries the id, so the join is +// a cast of everything past `photo:` — a key that casts to no row simply drops +// out of the join. Order: the mean, then how many votes stand behind it, then +// the most recent one, so a tie is settled by evidence and two loads of the page +// cannot disagree. +export function topRatedPhotos(since: string, limit: number): PhotoTally[] { + const rows = db + .prepare( + `SELECT photos.id AS id, AVG(ratings.stars) AS avg, COUNT(*) AS n, MAX(ratings.at) AS at + FROM ratings JOIN photos ON photos.id = CAST(substr(ratings.key, 7) AS INTEGER) + WHERE ratings.key LIKE 'photo:%' AND ratings.at >= ? AND photos.consent = 1 + GROUP BY photos.id + ORDER BY avg DESC, n DESC, at DESC, id DESC + LIMIT ?`, + ) + .all(since, limit) as PhotoTally[]; + return rows.map((r) => ({ id: r.id, avg: Math.round(r.avg * 100) / 100, n: r.n, at: r.at })); +} + // Every subject's tally, keyed by subject. `mine` is this visitor's own vote, 0 // when they have not rated it — the landing draws the star row from it. export function ratingsFor(visitor: string): Record { diff --git a/docker/backend/src/server.ts b/docker/backend/src/server.ts index 4a561e1..fa9200b 100644 --- a/docker/backend/src/server.ts +++ b/docker/backend/src/server.ts @@ -49,6 +49,7 @@ import { setUserBlocked, setUserPassword, setUserRemoved, + topRatedPhotos, updateRecipe, updateUserEmail, userAvatar, @@ -428,6 +429,24 @@ app.post('/api/ratings', async (req, reply) => { return reply.status(200).send({ key, rating: ratingsFor(visitor)[key] }); }); +// The landing hero's award column: the day's best-rated contribution and the +// week's, read off the same votes the strip casts — a window instead of a +// lifetime. The windows are the server's clock in UTC, so every visitor and +// every cache agrees on which frame is today's; the week starts on Monday, the +// ISO week the page's own copy implies. Public like the tally it is drawn from. +const HIGHLIGHT_LIMIT = 5; +app.get('/api/highlights', async () => { + const now = new Date(); + const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + const monday = midnight - ((new Date(midnight).getUTCDay() + 6) % 7) * 86_400_000; + return { + highlights: { + day: topRatedPhotos(new Date(midnight).toISOString(), HIGHLIGHT_LIMIT), + week: topRatedPhotos(new Date(monday).toISOString(), HIGHLIGHT_LIMIT), + }, + }; +}); + app.post('/api/auth/signup', async (req, reply) => { const b = bodyOf(req); if (!b) return reply.status(400).send({ error: 'invalid body' }); diff --git a/docker/backend/test/security.mjs b/docker/backend/test/security.mjs index a56c359..b2e2576 100644 --- a/docker/backend/test/security.mjs +++ b/docker/backend/test/security.mjs @@ -632,11 +632,33 @@ try { const throwawayId = throwaway.body?.photo?.id; check('the throwaway upload lands', Number.isInteger(throwawayId), JSON.stringify(throwaway.body)); await vote('10.9.9.4', `photo:${throwawayId}`, 5); + + // ---- the hero's award windows ------------------------------------------- + // The landing's award column reads the same votes over a window: what was + // cast today, and what was cast this week. A vote cast a moment ago is in + // both. A subject that is not a photo — a built-in look — is never a frame. + const highlights = (await (await fetch(`${BASE}/highlights`)).json()).highlights ?? {}; + const day = (highlights.day ?? []).find((r) => r.id === throwawayId); + const week = (highlights.week ?? []).find((r) => r.id === throwawayId); + check('a vote lands in today’s window', day?.avg === 5 && day?.n === 1, JSON.stringify(day)); + check('and in this week’s window too', week?.avg === 5 && week?.n === 1, JSON.stringify(week)); + check( + 'a look that is not a photo is never an award', + [...(highlights.day ?? []), ...(highlights.week ?? [])].every((r) => Number.isInteger(r.id)), + JSON.stringify(highlights), + ); + await user.req(`/photos/${throwawayId}`, { method: 'DELETE' }); check( 'a deleted photo takes its votes with it', !Object.hasOwn(await readRatings('10.9.9.4'), `photo:${throwawayId}`), ); + const afterDelete = (await (await fetch(`${BASE}/highlights`)).json()).highlights ?? {}; + check( + 'and off the award column', + ![...(afterDelete.day ?? []), ...(afterDelete.week ?? [])].some((r) => r.id === throwawayId), + JSON.stringify(afterDelete), + ); // ---- pre-existing guarantees still hold --------------------------------- const foreignRecipe = await user.req('/recipes/1', { method: 'DELETE' }); diff --git a/docker/frontend/src/Landing.tsx b/docker/frontend/src/Landing.tsx index 0229219..39f740c 100644 --- a/docker/frontend/src/Landing.tsx +++ b/docker/frontend/src/Landing.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { api, type Photo, type PhotoSlot, type Rating, type User } from './api'; +import { api, type Highlights, type Photo, type PhotoSlot, type Rating, type User } from './api'; import { clearSession } from './engine/session'; import { useI18n } from './i18n/I18nProvider'; import { ACCENTS, FONT_SETS, useTheme, type FontSetId } from './theme/ThemeProvider'; @@ -22,6 +22,14 @@ const STORE_LINKS = { type Txt = { en: string; vi: string }; +// The hero's award column: how long one frame stands before the next, and how +// long the two fades take. The swap is a crossfade — the frame leaving fades out +// over AWARD_FADE_MS while the one arriving fades in over the same — so a step +// reads as one movement, and the clock restarts whenever a visitor steps it by +// hand (see the effect). +const AWARD_MS = 5000; +const AWARD_FADE_MS = 1000; + // No page can really stop a save — the bytes are already on the machine. What it // can do is refuse to hand over more pixels than it draws: the photo is decoded, // redrawn at the size of its box times the screen's pixel ratio (2x at most) and @@ -219,6 +227,10 @@ export function Landing() { const [user, setUser] = useState(null); const [photos, setPhotos] = useState([]); const [ratings, setRatings] = useState>({}); + const [highlights, setHighlights] = useState({ day: [], week: [] }); + // Which award frame the hero shows. It steps forward forever and wraps modulo + // the list, so a reload that changes the list cannot land it out of range. + const [awardIdx, setAwardIdx] = useState(0); // 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>(() => { @@ -249,6 +261,7 @@ export function Landing() { const reload = useCallback(() => { api.listPhotos().then((r) => setPhotos(r.photos)).catch(() => undefined); api.ratings().then((r) => setRatings(r.ratings)).catch(() => undefined); + api.highlights().then((r) => setHighlights(r.highlights)).catch(() => undefined); return api.me().then((r) => { setUser(r.user); return r.user; @@ -372,6 +385,36 @@ export function Landing() { }; }, [photos]); + // The hero's award column: today's best-rated contributions first, then this + // week's, each frame carrying the award its own votes won — a frame that took + // both is the day's. The labels and the file come from the public listing, so + // a tally whose photo is no longer in it is dropped rather than shown bare. + const awards = useMemo(() => { + const by = new Map(photos.map((p) => [p.id, p])); + const seen = new Set(); + const out: { photo: Photo; avg: number; n: number; kind: 'day' | 'week' }[] = []; + for (const [kind, list] of [['day', highlights.day], ['week', highlights.week]] as const) { + for (const tally of list) { + const photo = by.get(tally.id); + if (!photo || seen.has(tally.id)) continue; + seen.add(tally.id); + out.push({ photo, avg: tally.avg, n: tally.n, kind }); + } + } + return out; + }, [photos, highlights]); + + // The column steps itself: five seconds a frame, the clock restarted by a + // manual step (the index is a dependency) and never started at all for a list + // with nothing to step to. + useEffect(() => { + if (awards.length < 2) return; + const timer = setTimeout(() => setAwardIdx((i) => i + 1), AWARD_MS); + return () => clearTimeout(timer); + }, [awards.length, awardIdx]); + + const award = awards.length ? awards[awardIdx % awards.length] : null; + // The QR card cycles its stills the way the previews do, but the payload has // to follow: the card is rendered from the one on screen. const curQr = slotPhoto.qrs.length ? slotPhoto.qrs[(slotPhoto.qrStart + qrIdx) % slotPhoto.qrs.length] : null; @@ -637,27 +680,77 @@ export function Landing() { ) : null} - {/* 2 — hero */} + {/* 2 — hero. The copy, and beside it the award column: the day's and the + week's best-rated contributions from the reel, stepped by hand or by + itself. The split only exists while there is an award to show, so a + page nobody has voted on keeps its one-column hero. */}
-
- {c({ en: '● NEXT-GEN COLOR RECIPES 4.0 — LIVE LUTS', vi: '● CÔNG THỨC MÀU THẾ HỆ MỚI 4.0 — LUT TRỰC TIẾP' })} -

{c({ en: 'Capture & Grade Photos With Exclusive Color Recipes', vi: 'Chụp & chỉnh màu ảnh bằng công thức màu độc quyền' })}

-

- {c({ - en: 'RecipesCam reproduces Kodak and Fuji film colour — tone curves, grain structure and halation — then applies it to your own photos. Same pipeline on the phone and in the browser, no upload, nothing leaves your machine.', - vi: 'RecipesCam tái tạo màu phim Kodak và Fuji — đường cong tông màu, cấu trúc hạt và quầng sáng — rồi áp lên ảnh của bạn. Cùng một pipeline trên điện thoại và trên trình duyệt, không tải lên, không gì rời khỏi máy bạn.', - })} -

- -
-
250+{c({ en: 'COLOR RECIPES', vi: 'CÔNG THỨC MÀU' })}
-
1M+{c({ en: 'DOWNLOADS', vi: 'LƯỢT TẢI' })}
-
4.9★{c({ en: 'STORE RATING', vi: 'ĐIỂM CỬA HÀNG' })}
+
+
+ {c({ en: '● NEXT-GEN COLOR RECIPES 4.0 — LIVE LUTS', vi: '● CÔNG THỨC MÀU THẾ HỆ MỚI 4.0 — LUT TRỰC TIẾP' })} +

{c({ en: 'Capture & Grade Photos With Exclusive Color Recipes', vi: 'Chụp & chỉnh màu ảnh bằng công thức màu độc quyền' })}

+

+ {c({ + en: 'RecipesCam reproduces Kodak and Fuji film colour — tone curves, grain structure and halation — then applies it to your own photos. Same pipeline on the phone and in the browser, no upload, nothing leaves your machine.', + vi: 'RecipesCam tái tạo màu phim Kodak và Fuji — đường cong tông màu, cấu trúc hạt và quầng sáng — rồi áp lên ảnh của bạn. Cùng một pipeline trên điện thoại và trên trình duyệt, không tải lên, không gì rời khỏi máy bạn.', + })} +

+ +
+
250+{c({ en: 'COLOR RECIPES', vi: 'CÔNG THỨC MÀU' })}
+
1M+{c({ en: 'DOWNLOADS', vi: 'LƯỢT TẢI' })}
+
4.9★{c({ en: 'STORE RATING', vi: 'ĐIỂM CỬA HÀNG' })}
+
+ {award ? ( + + ) : null}
diff --git a/docker/frontend/src/api.ts b/docker/frontend/src/api.ts index e0f81e5..424fb61 100644 --- a/docker/frontend/src/api.ts +++ b/docker/frontend/src/api.ts @@ -92,6 +92,24 @@ export interface Rating { mine: number; } +// One frame of the landing hero's award column: what a window of votes left on +// one photo. `at` is the most recent of them, the API's own last tie-break; the +// labels and the file come from the public listing, keyed by `id`. +export interface PhotoTally { + id: number; + avg: number; + n: number; + at: string; +} + +// The two windows the award column reads: `day` is today's votes, `week` this +// ISO week's — so today's winners appear in both, and the column shows them +// once, as the day's. +export interface Highlights { + day: PhotoTally[]; + week: PhotoTally[]; +} + // One account as /api/admin/users reports it. `avatar` is the ready-made URL // (or null), same shape as on the signed-in user. export interface AdminUser { @@ -224,6 +242,10 @@ export const api = { ratings: () => call<{ ratings: Record }>('/ratings'), rate: (key: string, stars: number) => call<{ key: string; rating: Rating }>('/ratings', { method: 'POST', body: JSON.stringify({ key, stars }) }), + // The hero's award column: the best-rated contributions of the UTC day and of + // the ISO week, most deserving first. Public, same as the tally it is read + // off; the frames' own labels come from the public listing. + highlights: () => call<{ highlights: Highlights }>('/highlights'), uploadPhoto: async (file: File, labels?: PhotoLabels, opts?: PhotoMetaOpts) => { const res = await uploadBytes(`/api/photos${photoQuery(labels, opts)}`, file, MAX_PHOTO_DIM, MAX_PHOTO_UPLOAD); const body = await readJson(res); diff --git a/docker/frontend/src/styles/landing.css b/docker/frontend/src/styles/landing.css index 15c7c24..7d4f2a5 100644 --- a/docker/frontend/src/styles/landing.css +++ b/docker/frontend/src/styles/landing.css @@ -266,6 +266,54 @@ html { scroll-behavior: smooth; scroll-padding-top: 116px; } color: transparent; } +/* The award column beside the hero copy: shown only when today's or this + week's best-rated photo exists, so the headline keeps its full width when + nobody has voted yet. */ +.lp-hero-split { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 46px; align-items: center; } +.lp-award { + border: 1px solid var(--lp-line); + background: var(--lp-card); + border-radius: 18px; + padding: 14px; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.35); +} +.lp-award-kind { + display: inline-block; + margin-bottom: 10px; + padding: 5px 11px; + border-radius: 999px; + border: 1px solid var(--accent-line); + background: var(--accent-soft); + color: var(--lp-ink-amber); + font-family: var(--lp-mono); + font-size: 10px; + letter-spacing: 0.14em; +} +.lp-award-view { position: relative; aspect-ratio: 4 / 5; border-radius: 14px; overflow: hidden; background: #000; } +/* Both stills stay stacked; the outgoing one fades out over AWARD_FADE_MS + while the incoming fades in over the same span — a true crossfade. */ +.lp-award-slide { position: absolute; inset: 0; opacity: 0; transition: opacity 1s ease; } +.lp-award-slide.on { opacity: 1; } +.lp-award-slide img { width: 100%; height: 100%; object-fit: cover; display: block; } +.lp-award-score { + position: absolute; + left: 13px; + bottom: 13px; + z-index: 1; + display: flex; + align-items: center; + gap: 7px; + font-family: var(--lp-mono); + font-size: 11px; + color: var(--lp-text); + text-shadow: 0 1px 8px #000; +} +.lp-award-score b { font-size: 13px; color: var(--lp-amber); } +.lp-award-score span { color: var(--lp-muted); letter-spacing: 0.06em; } +.lp-award-meta { margin-top: 11px; display: flex; flex-direction: column; gap: 3px; } +.lp-award-meta b { font-size: 13px; font-weight: 600; } +.lp-award-meta span { font-family: var(--lp-mono); font-size: 9.5px; color: var(--lp-muted); letter-spacing: 0.06em; } + /* --- 3. film strip marquee ---------------------------------------------- */ .lp-marquee { overflow: hidden; border-top: 1px solid var(--lp-line); border-bottom: 1px solid var(--lp-line); background: var(--lp-bg2); padding: 26px 0; } .lp-track { display: flex; width: max-content; animation: lp-marquee 48s linear infinite; } @@ -517,6 +565,9 @@ html { scroll-behavior: smooth; scroll-padding-top: 116px; } .lp-tester, .lp-lab, .lp-qr { grid-template-columns: minmax(0, 1fr); } .lp-grid, .lp-quotes { grid-template-columns: repeat(2, minmax(0, 1fr)); } .lp-recipe-card { transform: none; } + /* the award drops under the copy rather than squeezing the headline */ + .lp-hero-split { grid-template-columns: minmax(0, 1fr); gap: 34px; } + .lp-award { max-width: 340px; } } @media (max-width: 680px) { /* logo + theme + language + burger have to fit a 360px phone */