Ein Kontextmenü auf jeder Kachel — mitsamt Bildauswahl
Gesehen, Favorit und Weglegen gab es ausschliesslich auf der Detailseite. In einer Übersicht mit zwanzig Titeln hiess "die hab ich alle gesehen" vierzig Seitenwechsel. Im ganzen Quelltext stand kein einziger contextmenu-Zuhörer. Drei Wege hinein, weil es drei Arten gibt zu bedienen: Rechtsklick für die Maus, Shift+F10 und die Kontextmenü-Taste für die Tastatur, langer Druck für den Finger. Nur den Rechtsklick anzubieten hiesse, das Menü auf dem Telefon zu verstecken. Der lange Druck bricht bei jeder Bewegung ab, sonst löst Scrollen ihn aus. Die Aktionen wirken auf der Kachel selbst. Die Liste neu zu laden verlöre Bildlauf und Filter — für einen Haken. Dazu die Bildauswahl. Es gab genau einen Bild-Endpunkt, und der lieferte nur aus: wählte TMDB ein schlechtes Plakat, blieb es. Jetzt stehen Plakat, Hintergrund und Schriftzug zur Wahl, dazu eigene Bilder bis 10 MB. Die Kandidaten kosten nichts — TMDB liefert sie beim Einzelabruf ohnehin mit, und bisher wurde alles ausser dem Schriftzug weggeworfen. Drei Merker je Titel, nicht einer: man kann ein eigenes Plakat wollen und trotzdem automatische Hintergründe. Ohne sie überschriebe das nächste Auffrischen jede Handauswahl, und wer einmal ein besseres Plakat gesucht hat, suchte es jedes Mal neu. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e1399c808f
commit
eb63271e92
+38
-1
@@ -169,7 +169,12 @@ export class ApiFehler extends Error {
|
||||
async function anfrage<T>(pfad: string, opts: RequestInit = {}): Promise<T> {
|
||||
const kopf = new Headers(opts.headers);
|
||||
if (sitzung.token) kopf.set('Authorization', `Bearer ${sitzung.token}`);
|
||||
if (opts.body && !kopf.has('Content-Type')) kopf.set('Content-Type', 'application/json');
|
||||
// **Bei FormData nicht setzen.** Der Browser schreibt dort
|
||||
// `multipart/form-data` mitsamt der Grenzmarkierung selbst; ein von Hand
|
||||
// gesetzter Kopf hätte keine, und der Server fände kein einziges Feld.
|
||||
if (opts.body && !(opts.body instanceof FormData) && !kopf.has('Content-Type')) {
|
||||
kopf.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
const antwort = await fetch(`/api/v1${pfad}`, { ...opts, headers: kopf });
|
||||
|
||||
@@ -479,6 +484,38 @@ export const uebersetzungenHolen = (language: string) =>
|
||||
export const aehnliche = (id: string) =>
|
||||
anfrage<Seite<Film>>(`/movies/${id}/similar?take=12`);
|
||||
|
||||
export type Bildkandidat = {
|
||||
kind: 'poster' | 'backdrop' | 'logo';
|
||||
url: string; thumbUrl: string;
|
||||
language: string | null; vote: number | null;
|
||||
};
|
||||
|
||||
export type Bildauswahl = {
|
||||
current: {
|
||||
poster: string | null; backdrop: string | null; logo: string | null;
|
||||
posterPinned: boolean; backdropPinned: boolean; logoPinned: boolean;
|
||||
};
|
||||
candidates: Bildkandidat[];
|
||||
};
|
||||
|
||||
export const bildauswahl = (art: 'movies' | 'series', id: string) =>
|
||||
anfrage<Bildauswahl>(`/${art}/${id}/artwork`);
|
||||
|
||||
export const bildSetzen = (art: 'movies' | 'series', id: string, kind: string, url: string) =>
|
||||
anfrage<void>(`/${art}/${id}/artwork`,
|
||||
{ method: 'PUT', body: JSON.stringify({ kind, url }) });
|
||||
|
||||
/** Ein eigenes Bild hochladen. Ohne `Content-Type` — den setzt der Browser
|
||||
* mitsamt der Grenzmarkierung selbst, und von Hand gesetzt wäre er falsch. */
|
||||
export const bildHochladen = async (
|
||||
art: 'movies' | 'series', id: string, kind: string, datei: File,
|
||||
): Promise<{ image: string }> => {
|
||||
const inhalt = new FormData();
|
||||
inhalt.append('datei', datei);
|
||||
return anfrage<{ image: string }>(`/${art}/${id}/artwork?kind=${kind}`,
|
||||
{ method: 'POST', body: inhalt });
|
||||
};
|
||||
|
||||
export type Protokolltag = { date: string; bytes: number };
|
||||
|
||||
/** Welche Tage protokolliert sind, neueste zuerst. */
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { bildAdresse, bildauswahl, bildSetzen, bildHochladen, type Bildkandidat } from './api';
|
||||
import { knopf, fehlerzeile } from './util';
|
||||
import { icons } from './icons';
|
||||
|
||||
type Bildart = 'poster' | 'backdrop' | 'logo';
|
||||
|
||||
const ARTEN: { art: Bildart; titel: string; verhaeltnis: string }[] = [
|
||||
{ art: 'poster', titel: 'Plakat', verhaeltnis: '2 / 3' },
|
||||
{ art: 'backdrop', titel: 'Hintergrund', verhaeltnis: '16 / 9' },
|
||||
{ art: 'logo', titel: 'Schriftzug', verhaeltnis: '16 / 9' },
|
||||
];
|
||||
|
||||
/** Plakat, Hintergrund und Schriftzug von Hand wählen.
|
||||
*
|
||||
* **Erreichbar aus der Übersicht, nicht erst von der Detailseite.** Bei
|
||||
* zwanzig Titeln mit schlechtem Plakat waren das vierzig Seitenwechsel.
|
||||
*
|
||||
* **Die Kandidaten kosten nichts.** TMDB liefert sie beim Einzelabruf ohnehin
|
||||
* mit; bisher wurde alles ausser dem Schriftzug weggeworfen.
|
||||
*/
|
||||
export function bilddialog(art: 'movies' | 'series', id: string, titel: string): void {
|
||||
const d = document.createElement('dialog');
|
||||
d.className = 'dlg dlg-breit';
|
||||
|
||||
const kopf = document.createElement('div');
|
||||
kopf.className = 'dlg-kopf';
|
||||
const h = document.createElement('h2');
|
||||
h.textContent = `Bilder — ${titel}`;
|
||||
const zu = document.createElement('button');
|
||||
zu.className = 'dlg-zu';
|
||||
zu.innerHTML = icons.schliessen;
|
||||
zu.setAttribute('aria-label', 'Schliessen');
|
||||
zu.onclick = () => d.close();
|
||||
kopf.append(h, zu);
|
||||
|
||||
const koerper = document.createElement('div');
|
||||
koerper.className = 'dlg-koerper';
|
||||
koerper.innerHTML = '<div class="spinner"></div>';
|
||||
|
||||
d.append(kopf, koerper);
|
||||
d.addEventListener('close', () => d.remove());
|
||||
d.addEventListener('click', e => { if (e.target === d) d.close(); });
|
||||
document.body.appendChild(d);
|
||||
d.showModal();
|
||||
|
||||
void laden();
|
||||
|
||||
async function laden(): Promise<void> {
|
||||
let auswahl;
|
||||
try {
|
||||
auswahl = await bildauswahl(art, id);
|
||||
} catch (fehler) {
|
||||
koerper.innerHTML = '';
|
||||
koerper.appendChild(fehlerzeile((fehler as Error).message));
|
||||
return;
|
||||
}
|
||||
|
||||
koerper.innerHTML = '';
|
||||
|
||||
if (auswahl.candidates.length === 0) {
|
||||
const hinweis = document.createElement('p');
|
||||
hinweis.className = 'hinweis';
|
||||
// Ohne Zuordnung gibt es nichts zu wählen — dann ist die Zuordnung das
|
||||
// Problem, nicht das Bild, und das soll hier auch so dastehen.
|
||||
hinweis.textContent = 'Keine Bilder gefunden. Ist der Titel richtig zugeordnet?';
|
||||
koerper.appendChild(hinweis);
|
||||
}
|
||||
|
||||
ARTEN.forEach(({ art: bildart, titel: name, verhaeltnis }) => {
|
||||
const abschnitt = document.createElement('section');
|
||||
abschnitt.className = 'bildblock';
|
||||
|
||||
const kopfzeile = document.createElement('div');
|
||||
kopfzeile.className = 'knopfreihe';
|
||||
const u = document.createElement('h3');
|
||||
u.textContent = name;
|
||||
kopfzeile.appendChild(u);
|
||||
|
||||
// Eigenes Bild — ein verstecktes Dateifeld hinter einem Knopf, weil
|
||||
// das native Dateifeld in jedem Browser anders aussieht.
|
||||
const feld = document.createElement('input');
|
||||
feld.type = 'file';
|
||||
feld.accept = 'image/*';
|
||||
feld.className = 'hidden';
|
||||
const eigenes = knopf('Eigenes Bild…', 'btn');
|
||||
eigenes.type = 'button';
|
||||
eigenes.onclick = () => feld.click();
|
||||
feld.onchange = async () => {
|
||||
const datei = feld.files?.[0];
|
||||
if (!datei) return;
|
||||
eigenes.disabled = true;
|
||||
try { await bildHochladen(art, id, bildart, datei); await laden(); }
|
||||
catch (fehler) {
|
||||
abschnitt.appendChild(fehlerzeile((fehler as Error).message));
|
||||
eigenes.disabled = false;
|
||||
}
|
||||
};
|
||||
kopfzeile.append(eigenes, feld);
|
||||
abschnitt.appendChild(kopfzeile);
|
||||
|
||||
const gitter = document.createElement('div');
|
||||
gitter.className = 'bildgitter';
|
||||
gitter.style.setProperty('--verhaeltnis', verhaeltnis);
|
||||
|
||||
const aktuell = bildart === 'poster' ? auswahl.current.poster
|
||||
: bildart === 'backdrop' ? auswahl.current.backdrop
|
||||
: auswahl.current.logo;
|
||||
|
||||
const passende = auswahl.candidates.filter(k => k.kind === bildart);
|
||||
if (passende.length === 0 && !aktuell) {
|
||||
const leer = document.createElement('p');
|
||||
leer.className = 'winzig';
|
||||
leer.textContent = 'Nichts zur Auswahl.';
|
||||
abschnitt.append(leer);
|
||||
koerper.appendChild(abschnitt);
|
||||
return;
|
||||
}
|
||||
|
||||
// Das gesetzte Bild zuerst, damit sichtbar ist, wovon man weggeht.
|
||||
if (aktuell) gitter.appendChild(kachelFuer(null, bildAdresse(aktuell)!, true, bildart));
|
||||
passende.forEach(k => gitter.appendChild(kachelFuer(k, k.thumbUrl, false, bildart)));
|
||||
|
||||
abschnitt.appendChild(gitter);
|
||||
koerper.appendChild(abschnitt);
|
||||
});
|
||||
}
|
||||
|
||||
function kachelFuer(
|
||||
kandidat: Bildkandidat | null, adresse: string, gesetzt: boolean, bildart: Bildart,
|
||||
): HTMLElement {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'bildwahl' + (gesetzt ? ' an' : '');
|
||||
b.title = gesetzt ? 'Aktuell gesetzt'
|
||||
: [kandidat?.language?.toUpperCase(), kandidat?.vote ? `★ ${kandidat.vote.toFixed(1)}` : null]
|
||||
.filter(Boolean).join(' · ') || 'Auswählen';
|
||||
|
||||
const bild = document.createElement('img');
|
||||
bild.src = adresse;
|
||||
bild.alt = '';
|
||||
bild.loading = 'lazy';
|
||||
b.appendChild(bild);
|
||||
|
||||
if (gesetzt) {
|
||||
const marke = document.createElement('span');
|
||||
marke.className = 'bildwahl-marke';
|
||||
marke.innerHTML = icons.haken;
|
||||
b.appendChild(marke);
|
||||
b.disabled = true;
|
||||
return b;
|
||||
}
|
||||
|
||||
b.onclick = async () => {
|
||||
b.disabled = true;
|
||||
try { await bildSetzen(art, id, bildart, kandidat!.url); await laden(); }
|
||||
catch (fehler) {
|
||||
b.after(fehlerzeile((fehler as Error).message));
|
||||
b.disabled = false;
|
||||
}
|
||||
};
|
||||
return b;
|
||||
}
|
||||
}
|
||||
+90
-1
@@ -1,6 +1,11 @@
|
||||
import { bildAdresse, type Film, type Serie, type Angefangen, type Stand } from './api';
|
||||
import {
|
||||
bildAdresse, gesehen, favorit, weglegen, istAdmin,
|
||||
type Film, type Serie, type Angefangen, type Stand,
|
||||
} from './api';
|
||||
import { esc, anteil } from './util';
|
||||
import { icons } from './icons';
|
||||
import { kontextmenue, type Menuepunkt } from './menue';
|
||||
import { bilddialog } from './bilder';
|
||||
|
||||
/** Was eine Kachel zeigt — unabhängig davon, woher sie kommt. */
|
||||
export type Kachel = {
|
||||
@@ -59,6 +64,11 @@ export function kachel(k: Kachel, oeffnen: (art: 'filme' | 'serien', id: string)
|
||||
knopf.className = 'kachel';
|
||||
knopf.onclick = () => oeffnen(k.art, k.id);
|
||||
|
||||
// **Das Kontextmenü an jeder Kachel, überall.** Gesehen, Favorit und
|
||||
// Weglegen gab es nur auf der Detailseite — in einer Übersicht mit zwanzig
|
||||
// Titeln hiess „die hab ich alle gesehen" vierzig Seitenwechsel.
|
||||
kontextmenue(knopf, () => kachelpunkte(k, knopf, oeffnen));
|
||||
|
||||
const rahmen = document.createElement('div');
|
||||
rahmen.className = 'plakat';
|
||||
rahmen.setAttribute('role', 'img');
|
||||
@@ -172,3 +182,82 @@ export function reihe(
|
||||
bereich.append(kopf, streifen);
|
||||
return bereich;
|
||||
}
|
||||
|
||||
|
||||
/** Was im Kontextmenü einer Kachel steht.
|
||||
*
|
||||
* **Aktionen wirken sofort auf der Kachel**, ohne Seitenwechsel und ohne
|
||||
* Neuaufbau der Liste. Den Zustand hier zu spiegeln statt neu zu laden ist
|
||||
* nicht nur schneller — ein Neuaufbau verlöre Bildlauf und Filter.
|
||||
*/
|
||||
function kachelpunkte(
|
||||
k: Kachel,
|
||||
knopf: HTMLElement,
|
||||
oeffnen: (art: 'filme' | 'serien', id: string) => void,
|
||||
): Menuepunkt[] {
|
||||
const istGesehen = k.stand?.watched === true;
|
||||
|
||||
const punkte: Menuepunkt[] = [
|
||||
{
|
||||
text: istGesehen ? 'Als ungesehen markieren' : 'Als gesehen markieren',
|
||||
icon: 'hakenKreis',
|
||||
tun: async () => {
|
||||
await gesehen(k.id, !istGesehen);
|
||||
// **Die Kachel spiegelt den neuen Zustand selbst.** Die Liste neu zu
|
||||
// laden verlöre Bildlauf und Filter — für einen Haken.
|
||||
if (k.stand) k.stand.watched = !istGesehen;
|
||||
hakenZeigen(knopf, !istGesehen);
|
||||
},
|
||||
},
|
||||
{
|
||||
text: k.stand?.favourite ? 'Aus Favoriten entfernen' : 'Zu Favoriten',
|
||||
icon: 'stern',
|
||||
tun: async () => {
|
||||
const neuerWert = !k.stand?.favourite;
|
||||
await favorit(k.id, neuerWert);
|
||||
if (k.stand) k.stand.favourite = neuerWert;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Nur anbieten, wenn es dort auch steht — ein Knopf, der nichts tut, ist
|
||||
// schlechter als keiner.
|
||||
if (k.stand && !k.stand.watched && k.stand.positionSeconds > 0) {
|
||||
punkte.push({
|
||||
text: 'Aus Weiterschauen nehmen',
|
||||
tun: async () => {
|
||||
await weglegen(k.id);
|
||||
if (k.stand) k.stand.dismissed = true;
|
||||
knopf.querySelector('.fortschritt')?.remove();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
punkte.push({ text: 'Details', icon: 'mehr', tun: () => oeffnen(k.art, k.id) });
|
||||
|
||||
if (istAdmin()) {
|
||||
punkte.push({
|
||||
text: 'Bilder bearbeiten',
|
||||
icon: 'stift',
|
||||
tun: () => bilddialog(k.art === 'serien' ? 'series' : 'movies', k.id, k.titel),
|
||||
});
|
||||
}
|
||||
|
||||
return punkte;
|
||||
}
|
||||
|
||||
/** Setzt oder entfernt den Haken, ohne die Kachel neu zu bauen. */
|
||||
function hakenZeigen(knopf: HTMLElement, an: boolean): void {
|
||||
const plakat = knopf.querySelector('.plakat');
|
||||
if (!plakat) return;
|
||||
|
||||
plakat.querySelector('.ecke.haken')?.remove();
|
||||
knopf.querySelector('.fortschritt')?.remove();
|
||||
|
||||
if (!an) return;
|
||||
const ecke = document.createElement('span');
|
||||
ecke.className = 'ecke haken';
|
||||
ecke.title = 'Gesehen';
|
||||
ecke.innerHTML = icons.haken;
|
||||
plakat.appendChild(ecke);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { icons } from './icons';
|
||||
|
||||
export type Menuepunkt = {
|
||||
text: string;
|
||||
icon?: keyof typeof icons;
|
||||
/** Rot und unten abgesetzt — für alles, was etwas wegnimmt. */
|
||||
gefaehrlich?: boolean;
|
||||
tun: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
/** Ein Kontextmenü auf einer Kachel.
|
||||
*
|
||||
* **Warum es das braucht.** Gesehen, Favorit und Weglegen gab es
|
||||
* ausschliesslich auf der Detailseite. In einer Übersicht mit zwanzig Titeln
|
||||
* hiess „die hab ich alle gesehen" vierzig Seitenwechsel.
|
||||
*
|
||||
* **Drei Wege hinein, weil es drei Arten gibt zu bedienen.** Rechtsklick für
|
||||
* die Maus, `Shift+F10` und die Kontextmenü-Taste für die Tastatur, langer
|
||||
* Druck für den Finger. Nur den Rechtsklick anzubieten hiesse, das Menü auf
|
||||
* dem Telefon zu verstecken.
|
||||
*/
|
||||
export function kontextmenue(ziel: HTMLElement, punkte: () => Menuepunkt[]): void {
|
||||
ziel.addEventListener('contextmenu', e => {
|
||||
e.preventDefault();
|
||||
oeffnen(e.clientX, e.clientY, punkte());
|
||||
});
|
||||
|
||||
// Die Kontextmenü-Taste und Shift+F10 — dieselbe Geste ohne Maus.
|
||||
ziel.addEventListener('keydown', e => {
|
||||
if (e.key !== 'ContextMenu' && !(e.key === 'F10' && e.shiftKey)) return;
|
||||
e.preventDefault();
|
||||
const kasten = ziel.getBoundingClientRect();
|
||||
oeffnen(kasten.left + kasten.width / 2, kasten.top + kasten.height / 2, punkte());
|
||||
});
|
||||
|
||||
// Langer Druck. 500 ms, und jede Bewegung bricht ab — sonst löst Scrollen
|
||||
// das Menü aus, und das Scrollen hört dabei auf.
|
||||
let druck: number | undefined;
|
||||
let start = { x: 0, y: 0 };
|
||||
|
||||
ziel.addEventListener('touchstart', e => {
|
||||
const f = e.touches[0];
|
||||
if (!f) return;
|
||||
start = { x: f.clientX, y: f.clientY };
|
||||
druck = window.setTimeout(() => oeffnen(start.x, start.y, punkte()), 500);
|
||||
}, { passive: true });
|
||||
|
||||
const abbrechen = () => { if (druck !== undefined) { clearTimeout(druck); druck = undefined; } };
|
||||
ziel.addEventListener('touchend', abbrechen);
|
||||
ziel.addEventListener('touchcancel', abbrechen);
|
||||
ziel.addEventListener('touchmove', e => {
|
||||
const f = e.touches[0];
|
||||
if (!f) return;
|
||||
if (Math.abs(f.clientX - start.x) > 8 || Math.abs(f.clientY - start.y) > 8) abbrechen();
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
/** Das offene Menü — immer höchstens eines. */
|
||||
let offen: HTMLElement | null = null;
|
||||
|
||||
export function menueSchliessen(): void {
|
||||
offen?.remove();
|
||||
offen = null;
|
||||
}
|
||||
|
||||
function oeffnen(x: number, y: number, punkte: Menuepunkt[]): void {
|
||||
menueSchliessen();
|
||||
if (punkte.length === 0) return;
|
||||
|
||||
const menue = document.createElement('div');
|
||||
menue.className = 'kontextmenue';
|
||||
menue.setAttribute('role', 'menu');
|
||||
|
||||
punkte.forEach(p => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'kontextpunkt' + (p.gefaehrlich ? ' gefahr' : '');
|
||||
b.setAttribute('role', 'menuitem');
|
||||
if (p.icon) {
|
||||
const i = document.createElement('span');
|
||||
i.className = 'icon';
|
||||
i.innerHTML = icons[p.icon];
|
||||
b.appendChild(i);
|
||||
}
|
||||
const t = document.createElement('span');
|
||||
t.textContent = p.text;
|
||||
b.appendChild(t);
|
||||
b.onclick = async () => { menueSchliessen(); await p.tun(); };
|
||||
menue.appendChild(b);
|
||||
});
|
||||
|
||||
document.body.appendChild(menue);
|
||||
offen = menue;
|
||||
|
||||
// **Erst anhängen, dann messen.** Vorher hat das Menü keine Grösse, und
|
||||
// die Prüfung auf den Bildschirmrand liefe gegen null.
|
||||
const kasten = menue.getBoundingClientRect();
|
||||
const links = Math.min(x, window.innerWidth - kasten.width - 8);
|
||||
const oben = Math.min(y, window.innerHeight - kasten.height - 8);
|
||||
menue.style.left = `${Math.max(8, links)}px`;
|
||||
menue.style.top = `${Math.max(8, oben)}px`;
|
||||
|
||||
menue.querySelector<HTMLElement>('button')?.focus();
|
||||
}
|
||||
|
||||
// Ein Klick daneben, Escape oder ein Bildlauf schliessen. Einmal global
|
||||
// registriert und nicht je Kachel — sonst hingen an vierhundert Kacheln
|
||||
// zwölfhundert Zuhörer am Dokument.
|
||||
document.addEventListener('click', e => {
|
||||
if (offen && !offen.contains(e.target as Node)) menueSchliessen();
|
||||
});
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') menueSchliessen(); });
|
||||
window.addEventListener('scroll', menueSchliessen, { passive: true });
|
||||
@@ -870,3 +870,62 @@ h2 { font-size: var(--t-abschnitt); font-weight: 700; letter-spacing: -.015em; m
|
||||
.hero-inhalt { padding: var(--s-loose); max-width: none; }
|
||||
.hero-punkte { right: var(--s-loose); bottom: var(--s-loose); }
|
||||
}
|
||||
|
||||
/* --- Kontextmenü ---------------------------------------------------------
|
||||
*
|
||||
* Gesehen, Favorit und Weglegen gab es nur auf der Detailseite. In einer
|
||||
* Übersicht mit zwanzig Titeln hiess „die hab ich alle gesehen" vierzig
|
||||
* Seitenwechsel. */
|
||||
|
||||
.kontextmenue {
|
||||
position: fixed; z-index: 100;
|
||||
min-width: 220px; padding: var(--s-tight);
|
||||
background: var(--elevated);
|
||||
border: 1px solid var(--line); border-radius: var(--r-flaeche);
|
||||
box-shadow: var(--schatten-dialog);
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
|
||||
.kontextpunkt {
|
||||
display: flex; align-items: center; gap: var(--s-snug);
|
||||
background: none; border: 0; border-radius: var(--r-knopf);
|
||||
padding: 8px var(--s-regular); text-align: left;
|
||||
color: var(--text-dim); font-size: var(--t-normal);
|
||||
}
|
||||
.kontextpunkt:hover { background: var(--raised); color: var(--text); }
|
||||
.kontextpunkt svg { width: 17px; height: 17px; }
|
||||
.kontextpunkt.gefahr { color: var(--destructive); }
|
||||
|
||||
/* --- Bildauswahl ---------------------------------------------------------- */
|
||||
|
||||
.dlg-breit { width: min(860px, 100vw - 48px); }
|
||||
|
||||
.bildblock { display: flex; flex-direction: column; gap: var(--s-snug); }
|
||||
.bildblock h3 { margin: 0; font-size: var(--t-gross); font-weight: 600; flex: 1; }
|
||||
|
||||
.bildgitter {
|
||||
display: grid; gap: var(--s-snug);
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
max-height: 260px; overflow-y: auto;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.bildwahl {
|
||||
position: relative; padding: 0; border: 2px solid transparent;
|
||||
border-radius: var(--r-karte); overflow: hidden;
|
||||
background: var(--placeholder);
|
||||
aspect-ratio: var(--verhaeltnis, 2 / 3);
|
||||
}
|
||||
.bildwahl img { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||
.bildwahl:hover { border-color: var(--accent); }
|
||||
/* Das gesetzte Bild ist markiert und nicht anklickbar — es ist schon gewählt. */
|
||||
.bildwahl.an { border-color: var(--accent); opacity: 1; }
|
||||
.bildwahl:disabled { cursor: default; }
|
||||
|
||||
.bildwahl-marke {
|
||||
position: absolute; right: 4px; top: 4px;
|
||||
display: grid; place-items: center;
|
||||
width: 22px; height: 22px; border-radius: 50%;
|
||||
background: var(--accent); color: #fff;
|
||||
}
|
||||
.bildwahl-marke svg { width: 14px; height: 14px; }
|
||||
|
||||
Reference in New Issue
Block a user