web: PICTURES back to two album columns, every frame editable in place
The pane is two columns of the same thing: the uploads on the left, one album per account, and the landing on the right, one album per section — Film strip, Live preset tester, Custom recipe creator. QR is no longer a shelf of its own: the code belongs to the frame. Both columns list their albums the same way and draw the open album's frames as cards, each with its labels, the four section boxes, its QR code and the delete button under the picture. Ticking a box files the frame into that album on the other side straight away.
This commit is contained in:
+136
-136
@@ -10,9 +10,10 @@ import type { MsgKey } from './i18n/vi';
|
||||
// the pane for the chosen section.
|
||||
// Profile — the signed-in account's own picture/email/password
|
||||
// User account — every account: its name, email, picture and contribution count
|
||||
// Pictures — one album per user: click an album for its strip of
|
||||
// thumbnails, click a thumbnail for the full frame and the
|
||||
// landing sections it is allowed into
|
||||
// Pictures — two columns of albums: the uploads on the left, one album
|
||||
// per account; the landing on the right, one album per
|
||||
// section. Every frame carries its own labels, section boxes,
|
||||
// QR code and delete button under its preview
|
||||
// Stats — the visitor counter: views, clicks and their breakdowns
|
||||
// Close — leaves the frame and goes back to the landing page
|
||||
// The frame holds no privilege of its own — the API answers 403 unless the
|
||||
@@ -33,6 +34,10 @@ const DESTINATIONS: { id: PhotoSlot; key: MsgKey }[] = [
|
||||
{ id: 'qr', key: 'adm.pickQr' },
|
||||
];
|
||||
|
||||
// Column 2 browses these as albums. QR is not one of them: its code belongs to
|
||||
// the frame, so it is drawn under every preview instead of in a shelf of its own.
|
||||
const SECTIONS = DESTINATIONS.filter((d) => d.id !== 'qr');
|
||||
|
||||
// The left column, in the order it reads.
|
||||
const MENU: { id: Tab; key: MsgKey }[] = [
|
||||
{ id: 'profile', key: 'adm.tabProfile' },
|
||||
@@ -47,11 +52,10 @@ export function Admin() {
|
||||
const [photos, setPhotos] = useState<AdminPhoto[]>([]);
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [tab, setTab] = useState<Tab>('pictures');
|
||||
// The album browser: which user's album is open ('all' is the opening view),
|
||||
// which frame is up in the big box, and the three filters over the album and
|
||||
// its strip.
|
||||
// The two album browsers: which account's uploads are open on the left, which
|
||||
// landing section is open on the right, and the filters both columns obey.
|
||||
const [album, setAlbum] = useState<number | 'all'>('all');
|
||||
const [sel, setSel] = useState<number | null>(null);
|
||||
const [section, setSection] = useState<PhotoSlot>('strip');
|
||||
const [nameQuery, setNameQuery] = useState('');
|
||||
const [sort, setSort] = useState<'newest' | 'recipe' | 'rating'>('newest');
|
||||
const [minStars, setMinStars] = useState(0);
|
||||
@@ -141,11 +145,10 @@ export function Admin() {
|
||||
});
|
||||
};
|
||||
|
||||
// From the users table: open that account's album and put its newest frame up
|
||||
// in the big box, rather than filtering a flat pool of every upload.
|
||||
// From the users table: open that account's album in the left column.
|
||||
const showOwner = (userId: number) => {
|
||||
setTab('pictures');
|
||||
openAlbum(userId);
|
||||
setAlbum(userId);
|
||||
};
|
||||
|
||||
// Block/unblock and remove/restore share one route; the list is reloaded
|
||||
@@ -200,33 +203,100 @@ export function Admin() {
|
||||
}, [photos]);
|
||||
const shownAlbums = query ? albums.filter((a) => a.email.toLowerCase().includes(query)) : albums;
|
||||
|
||||
// The open album's strip: the name filter still narrows the list, the rating
|
||||
// filter drops frames below the bar, and the sort orders what is left.
|
||||
const strip = useMemo(() => {
|
||||
const base = (album === 'all' ? photos : photos.filter((p) => p.userId === album)).filter(
|
||||
(p) => !query || p.email.toLowerCase().includes(query),
|
||||
);
|
||||
const list = minStars > 0 ? base.filter((p) => score(p) >= minStars) : base;
|
||||
const sorted = [...list];
|
||||
if (sort === 'recipe') sorted.sort((a, b) => (a.recipeName ?? '').localeCompare(b.recipeName ?? ''));
|
||||
else if (sort === 'rating') sorted.sort((a, b) => score(b) - score(a));
|
||||
else sorted.sort((a, b) => b.id - a.id);
|
||||
return sorted;
|
||||
// `score` reads `ratings`, which is in the deps.
|
||||
}, [photos, album, minStars, sort, query, ratings]);
|
||||
// What an album draws: the frames the name box kept, minus anything under the
|
||||
// rating bar, in the chosen order. Both columns run it, so one frame sits
|
||||
// under the same rules on either side.
|
||||
const arrange = useCallback(
|
||||
(base: AdminPhoto[]) => {
|
||||
const named = base.filter((p) => !query || p.email.toLowerCase().includes(query));
|
||||
const sorted = [...(minStars > 0 ? named.filter((p) => score(p) >= minStars) : named)];
|
||||
if (sort === 'recipe') sorted.sort((a, b) => (a.recipeName ?? '').localeCompare(b.recipeName ?? ''));
|
||||
else if (sort === 'rating') sorted.sort((a, b) => score(b) - score(a));
|
||||
else sorted.sort((a, b) => b.id - a.id);
|
||||
return sorted;
|
||||
// `score` reads `ratings`, which is in the deps.
|
||||
},
|
||||
[query, minStars, sort, ratings],
|
||||
);
|
||||
|
||||
// The frame in the big box. `sel` names it, but a stale id (the photo was just
|
||||
// deleted, or a filter moved on) falls back to the top of the strip so the box
|
||||
// is never pointlessly empty.
|
||||
const current = photos.find((p) => p.id === sel) ?? strip[0] ?? null;
|
||||
const albumPhotos = useMemo(
|
||||
() => arrange(album === 'all' ? photos : photos.filter((p) => p.userId === album)),
|
||||
[arrange, album, photos],
|
||||
);
|
||||
const sectionPhotos = useMemo(
|
||||
() => arrange(photos.filter((p) => p.slots.includes(section))),
|
||||
[arrange, photos, section],
|
||||
);
|
||||
|
||||
// Opening an album puts its newest frame up, so the big box fills on the same
|
||||
// click that fills the strip.
|
||||
const openAlbum = (id: number | 'all') => {
|
||||
setAlbum(id);
|
||||
const first = photos.filter((p) => id === 'all' || p.userId === id).sort((a, b) => b.id - a.id)[0];
|
||||
setSel(first ? first.id : null);
|
||||
};
|
||||
// One frame, as both columns draw it: the picture, then everything a curator
|
||||
// can do to it — labels, the four landing sections, the look's own QR code and
|
||||
// the delete button — all of it under the preview.
|
||||
const card = (p: AdminPhoto) => (
|
||||
<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>
|
||||
{p.tag ? <span>{p.tag}</span> : null}
|
||||
{p.title ? <span>{p.title}</span> : null}
|
||||
{p.meta ? <span>{p.meta}</span> : null}
|
||||
{p.recipeName ? (
|
||||
<span>
|
||||
{t('adm.recipe')}: {p.recipeName}
|
||||
</span>
|
||||
) : null}
|
||||
<span>
|
||||
{t('adm.rating')}: {score(p).toFixed(1)} ({ratings[`photo:${p.id}`]?.n ?? 0})
|
||||
</span>
|
||||
<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>
|
||||
<div className="adm-choices">
|
||||
{DESTINATIONS.map((d) => (
|
||||
<label className="adm-choice" key={d.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-key={`adm-pick-${d.id}-${p.id}`}
|
||||
checked={p.slots.includes(d.id)}
|
||||
// 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, p.slots.includes(d.id) ? p.slots.filter((x) => x !== d.id) : [...p.slots, d.id])
|
||||
}
|
||||
/>
|
||||
{t(d.key)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{/* The QR section's real artefact is the code, not the photo, so it is
|
||||
drawn here under the frame — on white, because a scanner needs the
|
||||
quiet zone and the dark theme has none. */}
|
||||
<div className="adm-qr" data-key={`adm-qr-${p.id}`}>
|
||||
{p.hasPreset ? (
|
||||
<QrCode url={new URL(api.presetUrl(p.id), window.location.origin).href} size={104} />
|
||||
) : (
|
||||
<span>{t('adm.noPreset')}</span>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="adm">
|
||||
@@ -308,11 +378,16 @@ export function Admin() {
|
||||
<p className="hint adm-sub">{t('adm.slotHint')}</p>
|
||||
{photos.length === 0 ? <p className="hint adm-note">{t('adm.empty')}</p> : null}
|
||||
<div className="adm-cols">
|
||||
{/* One album per account, plus ALL. The list scrolls on its
|
||||
own, so the strip beside it stays put however many
|
||||
accounts there are. */}
|
||||
{/* Two columns of the same thing: frames grouped into albums.
|
||||
Left is the uploads, one album per account; right is the
|
||||
landing, one album per section. Both list their albums,
|
||||
both draw the open album's frames with everything a
|
||||
curator can do to one sitting under its picture. */}
|
||||
<div className="adm-col">
|
||||
<h3 className="adm-row-title">{t('adm.albums')}</h3>
|
||||
<h3 className="adm-row-title">
|
||||
{t('adm.uploads')}
|
||||
<span className="adm-row-count">{photos.length}</span>
|
||||
</h3>
|
||||
<input
|
||||
type="search"
|
||||
className="adm-input"
|
||||
@@ -353,7 +428,7 @@ export function Admin() {
|
||||
type="button"
|
||||
className={`adm-album${album === 'all' ? ' on' : ''}`}
|
||||
data-key="adm-album-all"
|
||||
onClick={() => openAlbum('all')}
|
||||
onClick={() => setAlbum('all')}
|
||||
>
|
||||
<span className="adm-album-name">{t('adm.albumAll')}</span>
|
||||
<span className="adm-album-count">{photos.length}</span>
|
||||
@@ -364,117 +439,42 @@ export function Admin() {
|
||||
type="button"
|
||||
className={`adm-album${album === a.userId ? ' on' : ''}`}
|
||||
data-key={`adm-album-${a.userId}`}
|
||||
onClick={() => openAlbum(a.userId)}
|
||||
onClick={() => setAlbum(a.userId)}
|
||||
>
|
||||
<span className="adm-album-name">{a.email}</span>
|
||||
<span className="adm-album-count">{a.n}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="adm-grid" data-key="adm-photos">
|
||||
{albumPhotos.map(card)}
|
||||
</div>
|
||||
{albumPhotos.length === 0 ? <p className="hint adm-row-empty">{t('adm.empty')}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="adm-col">
|
||||
{/* The frame that is up: the full picture, its labels and
|
||||
the sections it may appear in — the same checkboxes the
|
||||
flat pool carried, now on the chosen frame. */}
|
||||
<section className="adm-big" data-key="adm-big">
|
||||
{current ? (
|
||||
<>
|
||||
<img className="adm-big-img" data-key="adm-big-img" src={api.photoUrl(current.id)} alt="" />
|
||||
<div className="adm-big-side">
|
||||
<div className="adm-meta">
|
||||
<b>#{current.id}</b>
|
||||
{current.tag ? <span>{current.tag}</span> : null}
|
||||
{current.title ? <span>{current.title}</span> : null}
|
||||
{current.meta ? <span>{current.meta}</span> : null}
|
||||
{current.recipeName ? (
|
||||
<span>
|
||||
{t('adm.recipe')}: {current.recipeName}
|
||||
</span>
|
||||
) : null}
|
||||
<span>
|
||||
{t('adm.rating')}: {score(current).toFixed(1)} (
|
||||
{ratings[`photo:${current.id}`]?.n ?? 0})
|
||||
</span>
|
||||
<span>
|
||||
{t('adm.owner')}: {current.email}
|
||||
</span>
|
||||
<span>
|
||||
{t('adm.uploaded')}: {new Date(current.createdAt).toLocaleString()}
|
||||
</span>
|
||||
<span>
|
||||
{t('adm.size')}: {Math.round(current.bytes / 1024)} KB · {current.mime}
|
||||
</span>
|
||||
</div>
|
||||
<div className="adm-choices">
|
||||
{DESTINATIONS.map((d) => (
|
||||
<label className="adm-choice" key={d.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-key={`adm-pick-${d.id}-${current.id}`}
|
||||
checked={current.slots.includes(d.id)}
|
||||
// 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' && !current.hasPreset)}
|
||||
title={d.id === 'qr' && !current.hasPreset ? t('adm.noPreset') : undefined}
|
||||
onChange={() =>
|
||||
void setSlots(
|
||||
current.id,
|
||||
current.slots.includes(d.id)
|
||||
? current.slots.filter((x) => x !== d.id)
|
||||
: [...current.slots, d.id],
|
||||
)
|
||||
}
|
||||
/>
|
||||
{t(d.key)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{/* The QR section's real artefact is the code, not
|
||||
the photo, so it is drawn here when the frame is
|
||||
in that section and has a look to hand out. */}
|
||||
{current.hasPreset && current.slots.includes('qr') ? (
|
||||
<div className="adm-qr adm-qr-inline" data-key={`adm-qr-${current.id}`}>
|
||||
<QrCode
|
||||
url={new URL(api.presetUrl(current.id), window.location.origin).href}
|
||||
size={112}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="btn adm-danger"
|
||||
data-key={`adm-del-${current.id}`}
|
||||
disabled={busy}
|
||||
onClick={() => void remove(current.id)}
|
||||
>
|
||||
{t('adm.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="hint adm-row-empty">{t('adm.bigEmpty')}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* The album's strip: one thumbnail per frame, in the order
|
||||
the filters and the sort chose. */}
|
||||
<div className="adm-filmstrip" data-key="adm-filmstrip">
|
||||
{strip.map((p) => (
|
||||
<h3 className="adm-row-title">
|
||||
{t('adm.sections')}
|
||||
<span className="adm-row-count">{sectionPhotos.length}</span>
|
||||
</h3>
|
||||
<div className="adm-album-list" data-key="adm-sections">
|
||||
{SECTIONS.map((d) => (
|
||||
<button
|
||||
key={p.id}
|
||||
key={d.id}
|
||||
type="button"
|
||||
className={`adm-thumb${current?.id === p.id ? ' on' : ''}`}
|
||||
data-key={`adm-thumb-${p.id}`}
|
||||
onClick={() => setSel(p.id)}
|
||||
className={`adm-album${section === d.id ? ' on' : ''}`}
|
||||
data-key={`adm-section-${d.id}`}
|
||||
onClick={() => setSection(d.id)}
|
||||
>
|
||||
<img src={api.photoUrl(p.id)} alt="" loading="lazy" />
|
||||
<span className="adm-thumb-cap">#{p.id}</span>
|
||||
<span className="adm-album-name">{t(d.key)}</span>
|
||||
<span className="adm-album-count">{photos.filter((p) => p.slots.includes(d.id)).length}</span>
|
||||
</button>
|
||||
))}
|
||||
{strip.length === 0 ? <p className="hint adm-row-empty">{t('adm.empty')}</p> : null}
|
||||
</div>
|
||||
<div className="adm-grid" data-key="adm-section-photos">
|
||||
{sectionPhotos.map(card)}
|
||||
</div>
|
||||
{sectionPhotos.length === 0 ? <p className="hint adm-row-empty">{t('adm.rowEmpty')}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -166,7 +166,8 @@ 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.albums': 'User albums',
|
||||
'adm.uploads': 'Uploaded photos',
|
||||
'adm.sections': 'Landing sections',
|
||||
'adm.albumAll': 'All photos',
|
||||
'adm.filterName': 'Filter by user name',
|
||||
'adm.sort': 'Sort',
|
||||
@@ -177,7 +178,6 @@ export const en: Dict = {
|
||||
'adm.ratingAll': 'Any rating',
|
||||
'adm.ratingAtLeast': '{n}★ and up',
|
||||
'adm.recipe': 'Recipe',
|
||||
'adm.bigEmpty': 'Pick a thumbnail to view.',
|
||||
'adm.pickStrip': 'Film strip',
|
||||
'adm.pickTester': 'Live preset tester',
|
||||
'adm.pickCreator': 'Custom recipe creator',
|
||||
|
||||
@@ -170,7 +170,8 @@ 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.albums': 'Album người dùng',
|
||||
'adm.uploads': 'Ảnh đã tải lên',
|
||||
'adm.sections': 'Mục trên landing',
|
||||
'adm.albumAll': 'Tất cả ảnh',
|
||||
'adm.filterName': 'Lọc theo tên người dùng',
|
||||
'adm.sort': 'Sắp xếp',
|
||||
@@ -181,7 +182,6 @@ export const vi = {
|
||||
'adm.ratingAll': 'Mọi mức xếp hạng',
|
||||
'adm.ratingAtLeast': 'Từ {n} sao',
|
||||
'adm.recipe': 'Công thức',
|
||||
'adm.bigEmpty': 'Chọn một ảnh để xem.',
|
||||
'adm.pickStrip': 'Dải phim',
|
||||
'adm.pickTester': 'Live preset tester',
|
||||
'adm.pickCreator': 'Custom recipe creator',
|
||||
|
||||
@@ -475,10 +475,10 @@ 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; }
|
||||
/* Curating pictures: the albums on the left, the chosen frame and its strip of
|
||||
thumbnails on the right. The album column is the narrow one — it is a list of
|
||||
names — and scrolls on its own so the strip beside it never moves. */
|
||||
.adm-cols { display: grid; grid-template-columns: minmax(200px, 280px) minmax(0, 1fr); gap: 16px; align-items: start; }
|
||||
/* Curating pictures: two album columns of the same shape — the uploads (one
|
||||
album per account) and the landing (one album per section). Each column
|
||||
lists its albums and then draws the open album's frames. */
|
||||
.adm-cols { display: grid; grid-template-columns: repeat(2, 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; }
|
||||
/* The filters over the album list: one name box, two pickers. */
|
||||
@@ -494,7 +494,9 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
|
||||
}
|
||||
.adm-filters { display: flex; gap: 6px; }
|
||||
.adm-filters .adm-input { min-width: 0; }
|
||||
.adm-album-list { display: flex; flex-direction: column; gap: 4px; max-height: 62vh; overflow-y: auto; padding-right: 2px; }
|
||||
/* A shelf of albums: it scrolls on its own so the frames below stay reachable
|
||||
however many accounts or sections there are. */
|
||||
.adm-album-list { display: flex; flex-direction: column; gap: 4px; max-height: 38vh; overflow-y: auto; padding-right: 2px; }
|
||||
.adm-album {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -514,39 +516,6 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
|
||||
.adm-album.on { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
|
||||
.adm-album-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.adm-album-count { font-family: var(--mono); font-size: 11px; }
|
||||
/* The frame that is up: the picture on the left, everything about it on the
|
||||
right — including the four landing sections it may appear in. */
|
||||
.adm-big {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(220px, 300px);
|
||||
gap: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
padding: 10px;
|
||||
}
|
||||
.adm-big-img { width: 100%; aspect-ratio: 4 / 3; object-fit: contain; border-radius: var(--radius-sm); background: var(--bg-sunken); }
|
||||
.adm-big-side { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
|
||||
.adm-qr-inline { border-radius: var(--radius-sm); }
|
||||
/* The album's strip: thumbnails in one row, scrolled sideways when they
|
||||
overflow the pane rather than wrapping into a wall of pictures. */
|
||||
.adm-filmstrip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
|
||||
.adm-thumb {
|
||||
flex: 0 0 132px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--bg-sunken);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.adm-thumb:hover { border-color: var(--accent-line); }
|
||||
.adm-thumb.on { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||||
.adm-thumb img { display: block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover; }
|
||||
.adm-thumb-cap { display: block; padding: 3px 6px; font-family: var(--mono); font-size: 10.5px; }
|
||||
/* 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; }
|
||||
@@ -569,7 +538,6 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
|
||||
.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;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user