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:
2026-09-18 16:57:46 +07:00
parent a35ecf4f1c
commit 8a889db069
11 changed files with 233 additions and 18 deletions
+23 -1
View File
@@ -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>
))}