web: the QR card hands out the look that made the photo
A photo's landing section can now be the QR card, and that section is the only one that hands something out: the server writes the photo's own stored look back as the app's .recipe file, at GET /api/photos/:id/preset.recipe, for any row the curator ticked into the qr slot. Nothing new is stored — the file is built from the recipe the upload already carried, so it works for a photo uploaded by the phone too. The admin pane grows a fourth checkbox and a fourth row (QR card); the row draws the download link as a scannable code, and the box is dead for a photo with no stored look. The landing's QR card now encodes the curated photo's own link instead of a mock address. The listing exposes hasPreset, never the recipe itself.
This commit is contained in:
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api, type AdminPhoto, type AdminUser, type PhotoSlot } from './api';
|
||||
import { useI18n } from './i18n/I18nProvider';
|
||||
import { Profile } from './ui/Profile';
|
||||
import { QrCode } from './ui/QrCode';
|
||||
import { Stats } from './ui/Stats';
|
||||
import type { MsgKey } from './i18n/vi';
|
||||
|
||||
@@ -27,6 +28,7 @@ const DESTINATIONS: { id: PhotoSlot; key: MsgKey }[] = [
|
||||
{ id: 'strip', key: 'adm.pickStrip' },
|
||||
{ id: 'tester', key: 'adm.pickTester' },
|
||||
{ id: 'creator', key: 'adm.pickCreator' },
|
||||
{ id: 'qr', key: 'adm.pickQr' },
|
||||
];
|
||||
|
||||
// The left column, in the order it reads.
|
||||
@@ -283,7 +285,11 @@ export function Admin() {
|
||||
type="checkbox"
|
||||
data-key={`adm-pick-${d.id}-${p.id}`}
|
||||
checked={p.slots.includes(d.id)}
|
||||
disabled={busy}
|
||||
// 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,
|
||||
@@ -326,6 +332,22 @@ export function Admin() {
|
||||
{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>
|
||||
))}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useI18n } from './i18n/I18nProvider';
|
||||
import { ACCENTS, FONT_SETS, useTheme, type FontSetId } from './theme/ThemeProvider';
|
||||
import { AuthModal } from './ui/AuthModal';
|
||||
import { Avatar } from './ui/Avatar';
|
||||
import { QrCode } from './ui/QrCode';
|
||||
|
||||
// Landing page, built to the landing blueprint: the 10-section funnel. The
|
||||
// marketing prose is bilingual and lives here next to the markup as `Txt`
|
||||
@@ -160,7 +161,6 @@ export function Landing() {
|
||||
const [sat, setSat] = useState(100);
|
||||
const [con, setCon] = useState(100);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const [qrOk, setQrOk] = useState(true);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [photos, setPhotos] = useState<Photo[]>([]);
|
||||
// Sign up / log in happens in a dialog on this page, not by a trip to the
|
||||
@@ -236,11 +236,24 @@ export function Landing() {
|
||||
// render; the built-in sample stands in while a section has no curation. A
|
||||
// photo the curator put in several sections can turn up in each of them.
|
||||
const slotPhoto = useMemo(() => {
|
||||
const pick = (slot: PhotoSlot, fallback: string) => {
|
||||
const pick = (slot: PhotoSlot) => {
|
||||
const list = photos.filter((p) => p.slots.includes(slot));
|
||||
return list.length ? api.photoUrl(list[Math.floor(Math.random() * list.length)].id) : fallback;
|
||||
return list.length ? list[Math.floor(Math.random() * list.length)] : null;
|
||||
};
|
||||
const src = (slot: PhotoSlot, fallback: string) => {
|
||||
const p = pick(slot);
|
||||
return p ? api.photoUrl(p.id) : fallback;
|
||||
};
|
||||
// The QR card's payload is the curated photo's own look, as the link the
|
||||
// phone app downloads it from. The card's mock address stands in while the
|
||||
// section holds nothing that has a look to hand out.
|
||||
const qr = pick('qr');
|
||||
return {
|
||||
tester: src('tester', SAMPLE(1043)),
|
||||
creator: src('creator', SAMPLE(1015)),
|
||||
qr: qr ? api.photoUrl(qr.id) : SAMPLE(1069),
|
||||
qrLink: qr?.hasPreset ? new URL(api.presetUrl(qr.id), window.location.origin).href : `${window.location.origin}/app`,
|
||||
};
|
||||
return { tester: pick('tester', SAMPLE(1043)), creator: pick('creator', SAMPLE(1015)), qr: pick('qr', SAMPLE(1069)) };
|
||||
}, [photos]);
|
||||
|
||||
// The theme popover closes on a click anywhere else, like the workspace's.
|
||||
@@ -708,15 +721,7 @@ export function Landing() {
|
||||
</div>
|
||||
<div className="lp-rc-row">
|
||||
<div className="lp-rc-qr">
|
||||
{qrOk ? (
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(`${window.location.origin}/app`)}`}
|
||||
alt="QR"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setQrOk(false)}
|
||||
/>
|
||||
) : null}
|
||||
<QrCode url={slotPhoto.qrLink} />
|
||||
</div>
|
||||
<div className="lp-rc-meta">
|
||||
<b>SUNSET GLOW</b>
|
||||
|
||||
@@ -50,6 +50,10 @@ export interface Photo {
|
||||
// The uploader's consent to show this frame on the landing film strip. Own
|
||||
// rows carry it back; the public reel only returns consented ones anyway.
|
||||
consent?: boolean;
|
||||
// Whether the row still carries the look that made it. That look is the QR
|
||||
// card's payload, so the admin needs to know a photo can hand one out without
|
||||
// the listing shipping the recipe itself.
|
||||
hasPreset: boolean;
|
||||
// The look the photo was saved with, on the owner's own listing only — the
|
||||
// studio reads it back so a saved photo can be reopened for editing.
|
||||
recipe?: Recipe | null;
|
||||
@@ -215,6 +219,9 @@ export const api = {
|
||||
// The owner's own delete; an admin may pass any id here too.
|
||||
deletePhoto: (id: number) => call<void>(`/photos/${id}`, { method: 'DELETE' }),
|
||||
photoUrl: (id: number) => `/api/photos/${id}/file`,
|
||||
// The look a photo was uploaded with, as the `.recipe` file the app imports on
|
||||
// a scan. Only answers for a photo the curator ticked into the `qr` section.
|
||||
presetUrl: (id: number) => `/api/photos/${id}/preset.recipe`,
|
||||
|
||||
// Admin only: every account, with how many photos it owns.
|
||||
adminListUsers: () => call<{ users: AdminUser[] }>('/admin/users'),
|
||||
|
||||
@@ -168,8 +168,10 @@ export const en: Dict = {
|
||||
'adm.pickStrip': 'Film strip',
|
||||
'adm.pickTester': 'Live preset tester',
|
||||
'adm.pickCreator': 'Custom recipe creator',
|
||||
'adm.pickQr': 'QR card',
|
||||
'adm.noPreset': 'no look to hand out',
|
||||
'adm.rowEmpty': 'Nothing here yet.',
|
||||
'adm.slotHint': 'Tick the sections a photo should appear in — a photo can sit in several at once, and each section draws one of its own at random per visit. Unticking every box takes the photo off the landing page without deleting it — its owner keeps it in MY PHOTOS.',
|
||||
'adm.slotHint': 'Tick the sections a photo should appear in — a photo can sit in several at once, and each section draws one of its own at random per visit. Unticking every box takes the photo off the landing page without deleting it — its owner keeps it in MY PHOTOS. The QR section is the one that hands the look out: its card shows a code that downloads the photo’s own .recipe file for the phone app to scan in.',
|
||||
'adm.signIn': 'Sign in with an admin account',
|
||||
'adm.notAdmin': 'This account has no admin rights.',
|
||||
'adm.empty': 'No contributions yet.',
|
||||
|
||||
@@ -172,8 +172,10 @@ export const vi = {
|
||||
'adm.pickStrip': 'Dải phim',
|
||||
'adm.pickTester': 'Live preset tester',
|
||||
'adm.pickCreator': 'Custom recipe creator',
|
||||
'adm.pickQr': 'Thẻ QR',
|
||||
'adm.noPreset': 'không có look để chia sẻ',
|
||||
'adm.rowEmpty': 'Chưa có gì ở đây.',
|
||||
'adm.slotHint': 'Tích vào những mục ảnh sẽ xuất hiện — một ảnh có thể nằm cùng lúc ở nhiều mục, và mỗi mục bốc ngẫu nhiên một ảnh trong nhóm của nó mỗi lần ghé. Bỏ tích hết sẽ gỡ ảnh khỏi landing mà không xoá — chủ ảnh vẫn giữ nó trong MY PHOTOS.',
|
||||
'adm.slotHint': 'Tích vào những mục ảnh sẽ xuất hiện — một ảnh có thể nằm cùng lúc ở nhiều mục, và mỗi mục bốc ngẫu nhiên một ảnh trong nhóm của nó mỗi lần ghé. Bỏ tích hết sẽ gỡ ảnh khỏi landing mà không xoá — chủ ảnh vẫn giữ nó trong MY PHOTOS. Mục QR là mục duy nhất phát công thức: thẻ của nó vẽ một mã tải chính tệp .recipe của ảnh để app trên điện thoại quét vào.',
|
||||
'adm.signIn': 'Đăng nhập bằng tài khoản quản trị',
|
||||
'adm.notAdmin': 'Tài khoản này không có quyền quản trị.',
|
||||
'adm.empty': 'Chưa có ảnh đóng góp nào.',
|
||||
|
||||
@@ -440,6 +440,11 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
|
||||
.adm-thumb { margin: 0; border: 1px solid var(--border-soft); border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-sunken); }
|
||||
.adm-thumb img { display: block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover; }
|
||||
.adm-thumb figcaption { padding: 3px 6px; font-family: var(--mono); font-size: 10.5px; color: var(--text-dim); }
|
||||
/* The QR section's real artefact is the code, not the photo. Drawn on white:
|
||||
a scanner needs the quiet zone, and the dark theme has none. */
|
||||
.adm-qr { display: flex; align-items: center; justify-content: center; min-height: 88px; padding: 6px; background: #fff; }
|
||||
.adm-qr img { width: auto; aspect-ratio: 1 / 1; }
|
||||
.adm-qr span { color: #6b7280; font-size: 10.5px; text-align: center; }
|
||||
.adm-spacer { flex: 1 1 auto; }
|
||||
.adm-note { margin: 12px 0; }
|
||||
.adm-sub { margin: 0 0 14px; }
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
// A QR code, drawn by the one public image service the landing already used:
|
||||
// no QR library and no extra bytes in the bundle. `url` is the whole payload —
|
||||
// whatever the phone reads on a scan is exactly this string.
|
||||
// ponytail: a remote image means a third-party request and a blank frame while
|
||||
// it loads. Render the matrix in-bundle only if the QR must work offline.
|
||||
export function QrCode({ url, size = 180, className }: { url: string; size?: number; className?: string }) {
|
||||
const [ok, setOk] = useState(true);
|
||||
if (!ok) return null;
|
||||
return (
|
||||
<img
|
||||
className={className}
|
||||
width={size}
|
||||
height={size}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setOk(false)}
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=${size}x${size}&data=${encodeURIComponent(url)}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user