web: pick a photo's landing slot with two columns

The pictures pane now reads as a pool of every upload on the left, three
destination rows on the right. Each photo carries one button per
destination; picking the one it already sits in takes it off the landing
page without deleting the row (slot off), which is what the old select's
"not on the landing page" option did.
This commit is contained in:
2026-09-18 16:28:55 +07:00
parent 4d0d44170f
commit ee2c402438
4 changed files with 133 additions and 64 deletions
+82 -48
View File
@@ -19,15 +19,15 @@ import type { MsgKey } from './i18n/vi';
type State = 'loading' | 'guest' | 'forbidden' | 'ready';
type Tab = 'profile' | 'users' | 'pictures' | 'stats';
// Where a photo is drawn from. `strip` feeds the community reel and each live
// slot shows one photo of its set, picked at random per page load. `off` is the
// way off the landing without deleting the uploader's row.
const SLOTS: { id: PhotoSlot; key: MsgKey }[] = [
{ id: 'strip', key: 'adm.slotStrip' },
{ id: 'tester', key: 'adm.slotTester' },
{ id: 'creator', key: 'adm.slotCreator' },
{ id: 'qr', key: 'adm.slotQr' },
{ id: 'off', key: 'adm.slotOff' },
// Where a photo can be put. One destination each — the landing page draws the
// film strip from `strip` and one random photo per visit out of each live set.
// Clicking the destination a photo is already in takes it off the landing
// without deleting the uploader's row (slot `off`), which is the same switch
// the old picker carried.
const DESTINATIONS: { id: PhotoSlot; key: MsgKey }[] = [
{ id: 'strip', key: 'adm.pickStrip' },
{ id: 'tester', key: 'adm.pickTester' },
{ id: 'creator', key: 'adm.pickCreator' },
];
// The left column, in the order it reads.
@@ -245,45 +245,79 @@ export function Admin() {
</p>
) : null}
{shown.length === 0 ? <p className="hint adm-note">{t('adm.empty')}</p> : null}
<div className="adm-grid">
{shown.map((p) => (
<article className="adm-card" key={p.id} data-key={`adm-photo-${p.id}`}>
<img src={api.photoUrl(p.id)} alt="" loading="lazy" />
<div className="adm-meta">
<b>#{p.id}</b>
{/* The labels the uploader's studio stored, when it
sent any — the same ones the reel draws. */}
{p.tag ? <span>{p.tag}</span> : null}
{p.title ? <span>{p.title}</span> : null}
{p.meta ? <span>{p.meta}</span> : null}
<span>{t('adm.owner')}: {p.email}</span>
<span>{t('adm.uploaded')}: {new Date(p.createdAt).toLocaleString()}</span>
<span>{t('adm.size')}: {Math.round(p.bytes / 1024)} KB · {p.mime}</span>
</div>
<label className="hint adm-slot">
{t('adm.slot')}
<select
data-key={`adm-slot-${p.id}`}
value={p.slot}
disabled={busy}
onChange={(e) => void setSlot(p.id, e.target.value as PhotoSlot)}
>
{SLOTS.map((s) => (
<option key={s.id} value={s.id}>{t(s.key)}</option>
))}
</select>
</label>
<button
type="button"
className="btn adm-danger"
data-key={`adm-del-${p.id}`}
disabled={busy}
onClick={() => void remove(p.id)}
>
{t('adm.delete')}
</button>
</article>
))}
<div className="adm-cols">
<div className="adm-col">
<h3 className="adm-row-title">{t('adm.pool')}</h3>
<div className="adm-grid">
{shown.map((p) => (
<article className="adm-card" key={p.id} data-key={`adm-photo-${p.id}`}>
<img src={api.photoUrl(p.id)} alt="" loading="lazy" />
<div className="adm-meta">
<b>#{p.id}</b>
{/* The labels the uploader's studio stored, when it
sent any — the same ones the reel draws. */}
{p.tag ? <span>{p.tag}</span> : null}
{p.title ? <span>{p.title}</span> : null}
{p.meta ? <span>{p.meta}</span> : null}
<span>{t('adm.owner')}: {p.email}</span>
<span>{t('adm.uploaded')}: {new Date(p.createdAt).toLocaleString()}</span>
<span>{t('adm.size')}: {Math.round(p.bytes / 1024)} KB · {p.mime}</span>
</div>
{/* One button per destination. The one the photo is
already in reads pressed; pressing it sets `off`. */}
<div className="adm-choices">
{DESTINATIONS.map((d) => (
<button
key={d.id}
type="button"
className={`adm-choice${p.slot === d.id ? ' on' : ''}`}
data-key={`adm-pick-${d.id}-${p.id}`}
aria-pressed={p.slot === d.id}
disabled={busy}
onClick={() => void setSlot(p.id, p.slot === d.id ? 'off' : d.id)}
>
{t(d.key)}
</button>
))}
</div>
<button
type="button"
className="btn adm-danger"
data-key={`adm-del-${p.id}`}
disabled={busy}
onClick={() => void remove(p.id)}
>
{t('adm.delete')}
</button>
</article>
))}
</div>
</div>
<div className="adm-col">
{DESTINATIONS.map((d) => {
const inRow = shown.filter((p) => p.slot === d.id);
return (
<section className="adm-row" key={d.id} data-key={`adm-row-${d.id}`}>
<h3 className="adm-row-title">
{t(d.key)}
<span className="adm-row-count">{inRow.length}</span>
</h3>
{inRow.length === 0 ? (
<p className="hint adm-row-empty">{t('adm.rowEmpty')}</p>
) : (
<div className="adm-grid">
{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" />
<figcaption>#{p.id}</figcaption>
</figure>
))}
</div>
)}
</section>
);
})}
</div>
</div>
</>
) : null}
+6 -7
View File
@@ -164,13 +164,12 @@ export const en: Dict = {
'adm.title': 'Strip moderation',
'adm.subtitle': 'Photos contributed by users. Delete one to pull it off the landing page.',
'adm.upload': 'ADD A PHOTO',
'adm.slot': 'Shows up in',
'adm.slotStrip': 'Community film strip',
'adm.slotTester': 'Live tester (lp-tester)',
'adm.slotCreator': 'Live recipe section (lp-sec)',
'adm.slotQr': 'QR card (lp-qr)',
'adm.slotOff': 'Not on the landing page',
'adm.slotHint': 'The three live slots each draw one photo out of their set at random, so several photos in the same slot rotate between visits. "Not on the landing page" takes a photo off the reel without deleting it — its owner keeps it in MY PHOTOS.',
'adm.pool': 'Uploaded photos',
'adm.pickStrip': 'Film strip',
'adm.pickTester': 'Live preset tester',
'adm.pickCreator': 'Custom recipe creator',
'adm.rowEmpty': 'Nothing here yet.',
'adm.slotHint': 'Pick a destination under a photo to put it there. The three live sections each draw one photo out of their set at random, so several photos in the same section rotate between visits. Picking the destination a photo already sits in takes it off the landing page without deleting it — its owner keeps it in MY PHOTOS.',
'adm.signIn': 'Sign in with an admin account',
'adm.notAdmin': 'This account has no admin rights.',
'adm.empty': 'No contributions yet.',
+6 -7
View File
@@ -168,13 +168,12 @@ export const vi = {
'adm.title': 'Quản trị dải phim',
'adm.subtitle': 'Ảnh do người dùng đóng góp. Xoá một ảnh để gỡ nó khỏi trang chủ.',
'adm.upload': 'THÊM ẢNH',
'adm.slot': 'Hiển thị ở',
'adm.slotStrip': 'Dải phim cộng đồng',
'adm.slotTester': 'Live tester (lp-tester)',
'adm.slotCreator': 'Mục công thức live (lp-sec)',
'adm.slotQr': 'Thẻ QR (lp-qr)',
'adm.slotOff': 'Không hiện trên landing page',
'adm.slotHint': 'Ba slot trực tiếp mỗi cái bốc ngẫu nhiên một ảnh trong nhóm của nó, nên nhiều ảnh cùng slot sẽ luân phiên giữa các lần ghé. "Không hiện trên landing page" gỡ ảnh khỏi landing mà không xoá — chủ ảnh vẫn giữ nó trong MY PHOTOS.',
'adm.pool': 'Ảnh đã tải lên',
'adm.pickStrip': 'Dải phim',
'adm.pickTester': 'Live preset tester',
'adm.pickCreator': 'Custom recipe creator',
'adm.rowEmpty': 'Chưa có gì ở đây.',
'adm.slotHint': 'Bấm một đích dưới mỗi ảnh để đưa ảnh vào đó. Ba mục trực tiếp mỗi cái bốc ngẫu nhiên một ảnh trong nhóm của nó, nên nhiều ảnh cùng mục sẽ luân phiên giữa các lần ghé. Bấm đúng đích ảnh đang nằm sẽ gỡ ảnh khỏi landing mà không xoá — chủ ảnh vẫn giữ nó trong MY PHOTOS.',
'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.',
+39 -2
View File
@@ -394,8 +394,45 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
.adm-brand { display: flex; align-items: center; gap: 6px; font-weight: 650; letter-spacing: -0.01em; color: var(--text); text-decoration: none; }
.adm-brand em { font-style: normal; font-weight: 400; color: var(--accent); }
.adm-brand img { border-radius: 8px; }
.adm-slot { display: flex; flex-direction: column; gap: 3px; letter-spacing: 0.04em; }
.adm-slot select { width: 100%; }
/* Curating pictures: the pool of uploads on the left, the three landing
sections on the right. One picker button per destination under each photo. */
.adm-cols { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; align-items: start; }
@media (max-width: 1100px) { .adm-cols { grid-template-columns: minmax(0, 1fr); } }
.adm-col { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.adm-cols .adm-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
.adm-choices { display: flex; flex-direction: column; gap: 4px; }
.adm-choice {
font: inherit;
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
text-align: left;
padding: 5px 8px;
cursor: pointer;
color: var(--text);
background: var(--bg-sunken);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.adm-choice:hover:not(:disabled) { border-color: var(--accent-line); }
.adm-choice.on { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
.adm-choice:disabled { opacity: 0.5; cursor: default; }
.adm-row { border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); padding: 10px; }
.adm-row-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 8px;
font-size: 11.5px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
}
.adm-row-count { font-family: var(--mono); color: var(--text); }
.adm-row-empty { margin: 0; }
.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); }
.adm-spacer { flex: 1 1 auto; }
.adm-note { margin: 12px 0; }
.adm-sub { margin: 0 0 14px; }