web: a photo can sit in every landing section at once
The picker was one dropdown, so a photo lived in exactly one place. The three destinations are now independent checkboxes on the card, and the column holds the set as a comma list — the landing page draws a photo in every section it was ticked into, each still picking one of its own at random per visit. Ticking nothing is what `off` used to be: the row is kept and the landing page stops drawing it, which is what the old "not on the landing page" option did.
This commit is contained in:
+34
-16
@@ -83,21 +83,30 @@ CREATE INDEX IF NOT EXISTS idx_events_at ON events(at);
|
||||
|
||||
// Where a curated photo is allowed to appear on the landing page: the community
|
||||
// strip, the live tester's preview, the creator lab's preview, or the QR card.
|
||||
// One is picked at random out of its slot on every page load. `off` is the
|
||||
// curator's "take it off the landing, keep the row" — the reel and the three
|
||||
// live slots all draw by exact slot, so an `off` photo shows up nowhere, while
|
||||
// its owner still has it in MY PHOTOS.
|
||||
export const PHOTO_SLOTS = ['strip', 'tester', 'creator', 'qr', 'off'] as const;
|
||||
// A photo may sit in several at once — each section draws its own random pick
|
||||
// out of its set on every page load. Sitting in none of them is the curator's
|
||||
// "take it off the landing, keep the row": the owner still has it in MY PHOTOS.
|
||||
export const PHOTO_SLOTS = ['strip', 'tester', 'creator', 'qr'] as const;
|
||||
export type PhotoSlot = (typeof PHOTO_SLOTS)[number];
|
||||
export const isPhotoSlot = (v: unknown): v is PhotoSlot =>
|
||||
typeof v === 'string' && (PHOTO_SLOTS as readonly string[]).includes(v);
|
||||
// The column holds the set as a comma list. Ids it does not know — the retired
|
||||
// single-slot `off`, anything hand-edited — are dropped, so a row can always be
|
||||
// read back as a set the landing page understands.
|
||||
export const parseSlots = (raw: string | null | undefined): PhotoSlot[] =>
|
||||
(raw ?? '').split(',').filter(isPhotoSlot);
|
||||
export const serializeSlots = (slots: readonly PhotoSlot[]): string =>
|
||||
[...new Set(slots.filter(isPhotoSlot))].join(',');
|
||||
|
||||
// The column arrived after the first strips were already on disk, so add it in
|
||||
// place — `CREATE TABLE IF NOT EXISTS` would silently skip an existing table.
|
||||
// It first held one slot; the rename keeps every existing row readable, since
|
||||
// a bare `strip` parses as a one-member set and the old `off` as the empty one.
|
||||
{
|
||||
const cols = db.prepare('PRAGMA table_info(photos)').all() as { name: string }[];
|
||||
if (!cols.some((c) => c.name === 'slot')) {
|
||||
db.exec(`ALTER TABLE photos ADD COLUMN slot TEXT NOT NULL DEFAULT 'strip'`);
|
||||
if (!cols.some((c) => c.name === 'slots')) {
|
||||
if (cols.some((c) => c.name === 'slot')) db.exec(`ALTER TABLE photos RENAME COLUMN slot TO slots`);
|
||||
else db.exec(`ALTER TABLE photos ADD COLUMN slots TEXT NOT NULL DEFAULT 'strip'`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,7 +329,9 @@ export function deleteRecipe(userId: number, id: number): boolean {
|
||||
export type Photo = {
|
||||
id: number;
|
||||
createdAt: string;
|
||||
slot: PhotoSlot;
|
||||
// Every landing section this photo is allowed to appear in; empty means it
|
||||
// is curated off the landing page entirely.
|
||||
slots: PhotoSlot[];
|
||||
tag: string | null;
|
||||
title: string | null;
|
||||
meta: string | null;
|
||||
@@ -341,14 +352,19 @@ export type PhotoMeta = {
|
||||
};
|
||||
|
||||
// One SELECT list, so the call sites cannot drift apart.
|
||||
const PHOTO_COLUMNS = `photos.id AS id, photos.created_at AS createdAt, photos.slot AS slot,
|
||||
const PHOTO_COLUMNS = `photos.id AS id, photos.created_at AS createdAt, photos.slots AS slots,
|
||||
photos.tag AS tag, photos.title AS title, photos.meta AS meta,
|
||||
photos.consent AS consent`;
|
||||
|
||||
// SQLite has no boolean: a row comes back 0/1 and a recipe as its JSON text.
|
||||
type PhotoRow = Omit<Photo, 'consent'> & { consent: number };
|
||||
// `slots` comes back as the stored comma list, turned into a set on the way out.
|
||||
type PhotoRow = Omit<Photo, 'consent' | 'slots'> & { consent: number; slots: string | null };
|
||||
type MyPhotoRow = PhotoRow & { recipe: string | null; history: string | null };
|
||||
const toPhoto = (row: PhotoRow): Photo => ({ ...row, consent: row.consent === 1 });
|
||||
const toPhoto = (row: PhotoRow): Photo => ({
|
||||
...row,
|
||||
consent: row.consent === 1,
|
||||
slots: parseSlots(row.slots),
|
||||
});
|
||||
// A row whose JSON will not parse is still a photo: its settings are simply
|
||||
// gone, not worth failing the whole folder over. Same for one bad entry in the
|
||||
// history — the rest of the list still stands.
|
||||
@@ -513,11 +529,12 @@ export function createPhoto(
|
||||
meta?.consent === false ? 0 : 1,
|
||||
meta?.recipe === undefined ? null : JSON.stringify(meta.recipe),
|
||||
);
|
||||
// A fresh upload is a strip photo until the curator moves it to a live slot.
|
||||
// A fresh upload starts in the community strip; the curator may put it in
|
||||
// any of the live sections too, or take it off the landing.
|
||||
return {
|
||||
id: Number(info.lastInsertRowid),
|
||||
createdAt: ts,
|
||||
slot: 'strip',
|
||||
slots: ['strip'],
|
||||
tag: meta?.tag ?? null,
|
||||
title: meta?.title ?? null,
|
||||
meta: meta?.meta ?? null,
|
||||
@@ -610,9 +627,10 @@ export function deletePhotoOf(userId: number, id: number): string | undefined {
|
||||
return row.file;
|
||||
}
|
||||
|
||||
// Curating, not moderating: where this photo is allowed to surface.
|
||||
export function setPhotoSlot(id: number, slot: PhotoSlot): boolean {
|
||||
return db.prepare('UPDATE photos SET slot = ? WHERE id = ?').run(slot, id).changes > 0;
|
||||
// Curating, not moderating: where this photo is allowed to surface. The whole
|
||||
// set arrives at once — the admin's checkboxes are the only writer.
|
||||
export function setPhotoSlots(id: number, slots: readonly PhotoSlot[]): boolean {
|
||||
return db.prepare('UPDATE photos SET slots = ? WHERE id = ?').run(serializeSlots(slots), id).changes > 0;
|
||||
}
|
||||
|
||||
export function deleteAllPhotos(): string[] {
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
photoFile,
|
||||
photoPath,
|
||||
sessionUser,
|
||||
setPhotoSlot,
|
||||
setPhotoSlots,
|
||||
setPhotoConsent,
|
||||
setUserAvatar,
|
||||
setUserBlocked,
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
type Recipe,
|
||||
type User,
|
||||
type EventKind,
|
||||
type PhotoSlot,
|
||||
} from './db';
|
||||
|
||||
const PORT = Number(process.env.PORT || 3000);
|
||||
@@ -804,18 +805,22 @@ app.delete('/api/admin/photos', async (req, reply) => {
|
||||
return reply.status(200).send({ removed: files.length });
|
||||
});
|
||||
|
||||
// Curating: which slot on the landing page this photo is allowed to appear in.
|
||||
// The landing page picks one at random per slot, so several photos in one slot
|
||||
// rotate between visits.
|
||||
// Curating: the landing sections this photo is allowed to appear in. A photo
|
||||
// may sit in several at once — each section picks one of its own at random per
|
||||
// visit, so several photos in one section rotate. An empty set takes it off
|
||||
// the landing without deleting the row.
|
||||
app.patch<{ Params: { id: string } }>('/api/admin/photos/:id', async (req, reply) => {
|
||||
const user = admin(req);
|
||||
if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' });
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' });
|
||||
const b = bodyOf(req);
|
||||
if (!b || !isPhotoSlot(b.slot)) return reply.status(400).send({ error: 'invalid slot' });
|
||||
if (!setPhotoSlot(id, b.slot)) return reply.status(404).send({ error: 'photo not found' });
|
||||
return reply.status(200).send({ id, slot: b.slot });
|
||||
const slots = b?.slots;
|
||||
if (!Array.isArray(slots) || !slots.every((s) => isPhotoSlot(s)))
|
||||
return reply.status(400).send({ error: 'invalid slots' });
|
||||
const set: PhotoSlot[] = [...new Set(slots as PhotoSlot[])];
|
||||
if (!setPhotoSlots(id, set)) return reply.status(404).send({ error: 'photo not found' });
|
||||
return reply.status(200).send({ id, slots: set });
|
||||
});
|
||||
|
||||
app
|
||||
|
||||
@@ -284,7 +284,10 @@ try {
|
||||
const rows = adminList.body?.photos ?? [];
|
||||
check('an admin lists contributions', adminList.status === 200 && rows.length > 0);
|
||||
check('the admin listing carries the owner', rows.some((r) => /@test\.local$/.test(r.email ?? '')));
|
||||
check('a fresh upload lands in the strip slot', rows.find((r) => r.id === id)?.slot === 'strip');
|
||||
check(
|
||||
'a fresh upload lands in the strip section',
|
||||
JSON.stringify(rows.find((r) => r.id === id)?.slots) === JSON.stringify(['strip']),
|
||||
);
|
||||
check('a plain member cannot delete', (await user.req(`/admin/photos/${id}`, { method: 'DELETE' })).status === 403);
|
||||
|
||||
// ---- placement ----------------------------------------------------------
|
||||
@@ -295,27 +298,38 @@ try {
|
||||
(await user.req(`/admin/photos/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ slot: 'qr' }),
|
||||
body: JSON.stringify({ slots: ['qr'] }),
|
||||
})).status === 403,
|
||||
);
|
||||
const placed = await patch(`/admin/photos/${id}`, { slot: 'qr' });
|
||||
check('an admin moves a photo to a live slot', placed.status === 200 && placed.body?.slot === 'qr', JSON.stringify(placed.body));
|
||||
const placed = await patch(`/admin/photos/${id}`, { slots: ['tester', 'creator'] });
|
||||
check(
|
||||
'the slot is public, the owner is not',
|
||||
((await actor().req('/photos')).body?.photos ?? []).find((r) => r.id === id)?.slot === 'qr',
|
||||
'an admin puts a photo in several sections at once',
|
||||
placed.status === 200 && JSON.stringify(placed.body?.slots) === JSON.stringify(['tester', 'creator']),
|
||||
JSON.stringify(placed.body),
|
||||
);
|
||||
// `off` is the curator's removal: the reel draws slot === 'strip' and the
|
||||
// live slots draw their own, so the row shows up nowhere — but the uploader
|
||||
// keeps it in their folder.
|
||||
const off = await patch(`/admin/photos/${id}`, { slot: 'off' });
|
||||
check('an admin takes a photo off the landing', off.status === 200 && off.body?.slot === 'off', JSON.stringify(off.body));
|
||||
check(
|
||||
'an off photo is on no landing slot',
|
||||
((await actor().req('/photos')).body?.photos ?? []).find((r) => r.id === id)?.slot === 'off',
|
||||
'the sections are public, the owner is not',
|
||||
JSON.stringify(((await actor().req('/photos')).body?.photos ?? []).find((r) => r.id === id)?.slots) ===
|
||||
JSON.stringify(['tester', 'creator']),
|
||||
);
|
||||
const one = await patch(`/admin/photos/${id}`, { slots: ['strip', 'strip', 'qr'] });
|
||||
check(
|
||||
'a repeated section is stored once',
|
||||
JSON.stringify(one.body?.slots) === JSON.stringify(['strip', 'qr']),
|
||||
JSON.stringify(one.body),
|
||||
);
|
||||
// An empty set is the curator's removal: no landing section draws the row,
|
||||
// but the uploader keeps it in their folder.
|
||||
const off = await patch(`/admin/photos/${id}`, { slots: [] });
|
||||
check('an admin takes a photo off the landing', off.status === 200 && off.body?.slots?.length === 0, JSON.stringify(off.body));
|
||||
check(
|
||||
'an off photo is on no landing section',
|
||||
((await actor().req('/photos')).body?.photos ?? []).find((r) => r.id === id)?.slots?.length === 0,
|
||||
);
|
||||
check('its owner still has it in the folder', ((await user.req('/photos/mine')).body?.photos ?? []).some((p) => p.id === id));
|
||||
check('an unknown slot is refused', (await patch(`/admin/photos/${id}`, { slot: 'nope' })).status === 400);
|
||||
check('placing an unknown photo is a 404', (await patch('/admin/photos/999999', { slot: 'qr' })).status === 404);
|
||||
check('an unknown slot is refused', (await patch(`/admin/photos/${id}`, { slots: ['nope'] })).status === 400);
|
||||
check('a bare slot string is refused', (await patch(`/admin/photos/${id}`, { slots: 'strip' })).status === 400);
|
||||
check('placing an unknown photo is a 404', (await patch('/admin/photos/999999', { slots: ['qr'] })).status === 404);
|
||||
|
||||
const deleted = await admin.req(`/admin/photos/${id}`, { method: 'DELETE' });
|
||||
check('an admin deletes a contribution', deleted.status === 204, `got ${deleted.status}`);
|
||||
|
||||
@@ -19,11 +19,10 @@ import type { MsgKey } from './i18n/vi';
|
||||
type State = 'loading' | 'guest' | 'forbidden' | 'ready';
|
||||
type Tab = 'profile' | 'users' | 'pictures' | 'stats';
|
||||
|
||||
// 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.
|
||||
// Where a photo can be put. The boxes are independent — a photo may sit in all
|
||||
// three sections at once, and each section draws one random photo per visit out
|
||||
// of its own set. Unticking every box takes the photo off the landing without
|
||||
// deleting the uploader's row, 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' },
|
||||
@@ -102,10 +101,21 @@ export function Admin() {
|
||||
await refreshPhotos();
|
||||
});
|
||||
|
||||
const setSlot = (id: number, slot: PhotoSlot) =>
|
||||
// One checkbox tick: the whole new set goes to the API in one call. The box
|
||||
// flips under the curator's finger first — waiting for the round trip would
|
||||
// snap it back for a frame and read as a lost click — and a failed write puts
|
||||
// the old set back.
|
||||
const setSlots = (id: number, next: PhotoSlot[]) =>
|
||||
run(async () => {
|
||||
await api.adminSetPhotoSlot(id, slot);
|
||||
setPhotos((prev) => prev.map((p) => (p.id === id ? { ...p, slot } : p)));
|
||||
const before = photos.find((p) => p.id === id)?.slots ?? [];
|
||||
const show = (slots: PhotoSlot[]) => setPhotos((prev) => prev.map((p) => (p.id === id ? { ...p, slots } : p)));
|
||||
show(next);
|
||||
try {
|
||||
await api.adminSetPhotoSlots(id, next);
|
||||
} catch (err) {
|
||||
show(before);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
const clear = () => {
|
||||
@@ -263,21 +273,28 @@ export function Admin() {
|
||||
<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`. */}
|
||||
{/* One checkbox per destination, all three on one
|
||||
line: tick as many as the photo should appear in,
|
||||
untick the last one to take it off the landing. */}
|
||||
<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)}
|
||||
>
|
||||
<label className="adm-choice" key={d.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-key={`adm-pick-${d.id}-${p.id}`}
|
||||
checked={p.slots.includes(d.id)}
|
||||
disabled={busy}
|
||||
onChange={() =>
|
||||
void setSlots(
|
||||
p.id,
|
||||
p.slots.includes(d.id)
|
||||
? p.slots.filter((x) => x !== d.id)
|
||||
: [...p.slots, d.id],
|
||||
)
|
||||
}
|
||||
/>
|
||||
{t(d.key)}
|
||||
</button>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
@@ -295,7 +312,7 @@ export function Admin() {
|
||||
</div>
|
||||
<div className="adm-col">
|
||||
{DESTINATIONS.map((d) => {
|
||||
const inRow = shown.filter((p) => p.slot === d.id);
|
||||
const inRow = shown.filter((p) => p.slots.includes(d.id));
|
||||
return (
|
||||
<section className="adm-row" key={d.id} data-key={`adm-row-${d.id}`}>
|
||||
<h3 className="adm-row-title">
|
||||
|
||||
@@ -173,7 +173,7 @@ export function Landing() {
|
||||
// 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.
|
||||
...photos.filter((p) => p.slot === 'strip' && p.consent !== false).map((p) => ({
|
||||
...photos.filter((p) => p.slots.includes('strip') && p.consent !== false).map((p) => ({
|
||||
tag: p.tag ?? '#COMMUNITY',
|
||||
title: { en: p.title ?? 'Community Frame', vi: p.title ?? 'Khung cộng đồng' },
|
||||
meta: p.meta ?? `#${p.id}`,
|
||||
@@ -184,12 +184,13 @@ export function Landing() {
|
||||
[photos],
|
||||
);
|
||||
|
||||
// The three live slots. One random photo out of each set, drawn once per page
|
||||
// load so the preview does not change under the visitor's eyes on every
|
||||
// render; the built-in sample stands in while a slot has no curation.
|
||||
// The three live sections. One random photo out of each set, drawn once per
|
||||
// page load so the preview does not change under the visitor's eyes on every
|
||||
// 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 list = photos.filter((p) => p.slot === slot);
|
||||
const list = photos.filter((p) => p.slots.includes(slot));
|
||||
return list.length ? api.photoUrl(list[Math.floor(Math.random() * list.length)].id) : fallback;
|
||||
};
|
||||
return { tester: pick('tester', SAMPLE(1043)), creator: pick('creator', SAMPLE(1015)), qr: pick('qr', SAMPLE(1069)) };
|
||||
|
||||
@@ -31,10 +31,10 @@ export interface SavedRecipe {
|
||||
}
|
||||
|
||||
// Where a curated photo may appear on the landing page. `strip` is the
|
||||
// community reel; the rest are the three live slots, each of which shows one
|
||||
// random photo out of its set per page load. `off` keeps the row but takes it
|
||||
// off the landing entirely — the curator's removal.
|
||||
export type PhotoSlot = 'strip' | 'tester' | 'creator' | 'qr' | 'off';
|
||||
// community reel; the rest are the three live sections, each of which shows one
|
||||
// random photo out of its set per page load. A photo may sit in several at
|
||||
// once; an empty set is the curator's removal from the landing.
|
||||
export type PhotoSlot = 'strip' | 'tester' | 'creator' | 'qr';
|
||||
|
||||
// A strip contribution as the public sees it — the API never puts an email on
|
||||
// this shape. `tag`/`title`/`meta` are the frame's own labels (the amber
|
||||
@@ -43,7 +43,7 @@ export type PhotoSlot = 'strip' | 'tester' | 'creator' | 'qr' | 'off';
|
||||
export interface Photo {
|
||||
id: number;
|
||||
createdAt: string;
|
||||
slot: PhotoSlot;
|
||||
slots: PhotoSlot[];
|
||||
tag: string | null;
|
||||
title: string | null;
|
||||
meta: string | null;
|
||||
@@ -223,8 +223,11 @@ export const api = {
|
||||
adminListPhotos: () => call<{ photos: AdminPhoto[] }>('/admin/photos'),
|
||||
adminDeletePhoto: (id: number) => call<void>(`/admin/photos/${id}`, { method: 'DELETE' }),
|
||||
adminClearPhotos: () => call<{ removed: number }>('/admin/photos', { method: 'DELETE' }),
|
||||
adminSetPhotoSlot: (id: number, slot: PhotoSlot) =>
|
||||
call<{ id: number; slot: PhotoSlot }>(`/admin/photos/${id}`, { method: 'PATCH', body: JSON.stringify({ slot }) }),
|
||||
adminSetPhotoSlots: (id: number, slots: PhotoSlot[]) =>
|
||||
call<{ id: number; slots: PhotoSlot[] }>(`/admin/photos/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ slots }),
|
||||
}),
|
||||
|
||||
// Moderation of an account. Both flags are reversible; `adminDeleteUser` is
|
||||
// the final act and takes the account's photos and recipes with it.
|
||||
|
||||
@@ -169,7 +169,7 @@ export const en: Dict = {
|
||||
'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.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.signIn': 'Sign in with an admin account',
|
||||
'adm.notAdmin': 'This account has no admin rights.',
|
||||
'adm.empty': 'No contributions yet.',
|
||||
|
||||
@@ -173,7 +173,7 @@ export const vi = {
|
||||
'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.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.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.',
|
||||
|
||||
@@ -399,24 +399,31 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
|
||||
.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; }
|
||||
/* Wide enough for the three chips to sit on one line: the picker is the point
|
||||
of the card, so the grid gives it the width it needs. */
|
||||
.adm-cols .adm-grid { grid-template-columns: repeat(auto-fill, minmax(450px, 1fr)); }
|
||||
/* The three destinations sit on one line under the photo, each an independent
|
||||
checkbox — tick all three and the photo shows in all three sections. */
|
||||
.adm-choices { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.adm-choice {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
text-align: left;
|
||||
padding: 5px 8px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
color: var(--text-dim);
|
||||
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-choice:hover { border-color: var(--accent-line); }
|
||||
.adm-choice:has(input:checked) { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
|
||||
.adm-choice:has(input:disabled) { opacity: 0.5; cursor: default; }
|
||||
.adm-choice input { margin: 0; accent-color: var(--accent); }
|
||||
.adm-row { border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); padding: 10px; }
|
||||
.adm-row-title {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user