feat(logos): Infozeile mit Melden, Löschen und Admin-Aktionen
Task 9: die Infozeile zum gewählten Logo unter dem Raster (Spec § 3 und § 6). - logos/logo-info.ts (neu): „<Name> · von <Anzeigename> · ♥ <Zahl>“ und „Melden“ (auch für Gäste; ein ausgeblendetes Logo lässt sich nicht melden). Der Hochlader sieht „Löschen“ mit Rückfrage (Titel mit Namen, Folgen im Text), Admins „<n> Meldungen“ mit den Gründen als Text (listLogoReports, einmal je Logo und Stand), „Ausblenden“/„Einblenden“ und „Löschen“. Gelöscht: deleteLogo, forgetLogo, Kachel weg, onSelect(null); ein-/ausgeblendet: setLogoVisibility, forgetLogo (ein Null-Bild bleibt nicht im Zwischen- speicher), die Zeile des Pickers wird fortgeschrieben (Einblenden setzt reportCount auf 0). Gewähltes Logo nicht auffindbar: „Das gewählte Logo ist nicht mehr verfügbar.“ (ein Netzfehler von getLogo sagt nichts). Zugleich die Meldezeile (role="alert") für Fehler von Herz, Hochladen und diesen Aktionen; ein Wechsel der Auswahl räumt sie ab. Immer nur eine Aktion zugleich; die Rechte werden nach der Rückfrage noch einmal geprüft; der Aufbau steht einmal da, update() ändert nur Texte und Sichtbarkeit (Fokus bleibt). - logos/logo-picker.ts: hängt die Infozeile als Letztes unter das Raster (info.el). Herz- und Upload-Fehler gehen in die Infozeile, der Statusbereich des Pickers bleibt den Ladefehlern der Liste vorbehalten (ein Klick auf ein Herz löscht ihren Text nicht mehr). Aus dem Review von Task 8: requireLogin, das wirft, zeigt den Grund statt einer unbehandelten Ablehnung; select() nach dispose() ist wirkungslos. Löschen entfernt Kachel, Zeile, Herz und Eintrag der Liste, setzt den Cursor von „Mehr laden“ auf das Logo davor (Appwrite lehnt einen Cursor auf eine gelöschte Zeile ab), hebt die Auswahl über onSelect(null) auf und holt den Fokus nach „Kein Logo“, wenn er auf „Löschen“ lag. LogoPickerDeps erweitert um reportLogo, deleteLogo, setLogoVisibility, listLogoReports, confirm und reportDialog. - community/report-dialog.ts: subject 'card' (Standard, unverändert) oder 'logo': Überschrift „Logo melden“, Einleitung „dieses Logo“, vier Gründe ohne „Echte Kartendaten“, Duplikat-Dank „Danke, du hast dieses Logo schon gemeldet.“. Der ganze Dialog geht durch t()/msg(); Fehler des Dienstes über apiErrorText. - logos/logos.css: das Herz sitzt in der Textzeile unter der Bildfläche (nicht mehr auf dem Logo, folgt dem Theme), die Kachel füllt die Zelle (height: 100%), damit „Kein Logo“ so hoch ist wie sein Nachbar; Stile der Infozeile. - editor/editor-page.ts: Aus dem Review von Task 8: der Ladefehler der Logos verschwindet, sobald ein Logo danach lädt, aber nur der, den der Logo-Zustand selbst ins Fehlerfeld geschrieben hat; jeder andere Text im Feld bleibt. - i18n/en.ts: englische Texte zu allen neuen Beschriftungen und zum Melden-Dialog. - Tests: logo-info.test.ts (neu), logo-picker.test.ts (Infozeile, Löschen, Ein-/Ausblenden, Fehler, Cursor, Fokus), report-dialog.test.ts (Logo und Englisch), editor-page.test.ts (Fehlerfeld). Vitest 52 Dateien, 1764 Tests grün, tsc und Build sauber. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4cf9fe192d
commit
a75be2d44c
10 files changed
+1976
-81
No files matched your search
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
|
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
|
||||||
vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} }));
|
vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} }));
|
||||||
|
|
||||||
|
import { setLang } from '../i18n/i18n';
|
||||||
import { ApiError, type ReportReason } from './api';
|
import { ApiError, type ReportReason } from './api';
|
||||||
import { REPORT_REASONS, reportDialog, type ReportOutcome } from './report-dialog';
|
import { REPORT_REASONS, reportDialog, type ReportOutcome } from './report-dialog';
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ const settle = async () => {
|
|||||||
for (let i = 0; i < 30; i++) await Promise.resolve();
|
for (let i = 0; i < 30; i++) await Promise.resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
function open(opts: { cardTitle?: string; signal?: AbortSignal } = {}): Promise<ReportOutcome> {
|
function open(opts: { cardTitle?: string; signal?: AbortSignal; subject?: 'card' | 'logo' } = {}): Promise<ReportOutcome> {
|
||||||
return reportDialog({ send, ...opts });
|
return reportDialog({ send, ...opts });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,3 +240,158 @@ describe('Melden-Dialog', () => {
|
|||||||
expect(dialogEl()).toBeNull();
|
expect(dialogEl()).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Melden-Dialog für Logos (subject: „logo“)', () => {
|
||||||
|
const reasonLabels = () => [...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent);
|
||||||
|
|
||||||
|
it('Überschrift „Logo melden“, Einleitung nennt „dieses Logo“, vier Gründe ohne „Echte Kartendaten“', () => {
|
||||||
|
void open({ subject: 'logo' });
|
||||||
|
expect(dialogEl()?.open).toBe(true);
|
||||||
|
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Logo melden');
|
||||||
|
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Warum möchtest du dieses Logo melden?');
|
||||||
|
expect(reasonLabels()).toEqual(['Unangemessen', 'Urheberrecht/Marke', 'Spam', 'Sonstiges']);
|
||||||
|
expect(radios().map((r) => r.value)).toEqual(['unangemessen', 'urheberrecht', 'spam', 'sonstiges']);
|
||||||
|
expect(radios().some((r) => r.checked)).toBe(false);
|
||||||
|
expect(buttonByText('Abbrechen')).toBeTruthy();
|
||||||
|
expect(buttonByText('Melden')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die Gründe der Karten bleiben unverändert (REPORT_REASONS enthält weiter „Echte Kartendaten“)', () => {
|
||||||
|
expect(REPORT_REASONS.map((r) => r.value)).toEqual(['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Titel der Karte spielt für Logos keine Rolle: die Einleitung nennt immer „dieses Logo“', () => {
|
||||||
|
void open({ subject: 'logo', cardTitle: 'Urlaub' });
|
||||||
|
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Warum möchtest du dieses Logo melden?');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['unangemessen', 'urheberrecht', 'spam', 'sonstiges'] as const)('Grund %s geht unverändert an send', async (value) => {
|
||||||
|
send.mockResolvedValue({ schonGemeldet: false });
|
||||||
|
void open({ subject: 'logo' });
|
||||||
|
choose(value);
|
||||||
|
submit();
|
||||||
|
await settle();
|
||||||
|
expect(send).toHaveBeenCalledExactlyOnceWith(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Grund: derselbe Hinweis, nichts wird gesendet', async () => {
|
||||||
|
void open({ subject: 'logo' });
|
||||||
|
submit();
|
||||||
|
await settle();
|
||||||
|
expect(errorText()).toBe('Bitte wähle einen Grund.');
|
||||||
|
expect(send).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('neue Meldung: derselbe Dank, „reported“', async () => {
|
||||||
|
send.mockResolvedValue({ schonGemeldet: false });
|
||||||
|
const result = open({ subject: 'logo' });
|
||||||
|
choose('spam');
|
||||||
|
submit();
|
||||||
|
await settle();
|
||||||
|
expect(statusText()).toBe('Danke für deine Meldung.');
|
||||||
|
buttonByText('Schließen')!.click();
|
||||||
|
await expect(result).resolves.toBe('reported');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('doppelte Meldung: „Danke, du hast dieses Logo schon gemeldet.“ und „duplicate“', async () => {
|
||||||
|
send.mockResolvedValue({ schonGemeldet: true });
|
||||||
|
const result = open({ subject: 'logo' });
|
||||||
|
choose('spam');
|
||||||
|
submit();
|
||||||
|
await settle();
|
||||||
|
expect(statusText()).toBe('Danke, du hast dieses Logo schon gemeldet.');
|
||||||
|
buttonByText('Schließen')!.click();
|
||||||
|
await expect(result).resolves.toBe('duplicate');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fehler des Dienstes erscheinen wie bei Karten im Dialog, der offen bleibt', async () => {
|
||||||
|
send.mockRejectedValueOnce(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'));
|
||||||
|
void open({ subject: 'logo' });
|
||||||
|
choose('spam');
|
||||||
|
submit();
|
||||||
|
await settle();
|
||||||
|
expect(errorText()).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
|
||||||
|
expect(dialogEl()?.open).toBe(true);
|
||||||
|
expect(buttonByText('Melden')!.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Dialog mit subject „card“ ist derselbe wie ohne Angabe', () => {
|
||||||
|
void open({ subject: 'card', cardTitle: 'Urlaub' });
|
||||||
|
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Karte melden');
|
||||||
|
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Warum möchtest du „Urlaub“ melden?');
|
||||||
|
expect(radios().map((r) => r.value)).toEqual(['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Melden-Dialog auf Englisch', () => {
|
||||||
|
beforeEach(() => setLang('en', { persist: false }));
|
||||||
|
|
||||||
|
it('Karte: Überschrift, Einleitung, Gründe, Knöpfe', () => {
|
||||||
|
void open({ cardTitle: 'Holiday' });
|
||||||
|
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Report card');
|
||||||
|
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Why do you want to report “Holiday”?');
|
||||||
|
expect(dialogEl()!.querySelector('legend')!.textContent).toBe('Reason for the report');
|
||||||
|
expect([...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([
|
||||||
|
'Inappropriate',
|
||||||
|
'Real card data',
|
||||||
|
'Copyright/trademark',
|
||||||
|
'Spam',
|
||||||
|
'Other',
|
||||||
|
]);
|
||||||
|
expect(buttonByText('Cancel')).toBeTruthy();
|
||||||
|
expect(buttonByText('Report')).toBeTruthy();
|
||||||
|
// ohne Titel
|
||||||
|
dialogEl()!.remove();
|
||||||
|
void open();
|
||||||
|
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Why do you want to report this card?');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Logo: Überschrift, Einleitung, vier Gründe, Dank und Duplikat', async () => {
|
||||||
|
send.mockResolvedValueOnce({ schonGemeldet: true });
|
||||||
|
const result = open({ subject: 'logo' });
|
||||||
|
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Report logo');
|
||||||
|
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Why do you want to report this logo?');
|
||||||
|
expect([...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([
|
||||||
|
'Inappropriate',
|
||||||
|
'Copyright/trademark',
|
||||||
|
'Spam',
|
||||||
|
'Other',
|
||||||
|
]);
|
||||||
|
submit(); // ohne Grund
|
||||||
|
await settle();
|
||||||
|
expect(errorText()).toBe('Please choose a reason.');
|
||||||
|
choose('spam');
|
||||||
|
submit();
|
||||||
|
await settle();
|
||||||
|
expect(statusText()).toBe('Thanks, you’ve already reported this logo.');
|
||||||
|
buttonByText('Close')!.click();
|
||||||
|
await expect(result).resolves.toBe('duplicate');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Dank und Duplikat bei Karten', async () => {
|
||||||
|
send.mockResolvedValueOnce({ schonGemeldet: false }).mockResolvedValueOnce({ schonGemeldet: true });
|
||||||
|
const first = open();
|
||||||
|
choose('spam');
|
||||||
|
submit();
|
||||||
|
await settle();
|
||||||
|
expect(statusText()).toBe('Thanks for your report.');
|
||||||
|
buttonByText('Close')!.click();
|
||||||
|
await first;
|
||||||
|
const second = open();
|
||||||
|
choose('spam');
|
||||||
|
submit();
|
||||||
|
await settle();
|
||||||
|
expect(statusText()).toBe('Thanks, you’ve already reported this card.');
|
||||||
|
buttonByText('Close')!.click();
|
||||||
|
await second;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fehler des Dienstes erscheinen übersetzt, wenn der Schlüssel bekannt ist', async () => {
|
||||||
|
send.mockRejectedValueOnce(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'));
|
||||||
|
void open({ subject: 'logo' });
|
||||||
|
choose('spam');
|
||||||
|
submit();
|
||||||
|
await settle();
|
||||||
|
expect(errorText()).toBe('This logo is no longer available.');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,35 +1,45 @@
|
|||||||
// Melden-Dialog der Detailseite: Grund wählen, an den API-Dienst senden, das Ergebnis im Dialog zeigen.
|
// Melden-Dialog für Karten (Detailseite) und Logos (Editor): Grund wählen, an den API-Dienst senden, das Ergebnis im Dialog zeigen.
|
||||||
// Eigenes <dialog> wie confirm-dialog.ts; alle Texte gehen als Text in den DOM, nie als HTML.
|
// Eigenes <dialog> wie confirm-dialog.ts; alle Texte gehen als Text in den DOM, nie als HTML. Übersetzt wird beim Aufbau mit t().
|
||||||
import '../account/account.css';
|
import '../account/account.css';
|
||||||
import './community.css';
|
import './community.css';
|
||||||
import { errorMessage } from '../account/errors';
|
import { errorMessage } from '../account/errors';
|
||||||
import { el } from '../editor/dom';
|
import { el } from '../editor/dom';
|
||||||
import { ApiError, type ReportReason } from './api';
|
import { msg, t } from '../i18n/i18n';
|
||||||
|
import { ApiError, apiErrorText, type ReportReason } from './api';
|
||||||
|
|
||||||
/** Die Gründe in der Reihenfolge und mit dem Wortlaut der Spec (§ 5), dazu der Wert für die API. */
|
/** Die Gründe in der Reihenfolge und mit dem Wortlaut der Spec (§ 5), dazu der Wert für die API. */
|
||||||
export const REPORT_REASONS: ReadonlyArray<{ value: ReportReason; label: string }> = [
|
export const REPORT_REASONS: ReadonlyArray<{ value: ReportReason; label: string }> = [
|
||||||
{ value: 'unangemessen', label: 'Unangemessen' },
|
{ value: 'unangemessen', label: msg('Unangemessen') },
|
||||||
{ value: 'kartendaten', label: 'Echte Kartendaten' },
|
{ value: 'kartendaten', label: msg('Echte Kartendaten') },
|
||||||
{ value: 'urheberrecht', label: 'Urheberrecht/Marke' },
|
{ value: 'urheberrecht', label: msg('Urheberrecht/Marke') },
|
||||||
{ value: 'spam', label: 'Spam' },
|
{ value: 'spam', label: msg('Spam') },
|
||||||
{ value: 'sonstiges', label: 'Sonstiges' },
|
{ value: 'sonstiges', label: msg('Sonstiges') },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const REPORT_THANKS = 'Danke für deine Meldung.';
|
/** Ein Logo hat keine Kartendaten: für Logos entfällt „Echte Kartendaten“ (Spec § 6). */
|
||||||
/** Wortlaut aus Spec § 11. */
|
const LOGO_REASONS = REPORT_REASONS.filter((reason) => reason.value !== 'kartendaten');
|
||||||
export const REPORT_DUPLICATE = 'Danke, du hast diese Karte schon gemeldet.';
|
|
||||||
const NO_REASON = 'Bitte wähle einen Grund.';
|
|
||||||
|
|
||||||
/** `reported`: neue Meldung angenommen; `duplicate`: dieser Melder hatte die Karte schon gemeldet; `cancelled`: nichts gesendet oder Ausgang unbekannt. */
|
export const REPORT_THANKS = msg('Danke für deine Meldung.');
|
||||||
|
/** Wortlaut aus Spec § 11. */
|
||||||
|
export const REPORT_DUPLICATE = msg('Danke, du hast diese Karte schon gemeldet.');
|
||||||
|
export const LOGO_REPORT_DUPLICATE = msg('Danke, du hast dieses Logo schon gemeldet.');
|
||||||
|
const NO_REASON = msg('Bitte wähle einen Grund.');
|
||||||
|
|
||||||
|
/** `reported`: neue Meldung angenommen; `duplicate`: dieser Melder hatte die Karte (das Logo) schon gemeldet; `cancelled`: nichts gesendet oder Ausgang unbekannt. */
|
||||||
export type ReportOutcome = 'reported' | 'duplicate' | 'cancelled';
|
export type ReportOutcome = 'reported' | 'duplicate' | 'cancelled';
|
||||||
|
|
||||||
|
/** Was gemeldet wird: eine Karte (Standard) oder ein Logo. */
|
||||||
|
export type ReportSubject = 'card' | 'logo';
|
||||||
|
|
||||||
export interface ReportDialogOptions {
|
export interface ReportDialogOptions {
|
||||||
/** Sendet die Meldung (`reportCard` der API). */
|
/** Sendet die Meldung (`reportCard` bzw. `reportLogo` der API). */
|
||||||
send: (reason: ReportReason) => Promise<{ schonGemeldet: boolean }>;
|
send: (reason: ReportReason) => Promise<{ schonGemeldet: boolean }>;
|
||||||
/** Titel der Karte für die Überschrift des Textes (als Text). */
|
/** Titel der Karte für die Überschrift des Textes (als Text); für Logos ohne Wirkung (dort heißt es „dieses Logo“). */
|
||||||
cardTitle?: string;
|
cardTitle?: string;
|
||||||
/** Schließt den Dialog, z. B. beim Verlassen der Seite; eine danach eintreffende Antwort wird verworfen. */
|
/** Schließt den Dialog, z. B. beim Verlassen der Seite; eine danach eintreffende Antwort wird verworfen. */
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
/** Karte (Standard) oder Logo: Überschrift, Einleitung, Dank bei Duplikat und die Gründe (ohne „Echte Kartendaten“) folgen daraus. */
|
||||||
|
subject?: ReportSubject;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,32 +54,43 @@ export function reportDialog(opts: ReportDialogOptions): Promise<ReportOutcome>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
|
const logo = opts.subject === 'logo';
|
||||||
|
const choices = logo ? LOGO_REASONS : REPORT_REASONS;
|
||||||
let outcome: ReportOutcome = 'cancelled';
|
let outcome: ReportOutcome = 'cancelled';
|
||||||
let closed = false;
|
let closed = false;
|
||||||
let busy = false;
|
let busy = false;
|
||||||
|
|
||||||
const radios = REPORT_REASONS.map(({ value }) => el('input', { type: 'radio', name: 'reason', value }));
|
const radios = choices.map(({ value }) => el('input', { type: 'radio', name: 'reason', value }));
|
||||||
const reasons = el(
|
const reasons = el(
|
||||||
'fieldset',
|
'fieldset',
|
||||||
{ class: 'report-reasons' },
|
{ class: 'report-reasons' },
|
||||||
el('legend', { class: 'report-legend' }, 'Grund der Meldung'),
|
el('legend', { class: 'report-legend' }, t('Grund der Meldung')),
|
||||||
...REPORT_REASONS.map(({ label }, i) => el('label', { class: 'report-reason' }, radios[i], label)),
|
...choices.map(({ label }, i) => el('label', { class: 'report-reason' }, radios[i], t(label))),
|
||||||
);
|
);
|
||||||
const intro = el(
|
const intro = el(
|
||||||
'p',
|
'p',
|
||||||
{ class: 'report-text' },
|
{ class: 'report-text' },
|
||||||
opts.cardTitle ? `Warum möchtest du „${opts.cardTitle}“ melden?` : 'Warum möchtest du diese Karte melden?',
|
logo
|
||||||
|
? t('Warum möchtest du dieses Logo melden?')
|
||||||
|
: opts.cardTitle
|
||||||
|
? t('Warum möchtest du „{title}“ melden?', { title: opts.cardTitle })
|
||||||
|
: t('Warum möchtest du diese Karte melden?'),
|
||||||
);
|
);
|
||||||
const errorEl = el('p', { class: 'auth-msg auth-msg-error', role: 'alert' });
|
const errorEl = el('p', { class: 'auth-msg auth-msg-error', role: 'alert' });
|
||||||
const statusEl = el('p', { class: 'auth-msg auth-msg-ok', role: 'status' });
|
const statusEl = el('p', { class: 'auth-msg auth-msg-ok', role: 'status' });
|
||||||
const cancel = el('button', { type: 'button', class: 'auth-btn' }, 'Abbrechen');
|
const cancel = el('button', { type: 'button', class: 'auth-btn' }, t('Abbrechen'));
|
||||||
const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, 'Melden');
|
const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, t('Melden'));
|
||||||
const actions = el('div', { class: 'report-actions' }, cancel, send);
|
const actions = el('div', { class: 'report-actions' }, cancel, send);
|
||||||
const form = el('form', { class: 'report-form' }, intro, reasons, errorEl, statusEl, actions);
|
const form = el('form', { class: 'report-form' }, intro, reasons, errorEl, statusEl, actions);
|
||||||
const dialog = el(
|
const dialog = el(
|
||||||
'dialog',
|
'dialog',
|
||||||
{ class: 'report-dialog', 'aria-labelledby': 'report-title' },
|
{ class: 'report-dialog', 'aria-labelledby': 'report-title' },
|
||||||
el('div', { class: 'report-card' }, el('h2', { id: 'report-title', class: 'report-title' }, 'Karte melden'), form),
|
el(
|
||||||
|
'div',
|
||||||
|
{ class: 'report-card' },
|
||||||
|
el('h2', { id: 'report-title', class: 'report-title' }, logo ? t('Logo melden') : t('Karte melden')),
|
||||||
|
form,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const finish = () => {
|
const finish = () => {
|
||||||
@@ -92,20 +113,20 @@ export function reportDialog(opts: ReportDialogOptions): Promise<ReportOutcome>
|
|||||||
function showResult(duplicate: boolean): void {
|
function showResult(duplicate: boolean): void {
|
||||||
outcome = duplicate ? 'duplicate' : 'reported';
|
outcome = duplicate ? 'duplicate' : 'reported';
|
||||||
errorEl.textContent = '';
|
errorEl.textContent = '';
|
||||||
statusEl.textContent = duplicate ? REPORT_DUPLICATE : REPORT_THANKS;
|
statusEl.textContent = t(duplicate ? (logo ? LOGO_REPORT_DUPLICATE : REPORT_DUPLICATE) : REPORT_THANKS);
|
||||||
intro.remove();
|
intro.remove();
|
||||||
reasons.remove();
|
reasons.remove();
|
||||||
const close = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, 'Schließen');
|
const close = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, t('Schließen'));
|
||||||
close.addEventListener('click', finish);
|
close.addEventListener('click', finish);
|
||||||
actions.replaceChildren(close);
|
actions.replaceChildren(close);
|
||||||
close.focus();
|
close.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit(): Promise<void> {
|
async function submit(): Promise<void> {
|
||||||
const chosen = REPORT_REASONS.find((_, i) => radios[i].checked);
|
const chosen = choices.find((_, i) => radios[i].checked);
|
||||||
if (!chosen) {
|
if (!chosen) {
|
||||||
statusEl.textContent = '';
|
statusEl.textContent = '';
|
||||||
errorEl.textContent = NO_REASON;
|
errorEl.textContent = t(NO_REASON);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -117,8 +138,8 @@ export function reportDialog(opts: ReportDialogOptions): Promise<ReportOutcome>
|
|||||||
showResult(res.schonGemeldet);
|
showResult(res.schonGemeldet);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
// Der Dienst liefert deutsche Texte (auch für 429); alles andere (Netz, JWT) übersetzt errorMessage.
|
// Texte des Dienstes (auch für 429) kommen über ihren Schlüssel in der Sprache der Seite; alles andere (Netz, JWT) über errorMessage.
|
||||||
errorEl.textContent = err instanceof ApiError ? err.message : errorMessage(err, 'load');
|
errorEl.textContent = err instanceof ApiError ? apiErrorText(err) : errorMessage(err, 'load');
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1146,6 +1146,92 @@ describe('Community-Logo auf der Karte', () => {
|
|||||||
expect(mocks.drawCard.mock.calls.every(([, config]) => config.logo === 'A')).toBe(true);
|
expect(mocks.drawCard.mock.calls.every(([, config]) => config.logo === 'A')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Fehler „Die Logos konnten nicht geladen werden.“ und das gemeinsame Fehlerfeld', () => {
|
||||||
|
const LOGO_ERROR = 'Die Logos konnten nicht geladen werden.';
|
||||||
|
/** Logo „A“ lässt sich nicht laden, alle anderen schon. */
|
||||||
|
const failA = async (config: { logo: string | null }) => {
|
||||||
|
if (config.logo === 'A') throw new TypeError('Failed to fetch');
|
||||||
|
return FOUND;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('der Fehler gehört dem Logo-Zustand: lädt danach ein anderes Logo, verschwindet er', async () => {
|
||||||
|
mocks.loadCardAssets.mockImplementation(failA);
|
||||||
|
store.set({ config: withLogo('A') });
|
||||||
|
mount();
|
||||||
|
await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR));
|
||||||
|
store.set({ config: withLogo('B') });
|
||||||
|
await vi.waitFor(() => expect(errorText()).toBe(''));
|
||||||
|
// und auch „Kein Logo“ ist ein erfolgreicher Ladevorgang
|
||||||
|
store.set({ config: withLogo('A') });
|
||||||
|
await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR));
|
||||||
|
store.set({ config: withLogo(null) });
|
||||||
|
await vi.waitFor(() => expect(errorText()).toBe(''));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein anderer Fehler im Feld bleibt stehen, auch wenn danach ein Logo lädt', async () => {
|
||||||
|
mocks.loadCardAssets.mockImplementation(failA);
|
||||||
|
store.set({ config: withLogo('A') });
|
||||||
|
mount();
|
||||||
|
await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR));
|
||||||
|
// Ein anderer Fehler ersetzt den Logo-Fehler im Feld …
|
||||||
|
saveButton().disabled = false;
|
||||||
|
saveButton().click();
|
||||||
|
expect(errorText()).toBe('Bitte zuerst ein Bild wählen.');
|
||||||
|
// … und gehört nicht dem Logo-Zustand: ein erfolgreicher Ladevorgang löscht ihn nicht.
|
||||||
|
store.set({ config: withLogo('B') });
|
||||||
|
await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(2));
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
expect(errorText()).toBe('Bitte zuerst ein Bild wählen.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein erfolgreicher Ladevorgang ohne Logo-Fehler lässt einen fremden Fehler ebenfalls stehen', async () => {
|
||||||
|
store.set({ config: withLogo('A') });
|
||||||
|
mocks.loadCardAssets.mockResolvedValue(FOUND);
|
||||||
|
mount();
|
||||||
|
await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(1));
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
saveButton().disabled = false;
|
||||||
|
saveButton().click();
|
||||||
|
expect(errorText()).toBe('Bitte zuerst ein Bild wählen.');
|
||||||
|
store.set({ config: withLogo('B') });
|
||||||
|
await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(2));
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
expect(errorText()).toBe('Bitte zuerst ein Bild wählen.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derselbe Text aus einem gescheiterten Download gehört dem Download und bleibt stehen', async () => {
|
||||||
|
withImage();
|
||||||
|
store.set({ config: withLogo('da') });
|
||||||
|
mocks.loadCardAssets.mockResolvedValueOnce(FOUND).mockRejectedValueOnce(new TypeError('Failed to fetch')).mockResolvedValue(FOUND);
|
||||||
|
mount();
|
||||||
|
await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(1));
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
downloadButton().click();
|
||||||
|
await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR));
|
||||||
|
store.set({ config: withLogo('db') });
|
||||||
|
await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(3));
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
expect(errorText()).toBe(LOGO_ERROR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine späte Antwort für einen überholten Schlüssel löscht nichts', async () => {
|
||||||
|
let finishB!: (v: typeof FOUND) => void;
|
||||||
|
mocks.loadCardAssets.mockImplementation((config: { logo: string | null }) => {
|
||||||
|
if (config.logo === 'A') return Promise.reject(new TypeError('Failed to fetch'));
|
||||||
|
if (config.logo === 'B') return new Promise((resolve) => (finishB = resolve as (v: typeof FOUND) => void));
|
||||||
|
return Promise.resolve(FOUND);
|
||||||
|
});
|
||||||
|
store.set({ config: withLogo('A') });
|
||||||
|
mount();
|
||||||
|
await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR));
|
||||||
|
store.set({ config: withLogo('B') }); // lädt noch …
|
||||||
|
store.set({ config: withLogo('A') }); // … da wird A wieder gewählt und scheitert erneut
|
||||||
|
finishB(FOUND);
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
expect(errorText()).toBe(LOGO_ERROR);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('gespeicherte Karte mit gelöschtem Logo: öffnet ohne Fehler, zeigt den Hinweis, behält die Auswahl und lässt sich herunterladen', async () => {
|
it('gespeicherte Karte mit gelöschtem Logo: öffnet ohne Fehler, zeigt den Hinweis, behält die Auswahl und lässt sich herunterladen', async () => {
|
||||||
history.replaceState(null, '', '/gestalten?karte=k1');
|
history.replaceState(null, '', '/gestalten?karte=k1');
|
||||||
mocks.get.mockResolvedValue(savedCard({ config: { ...structuredClone(DEFAULT_CONFIG), template: 'classic', logo: 'gone' } }));
|
mocks.get.mockResolvedValue(savedCard({ config: { ...structuredClone(DEFAULT_CONFIG), template: 'classic', logo: 'gone' } }));
|
||||||
|
|||||||
@@ -106,6 +106,16 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
|||||||
onNewCard: () => void startNewCard(),
|
onNewCard: () => void startNewCard(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Das Fehlerfeld der Vorschau teilen sich alle Fehler des Editors. LOGO_ERROR (ensureLogos) darf verschwinden, sobald ein Logo
|
||||||
|
// doch lädt, aber nur, solange er noch dort steht, wo ensureLogos ihn hingeschrieben hat: jeder andere Aufruf von setError
|
||||||
|
// (ein anderer Text, auch derselbe aus einem Download, oder das Leeren) nimmt ihm dieses Recht.
|
||||||
|
let logoErrorShown = false;
|
||||||
|
const setPreviewError = preview.setError;
|
||||||
|
preview.setError = (message) => {
|
||||||
|
logoErrorShown = false;
|
||||||
|
setPreviewError(message);
|
||||||
|
};
|
||||||
|
|
||||||
// Der Hinweis unter der Vorschau: ein allgemeiner (Bild fehlt, PNG zu groß …) und der zum fehlenden Community-Logo (er gilt,
|
// Der Hinweis unter der Vorschau: ein allgemeiner (Bild fehlt, PNG zu groß …) und der zum fehlenden Community-Logo (er gilt,
|
||||||
// solange die geladenen Logos `missing` sind, siehe ensureLogos). Beide teilen sich das Feld, werden aber getrennt gehalten,
|
// solange die geladenen Logos `missing` sind, siehe ensureLogos). Beide teilen sich das Feld, werden aber getrennt gehalten,
|
||||||
// damit das Ende des einen den anderen nicht löscht.
|
// damit das Ende des einen den anderen nicht löscht.
|
||||||
@@ -409,6 +419,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
|||||||
if (disposed || token !== logoToken) return;
|
if (disposed || token !== logoToken) return;
|
||||||
pendingKey = '';
|
pendingKey = '';
|
||||||
logos = { logo: assets.logo, brand: assets.brand, missing: assets.logoMissing, key };
|
logos = { logo: assets.logo, brand: assets.brand, missing: assets.logoMissing, key };
|
||||||
|
if (logoErrorShown) preview.setError(null); // der Ladefehler dieses Zustands ist erledigt, alle anderen Fehler bleiben
|
||||||
showWarning();
|
showWarning();
|
||||||
schedule();
|
schedule();
|
||||||
},
|
},
|
||||||
@@ -417,6 +428,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
|||||||
pendingKey = '';
|
pendingKey = '';
|
||||||
failedKey = key;
|
failedKey = key;
|
||||||
preview.setError(LOGO_ERROR);
|
preview.setError(LOGO_ERROR);
|
||||||
|
logoErrorShown = true;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,35 @@ export const EN: Readonly<Record<string, string>> = {
|
|||||||
'Nochmal versuchen': 'Try again',
|
'Nochmal versuchen': 'Try again',
|
||||||
'Mehr laden': 'Load more',
|
'Mehr laden': 'Load more',
|
||||||
|
|
||||||
|
// Infozeile zum gewählten Logo (logos/logo-info.ts). „von {name}“, „Anonym“, „Ausgeblendet“ und „Melden“ stehen an anderer Stelle,
|
||||||
|
// der Hinweis „Das gewählte Logo ist nicht mehr verfügbar.“ weiter oben (Logo auf der Karte).
|
||||||
|
'Löschen': 'Delete',
|
||||||
|
'Ausblenden': 'Hide',
|
||||||
|
'Einblenden': 'Unhide',
|
||||||
|
'Die Gründe konnten nicht geladen werden.': 'The reasons couldn’t be loaded.',
|
||||||
|
'Logo „{name}“ löschen?': 'Delete logo “{name}”?',
|
||||||
|
'Das Logo, seine Herzen und Meldungen werden endgültig gelöscht. Gespeicherte Karten zeigen es danach nicht mehr. Bereits veröffentlichte Kartenbilder bleiben unverändert.':
|
||||||
|
'The logo, its hearts and reports will be deleted permanently. Saved cards will no longer show it. Cards that were already published stay unchanged.',
|
||||||
|
|
||||||
|
// Melden-Dialog für Karten und Logos (community/report-dialog.ts). „Abbrechen“ steht weiter oben.
|
||||||
|
'Melden': 'Report',
|
||||||
|
'Karte melden': 'Report card',
|
||||||
|
'Logo melden': 'Report logo',
|
||||||
|
'Warum möchtest du diese Karte melden?': 'Why do you want to report this card?',
|
||||||
|
'Warum möchtest du „{title}“ melden?': 'Why do you want to report “{title}”?',
|
||||||
|
'Warum möchtest du dieses Logo melden?': 'Why do you want to report this logo?',
|
||||||
|
'Grund der Meldung': 'Reason for the report',
|
||||||
|
'Unangemessen': 'Inappropriate',
|
||||||
|
'Echte Kartendaten': 'Real card data',
|
||||||
|
'Urheberrecht/Marke': 'Copyright/trademark',
|
||||||
|
'Spam': 'Spam',
|
||||||
|
'Sonstiges': 'Other',
|
||||||
|
'Bitte wähle einen Grund.': 'Please choose a reason.',
|
||||||
|
'Danke für deine Meldung.': 'Thanks for your report.',
|
||||||
|
'Danke, du hast diese Karte schon gemeldet.': 'Thanks, you’ve already reported this card.',
|
||||||
|
'Danke, du hast dieses Logo schon gemeldet.': 'Thanks, you’ve already reported this logo.',
|
||||||
|
'Schließen': 'Close',
|
||||||
|
|
||||||
// API-Fehlertexte (i18n/api-messages.ts). Schlüssel und deutsche Texte stammen aus MSG (deploy/api/common.js); api-messages.test.ts hält sie gleich.
|
// API-Fehlertexte (i18n/api-messages.ts). Schlüssel und deutsche Texte stammen aus MSG (deploy/api/common.js); api-messages.test.ts hält sie gleich.
|
||||||
'Bitte melde dich an.': 'Please sign in.',
|
'Bitte melde dich an.': 'Please sign in.',
|
||||||
'Nicht gefunden.': 'Not found.',
|
'Nicht gefunden.': 'Not found.',
|
||||||
|
|||||||
@@ -0,0 +1,709 @@
|
|||||||
|
// Infozeile zum gewählten Logo (Spec § 3 und § 6): Anzeige, Melden, Löschen und die Admin-Aktionen. Die Zeile des Logos und die Auswahl
|
||||||
|
// gehören dem Picker (hier eine Attrappe als `host`); Datenschicht, Rückfrage und Melden-Dialog sind Attrappen. Das Zusammenspiel
|
||||||
|
// mit dem echten Picker steht in logo-picker.test.ts.
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({ forgetLogo: vi.fn() }));
|
||||||
|
|
||||||
|
// Die Infozeile hängt (über Melden-Dialog und API-Fehler) an Appwrite und Anmeldung; hier kommt alles über `deps`.
|
||||||
|
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
|
||||||
|
vi.mock('../account/auth', () => ({
|
||||||
|
currentUser: () => null,
|
||||||
|
currentUserId: () => null,
|
||||||
|
isAdmin: () => false,
|
||||||
|
onAuthChange: () => () => {},
|
||||||
|
onLogout: () => () => {},
|
||||||
|
}));
|
||||||
|
vi.mock('./logo-asset', async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import('./logo-asset')>()),
|
||||||
|
forgetLogo: mocks.forgetLogo,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ApiError } from '../community/api';
|
||||||
|
import { setLang } from '../i18n/i18n';
|
||||||
|
import { createLogoInfo, failureText, type LogoInfo, type LogoInfoDeps, type LogoInfoHost } from './logo-info';
|
||||||
|
import type { CommunityLogo, LogoReport } from './logos';
|
||||||
|
|
||||||
|
const logo = (patch: Partial<CommunityLogo> = {}): CommunityLogo => ({
|
||||||
|
id: 'a',
|
||||||
|
name: 'Logo A',
|
||||||
|
displayName: 'Kenso',
|
||||||
|
fileId: 'a',
|
||||||
|
ownerId: 'u9',
|
||||||
|
mono: false,
|
||||||
|
likeCount: 12,
|
||||||
|
hidden: false,
|
||||||
|
reportCount: 0,
|
||||||
|
createdAt: '2026-10-04T12:00:00.000Z',
|
||||||
|
...patch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = (reason: string, id = reason): LogoReport => ({ id, logoId: 'a', reason, createdAt: '2026-10-04T12:00:00.000Z' });
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason: unknown) => void;
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
const flush = async () => {
|
||||||
|
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Attrappen ------------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let selectedId: string | null;
|
||||||
|
let row: CommunityLogo | null;
|
||||||
|
let gone: boolean;
|
||||||
|
let user: string | null;
|
||||||
|
let admin: boolean;
|
||||||
|
let abort: AbortController;
|
||||||
|
let info: LogoInfo;
|
||||||
|
|
||||||
|
const host = {
|
||||||
|
selectedId: () => selectedId,
|
||||||
|
selected: () => row,
|
||||||
|
gone: () => gone,
|
||||||
|
patchRow: vi.fn((id: string, patch: Partial<CommunityLogo>) => {
|
||||||
|
if (row?.id === id) row = { ...row, ...patch };
|
||||||
|
}),
|
||||||
|
removeLogo: vi.fn<LogoInfoHost['removeLogo']>(),
|
||||||
|
} satisfies LogoInfoHost;
|
||||||
|
|
||||||
|
const fx = {
|
||||||
|
reportLogo: vi.fn<LogoInfoDeps['reportLogo']>(),
|
||||||
|
deleteLogo: vi.fn<LogoInfoDeps['deleteLogo']>(),
|
||||||
|
setLogoVisibility: vi.fn<LogoInfoDeps['setLogoVisibility']>(),
|
||||||
|
listLogoReports: vi.fn<LogoInfoDeps['listLogoReports']>(),
|
||||||
|
confirm: vi.fn<LogoInfoDeps['confirm']>(),
|
||||||
|
reportDialog: vi.fn<LogoInfoDeps['reportDialog']>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const deps: LogoInfoDeps = { ...fx, userId: () => user, isAdmin: () => admin };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const fn of [...Object.values(fx), mocks.forgetLogo, host.patchRow, host.removeLogo]) fn.mockReset();
|
||||||
|
host.patchRow.mockImplementation((id, patch) => {
|
||||||
|
if (row?.id === id) row = { ...row, ...patch };
|
||||||
|
});
|
||||||
|
selectedId = null;
|
||||||
|
row = null;
|
||||||
|
gone = false;
|
||||||
|
user = 'u1';
|
||||||
|
admin = false;
|
||||||
|
abort = new AbortController();
|
||||||
|
fx.reportLogo.mockResolvedValue({ schonGemeldet: false });
|
||||||
|
fx.deleteLogo.mockResolvedValue(undefined);
|
||||||
|
fx.setLogoVisibility.mockResolvedValue(undefined);
|
||||||
|
fx.listLogoReports.mockResolvedValue([]);
|
||||||
|
fx.confirm.mockResolvedValue(true);
|
||||||
|
fx.reportDialog.mockResolvedValue('cancelled');
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Baut die Infozeile; `shown` ist das gewählte Logo (null: keins). */
|
||||||
|
async function mount(shown: CommunityLogo | null = logo()): Promise<LogoInfo> {
|
||||||
|
row = shown;
|
||||||
|
selectedId = shown?.id ?? null;
|
||||||
|
info = createLogoInfo(host, deps, abort.signal);
|
||||||
|
document.body.append(info.el);
|
||||||
|
await flush();
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt der Picker ein anderes Logo (oder keins), meldet er es mit update(). */
|
||||||
|
function choose(next: CommunityLogo | null): void {
|
||||||
|
row = next;
|
||||||
|
selectedId = next?.id ?? null;
|
||||||
|
info.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Zugriffe auf das DOM -------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Sichtbar: weder selbst noch ein Vorfahre ist mit `hidden` verborgen. */
|
||||||
|
const visible = (node: Element) => node.closest('[hidden]') === null;
|
||||||
|
const line = () => {
|
||||||
|
const text = info.el.querySelector('.logo-info-text')!;
|
||||||
|
return visible(text) ? text.textContent : '';
|
||||||
|
};
|
||||||
|
const goneText = () => info.el.querySelector('.logo-info-gone')!.textContent;
|
||||||
|
const message = () => info.el.querySelector('[role="alert"]')!.textContent;
|
||||||
|
const buttons = () => [...info.el.querySelectorAll('button')].filter(visible).map((b) => b.textContent);
|
||||||
|
const button = (label: string) => [...info.el.querySelectorAll('button')].find((b) => b.textContent === label && visible(b)) as HTMLButtonElement;
|
||||||
|
const reportCount = () => {
|
||||||
|
const text = info.el.querySelector('.logo-info-reports')!;
|
||||||
|
return visible(text) ? text.textContent : '';
|
||||||
|
};
|
||||||
|
const reasons = () => [...info.el.querySelectorAll('.logo-info-reasons li')].map((li) => li.textContent);
|
||||||
|
const reasonsNote = () => info.el.querySelector('.logo-info-note')!.textContent;
|
||||||
|
const hiddenNote = () => {
|
||||||
|
const note = info.el.querySelector('.logo-info-hidden')!;
|
||||||
|
return visible(note) ? note.textContent : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Anzeige', () => {
|
||||||
|
it('ohne Auswahl ist die Infozeile leer: kein Text, keine Knöpfe, keine Meldung', async () => {
|
||||||
|
await mount(null);
|
||||||
|
expect(line()).toBe('');
|
||||||
|
expect(buttons()).toEqual([]);
|
||||||
|
expect(goneText()).toBe('');
|
||||||
|
expect(hiddenNote()).toBe('');
|
||||||
|
expect(message()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gewähltes Logo: „<Name> · von <Anzeigename> · ♥ <Zahl>“ und „Melden“ – auch für Gäste', async () => {
|
||||||
|
user = null;
|
||||||
|
await mount();
|
||||||
|
expect(line()).toBe('Logo A · von Kenso · ♥ 12');
|
||||||
|
expect(buttons()).toEqual(['Melden']);
|
||||||
|
expect(button('Melden').type).toBe('button');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Anzeigename steht „von Anonym“', async () => {
|
||||||
|
await mount(logo({ displayName: '' }));
|
||||||
|
expect(line()).toBe('Logo A · von Anonym · ♥ 12');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Name und Anzeigename stehen nur als Text, nie als HTML', async () => {
|
||||||
|
await mount(logo({ name: '<img src=x onerror=alert(1)>', displayName: '<b>fett</b>' }));
|
||||||
|
expect(line()).toBe('<img src=x onerror=alert(1)> · von <b>fett</b> · ♥ 12');
|
||||||
|
expect(info.el.querySelector('img')).toBeNull();
|
||||||
|
expect(info.el.querySelector('b')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update() folgt der Zeile des Pickers: neue Zahl, anderes Logo, danach wieder keins', async () => {
|
||||||
|
await mount();
|
||||||
|
row = logo({ likeCount: 13 });
|
||||||
|
info.update();
|
||||||
|
expect(line()).toBe('Logo A · von Kenso · ♥ 13');
|
||||||
|
choose(logo({ id: 'b', name: 'Logo B', displayName: 'Anna', likeCount: 0 }));
|
||||||
|
expect(line()).toBe('Logo B · von Anna · ♥ 0');
|
||||||
|
choose(null);
|
||||||
|
expect(line()).toBe('');
|
||||||
|
expect(buttons()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Logo, dessen Zeile noch fehlt (wird gerade geholt), zeigt weder Zeile noch Hinweis', async () => {
|
||||||
|
await mount(null);
|
||||||
|
selectedId = 'x'; // gewählt, die Zeile ist noch unbekannt
|
||||||
|
info.update();
|
||||||
|
expect(line()).toBe('');
|
||||||
|
expect(goneText()).toBe('');
|
||||||
|
expect(buttons()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gewähltes Logo nicht auffindbar: „Das gewählte Logo ist nicht mehr verfügbar.“, ohne Knöpfe', async () => {
|
||||||
|
await mount(null);
|
||||||
|
selectedId = 'x';
|
||||||
|
gone = true;
|
||||||
|
info.update();
|
||||||
|
expect(goneText()).toBe('Das gewählte Logo ist nicht mehr verfügbar.');
|
||||||
|
expect(line()).toBe('');
|
||||||
|
expect(buttons()).toEqual([]);
|
||||||
|
expect(info.el.querySelector('.logo-info-gone')!.getAttribute('role')).toBe('status');
|
||||||
|
// Taucht die Zeile doch auf, ist der Hinweis weg.
|
||||||
|
row = logo({ id: 'x', name: 'Logo X' });
|
||||||
|
gone = false;
|
||||||
|
info.update();
|
||||||
|
expect(goneText()).toBe('');
|
||||||
|
expect(line()).toBe('Logo X · von Kenso · ♥ 12');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine Zeile, die der Picker inzwischen kennt, geht dem Hinweis „nicht mehr verfügbar“ vor, auch wenn gone() noch wahr meldet', async () => {
|
||||||
|
await mount(null);
|
||||||
|
selectedId = 'x';
|
||||||
|
gone = true;
|
||||||
|
info.update();
|
||||||
|
expect(goneText()).toBe('Das gewählte Logo ist nicht mehr verfügbar.');
|
||||||
|
row = logo({ id: 'x', name: 'Logo X' });
|
||||||
|
info.update();
|
||||||
|
expect(goneText()).toBe('');
|
||||||
|
expect(line()).toBe('Logo X · von Kenso · ♥ 12');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne einen sichtbaren Knopf ist auch die Knopfleiste verborgen (sonst bliebe ein leerer Abstand in der Karte)', async () => {
|
||||||
|
const actions = () => info.el.querySelector<HTMLElement>('.logo-info-actions')!;
|
||||||
|
user = 'u2'; // weder Hochlader noch Admin
|
||||||
|
await mount(logo({ hidden: true })); // melden lässt es sich nicht, ein- und ausblenden nur ein Admin
|
||||||
|
expect(buttons()).toEqual([]);
|
||||||
|
expect(actions().hidden).toBe(true);
|
||||||
|
expect(hiddenNote()).toBe('Ausgeblendet');
|
||||||
|
user = 'u9'; // der Hochlader darf löschen
|
||||||
|
info.update();
|
||||||
|
expect(buttons()).toEqual(['Löschen']);
|
||||||
|
expect(actions().hidden).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Englisch: Zeile, Knopf und Hinweis sind übersetzt', async () => {
|
||||||
|
setLang('en', { persist: false });
|
||||||
|
await mount(logo({ displayName: '' }));
|
||||||
|
expect(line()).toBe('Logo A · by Anonymous · ♥ 12');
|
||||||
|
expect(buttons()).toEqual(['Report']);
|
||||||
|
choose(null);
|
||||||
|
selectedId = 'x';
|
||||||
|
gone = true;
|
||||||
|
info.update();
|
||||||
|
expect(goneText()).toBe('The selected logo is no longer available.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Melden', () => {
|
||||||
|
it('öffnet den Dialog für Logos und sendet reportLogo(id, Grund) – auch als Gast', async () => {
|
||||||
|
user = null;
|
||||||
|
await mount();
|
||||||
|
button('Melden').click();
|
||||||
|
await flush();
|
||||||
|
expect(fx.reportDialog).toHaveBeenCalledTimes(1);
|
||||||
|
const opts = fx.reportDialog.mock.calls[0][0];
|
||||||
|
expect(opts.subject).toBe('logo');
|
||||||
|
expect(opts.signal).toBe(abort.signal);
|
||||||
|
fx.reportLogo.mockResolvedValue({ schonGemeldet: true });
|
||||||
|
await expect(opts.send('spam')).resolves.toEqual({ schonGemeldet: true });
|
||||||
|
expect(fx.reportLogo).toHaveBeenCalledExactlyOnceWith('a', 'spam');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein zweiter Klick, solange der Dialog offen ist, öffnet keinen zweiten; danach geht es wieder', async () => {
|
||||||
|
const open = deferred<'reported'>();
|
||||||
|
fx.reportDialog.mockReturnValue(open.promise);
|
||||||
|
await mount();
|
||||||
|
button('Melden').click();
|
||||||
|
button('Melden').click();
|
||||||
|
await flush();
|
||||||
|
expect(fx.reportDialog).toHaveBeenCalledTimes(1);
|
||||||
|
open.resolve('reported');
|
||||||
|
await flush();
|
||||||
|
button('Melden').click();
|
||||||
|
expect(fx.reportDialog).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Dialog, der sich nicht öffnen lässt, zeigt einen Fehlertext; der Knopf geht danach wieder', async () => {
|
||||||
|
fx.reportDialog.mockRejectedValueOnce(new Error('kein <dialog>'));
|
||||||
|
await mount();
|
||||||
|
button('Melden').click();
|
||||||
|
await flush();
|
||||||
|
expect(message()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
|
||||||
|
button('Melden').click(); // der nächste Versuch räumt den Text ab
|
||||||
|
expect(message()).toBe('');
|
||||||
|
await flush();
|
||||||
|
expect(fx.reportDialog).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein ausgeblendetes Logo lässt sich nicht melden (der Dienst kennt es für Besucher nicht)', async () => {
|
||||||
|
user = 'u9';
|
||||||
|
await mount(logo({ hidden: true }));
|
||||||
|
expect(buttons()).toEqual(['Löschen']);
|
||||||
|
expect(hiddenNote()).toBe('Ausgeblendet');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Löschen durch den Hochlader', () => {
|
||||||
|
it('„Löschen“ sehen nur der Hochlader und Admins; Gäste und andere Nutzer nur „Melden“', async () => {
|
||||||
|
user = 'u9'; // ownerId des Beispiel-Logos
|
||||||
|
await mount();
|
||||||
|
expect(buttons()).toEqual(['Melden', 'Löschen']);
|
||||||
|
user = 'u2';
|
||||||
|
info.update();
|
||||||
|
expect(buttons()).toEqual(['Melden']);
|
||||||
|
user = null;
|
||||||
|
info.update();
|
||||||
|
expect(buttons()).toEqual(['Melden']);
|
||||||
|
expect(reportCount()).toBe('');
|
||||||
|
expect(button('Ausblenden')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Logo ohne bekannten Hochlader gehört niemandem (auch nicht einem Konto mit leerer ID)', async () => {
|
||||||
|
user = '';
|
||||||
|
await mount(logo({ ownerId: '' }));
|
||||||
|
expect(buttons()).toEqual(['Melden']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fragt mit Namen nach; bestätigt: deleteLogo, forgetLogo und removeLogo in dieser Reihenfolge', async () => {
|
||||||
|
user = 'u9';
|
||||||
|
const order: string[] = [];
|
||||||
|
fx.confirm.mockImplementation(async () => {
|
||||||
|
order.push('confirm');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
fx.deleteLogo.mockImplementation(async () => {
|
||||||
|
order.push('delete');
|
||||||
|
});
|
||||||
|
mocks.forgetLogo.mockImplementation(() => {
|
||||||
|
order.push('forget');
|
||||||
|
});
|
||||||
|
host.removeLogo.mockImplementation(() => {
|
||||||
|
order.push('remove');
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
button('Löschen').click();
|
||||||
|
await flush();
|
||||||
|
expect(order).toEqual(['confirm', 'delete', 'forget', 'remove']);
|
||||||
|
expect(fx.confirm).toHaveBeenCalledExactlyOnceWith(
|
||||||
|
{
|
||||||
|
title: 'Logo „Logo A“ löschen?',
|
||||||
|
text: 'Das Logo, seine Herzen und Meldungen werden endgültig gelöscht. Gespeicherte Karten zeigen es danach nicht mehr. Bereits veröffentlichte Kartenbilder bleiben unverändert.',
|
||||||
|
confirmLabel: 'Löschen',
|
||||||
|
cancelLabel: 'Abbrechen',
|
||||||
|
},
|
||||||
|
abort.signal,
|
||||||
|
);
|
||||||
|
expect(fx.deleteLogo).toHaveBeenCalledExactlyOnceWith('a');
|
||||||
|
expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('a');
|
||||||
|
expect(host.removeLogo).toHaveBeenCalledExactlyOnceWith('a');
|
||||||
|
expect(message()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('abgelehnt: es passiert nichts, auch kein Fehlertext', async () => {
|
||||||
|
user = 'u9';
|
||||||
|
fx.confirm.mockResolvedValue(false);
|
||||||
|
await mount();
|
||||||
|
button('Löschen').click();
|
||||||
|
await flush();
|
||||||
|
expect(fx.deleteLogo).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.forgetLogo).not.toHaveBeenCalled();
|
||||||
|
expect(host.removeLogo).not.toHaveBeenCalled();
|
||||||
|
expect(message()).toBe('');
|
||||||
|
expect(buttons()).toEqual(['Melden', 'Löschen']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fehler des Dienstes: der Text steht in der Meldezeile, nichts wird entfernt; ein neuer Versuch geht', async () => {
|
||||||
|
user = 'u9';
|
||||||
|
fx.deleteLogo.mockRejectedValueOnce(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden'));
|
||||||
|
await mount();
|
||||||
|
button('Löschen').click();
|
||||||
|
await flush();
|
||||||
|
expect(message()).toBe('Dazu hast du keine Berechtigung.');
|
||||||
|
expect(mocks.forgetLogo).not.toHaveBeenCalled();
|
||||||
|
expect(host.removeLogo).not.toHaveBeenCalled();
|
||||||
|
button('Löschen').click();
|
||||||
|
await flush();
|
||||||
|
expect(message()).toBe('');
|
||||||
|
expect(host.removeLogo).toHaveBeenCalledExactlyOnceWith('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Fehler anderer Art (Netz) bekommt den allgemeinen Text', async () => {
|
||||||
|
user = 'u9';
|
||||||
|
fx.deleteLogo.mockRejectedValueOnce(new TypeError('offline'));
|
||||||
|
await mount();
|
||||||
|
button('Löschen').click();
|
||||||
|
await flush();
|
||||||
|
expect(message()).toBe('Keine Verbindung zum Server.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine Rückfrage, die sich nicht öffnen lässt, löscht nichts und sagt es', async () => {
|
||||||
|
user = 'u9';
|
||||||
|
fx.confirm.mockRejectedValueOnce(new Error('kein <dialog>'));
|
||||||
|
await mount();
|
||||||
|
button('Löschen').click();
|
||||||
|
await flush();
|
||||||
|
expect(fx.deleteLogo).not.toHaveBeenCalled();
|
||||||
|
expect(message()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet sich der Nutzer während der Rückfrage ab, wird nichts gelöscht', async () => {
|
||||||
|
user = 'u9';
|
||||||
|
const answer = deferred<boolean>();
|
||||||
|
fx.confirm.mockReturnValue(answer.promise);
|
||||||
|
await mount();
|
||||||
|
button('Löschen').click();
|
||||||
|
user = null;
|
||||||
|
info.update();
|
||||||
|
answer.resolve(true);
|
||||||
|
await flush();
|
||||||
|
expect(fx.deleteLogo).not.toHaveBeenCalled();
|
||||||
|
expect(host.removeLogo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('solange eine Rückfrage oder Anfrage läuft, bleiben weitere Klicks ohne Wirkung', async () => {
|
||||||
|
user = 'u9';
|
||||||
|
const request = deferred<void>();
|
||||||
|
fx.deleteLogo.mockReturnValue(request.promise);
|
||||||
|
await mount();
|
||||||
|
button('Löschen').click();
|
||||||
|
button('Löschen').click(); // die Rückfrage ist noch nicht beantwortet
|
||||||
|
await flush();
|
||||||
|
button('Löschen').click(); // die Anfrage läuft
|
||||||
|
button('Melden').click();
|
||||||
|
await flush();
|
||||||
|
expect(fx.confirm).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fx.deleteLogo).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fx.reportDialog).not.toHaveBeenCalled();
|
||||||
|
request.resolve();
|
||||||
|
await flush();
|
||||||
|
expect(host.removeLogo).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verlässt der Nutzer die Seite während der Rückfrage, wird nichts gelöscht', async () => {
|
||||||
|
user = 'u9';
|
||||||
|
const answer = deferred<boolean>();
|
||||||
|
fx.confirm.mockReturnValue(answer.promise);
|
||||||
|
await mount();
|
||||||
|
button('Löschen').click();
|
||||||
|
abort.abort();
|
||||||
|
answer.resolve(true);
|
||||||
|
await flush();
|
||||||
|
expect(fx.deleteLogo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verlässt der Nutzer die Seite während der Anfrage, vergisst der Zwischenspeicher das Logo trotzdem, die Kachel wird nicht mehr angefasst', async () => {
|
||||||
|
user = 'u9';
|
||||||
|
const request = deferred<void>();
|
||||||
|
fx.deleteLogo.mockReturnValue(request.promise);
|
||||||
|
await mount();
|
||||||
|
button('Löschen').click();
|
||||||
|
await flush();
|
||||||
|
abort.abort();
|
||||||
|
request.resolve();
|
||||||
|
await flush();
|
||||||
|
expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('a');
|
||||||
|
expect(host.removeLogo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Admin', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
admin = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Nicht-Admins sehen keine Meldungen und kein Ausblenden und fragen die Gründe nicht ab', async () => {
|
||||||
|
admin = false;
|
||||||
|
await mount(logo({ reportCount: 3 }));
|
||||||
|
expect(reportCount()).toBe('');
|
||||||
|
expect(buttons()).toEqual(['Melden']);
|
||||||
|
expect(fx.listLogoReports).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('„<n> Meldungen“ mit den Gründen als Text; dazu „Ausblenden“ und „Löschen“ (auch für fremde Logos)', async () => {
|
||||||
|
fx.listLogoReports.mockResolvedValue([report('spam'), report('urheberrecht'), report('Eigener Grund')]);
|
||||||
|
await mount(logo({ reportCount: 3 }));
|
||||||
|
expect(fx.listLogoReports).toHaveBeenCalledExactlyOnceWith('a');
|
||||||
|
expect(reportCount()).toBe('3 Meldungen');
|
||||||
|
expect(reasons()).toEqual(['Spam', 'Urheberrecht/Marke', 'Eigener Grund']);
|
||||||
|
expect(buttons()).toEqual(['Melden', 'Löschen', 'Ausblenden']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('„1 Meldung“ im Singular', async () => {
|
||||||
|
fx.listLogoReports.mockResolvedValue([report('unangemessen')]);
|
||||||
|
await mount(logo({ reportCount: 1 }));
|
||||||
|
expect(reportCount()).toBe('1 Meldung');
|
||||||
|
expect(reasons()).toEqual(['Unangemessen']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Meldungen: „0 Meldungen“ und keine Anfrage', async () => {
|
||||||
|
await mount(logo({ reportCount: 0 }));
|
||||||
|
expect(reportCount()).toBe('0 Meldungen');
|
||||||
|
expect(reasons()).toEqual([]);
|
||||||
|
expect(fx.listLogoReports).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die Gründe werden je Logo nur einmal geholt, auch bei vielen Aktualisierungen', async () => {
|
||||||
|
fx.listLogoReports.mockResolvedValue([report('spam')]);
|
||||||
|
await mount(logo({ reportCount: 1 }));
|
||||||
|
row = logo({ reportCount: 1, likeCount: 99 });
|
||||||
|
info.update();
|
||||||
|
info.update();
|
||||||
|
await flush();
|
||||||
|
expect(fx.listLogoReports).toHaveBeenCalledTimes(1);
|
||||||
|
expect(reasons()).toEqual(['Spam']);
|
||||||
|
choose(logo({ id: 'b', reportCount: 1 }));
|
||||||
|
await flush();
|
||||||
|
expect(fx.listLogoReports).toHaveBeenLastCalledWith('b');
|
||||||
|
choose(logo({ reportCount: 1 })); // zurück zu A: frisch holen
|
||||||
|
await flush();
|
||||||
|
expect(fx.listLogoReports).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ändert sich die Zahl der Meldungen (die Zeile wurde neu geladen), werden die Gründe neu geholt', async () => {
|
||||||
|
fx.listLogoReports.mockResolvedValueOnce([report('spam')]);
|
||||||
|
await mount(logo({ reportCount: 1 }));
|
||||||
|
expect(reasons()).toEqual(['Spam']);
|
||||||
|
fx.listLogoReports.mockResolvedValueOnce([report('spam'), report('sonstiges')]);
|
||||||
|
row = logo({ reportCount: 2 });
|
||||||
|
info.update();
|
||||||
|
await flush();
|
||||||
|
expect(reportCount()).toBe('2 Meldungen');
|
||||||
|
expect(reasons()).toEqual(['Spam', 'Sonstiges']);
|
||||||
|
expect(fx.listLogoReports).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wird dasselbe Logo nach „kein Logo“ erneut gewählt, werden die Gründe frisch geholt – auch nach einem Ladefehler', async () => {
|
||||||
|
fx.listLogoReports.mockRejectedValueOnce(new Error('offline'));
|
||||||
|
await mount(logo({ reportCount: 1 }));
|
||||||
|
expect(reasonsNote()).toBe('Die Gründe konnten nicht geladen werden.');
|
||||||
|
choose(null);
|
||||||
|
fx.listLogoReports.mockResolvedValueOnce([report('spam')]);
|
||||||
|
choose(logo({ reportCount: 1 }));
|
||||||
|
await flush();
|
||||||
|
expect(fx.listLogoReports).toHaveBeenCalledTimes(2);
|
||||||
|
expect(reasonsNote()).toBe('');
|
||||||
|
expect(reasons()).toEqual(['Spam']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die Gründe lassen sich nicht laden: Hinweis dazu, die Zahl bleibt', async () => {
|
||||||
|
fx.listLogoReports.mockRejectedValue(new Error('offline'));
|
||||||
|
await mount(logo({ reportCount: 3 }));
|
||||||
|
expect(reportCount()).toBe('3 Meldungen');
|
||||||
|
expect(reasons()).toEqual([]);
|
||||||
|
expect(reasonsNote()).toBe('Die Gründe konnten nicht geladen werden.');
|
||||||
|
// Ein anderes Logo, das sich laden lässt, hat den Hinweis nicht.
|
||||||
|
fx.listLogoReports.mockResolvedValue([report('spam')]);
|
||||||
|
choose(logo({ id: 'b', reportCount: 1 }));
|
||||||
|
await flush();
|
||||||
|
expect(reasonsNote()).toBe('');
|
||||||
|
expect(reasons()).toEqual(['Spam']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine späte Antwort für ein inzwischen verlassenes Logo wird verworfen', async () => {
|
||||||
|
const slow = deferred<LogoReport[]>();
|
||||||
|
fx.listLogoReports.mockReturnValueOnce(slow.promise);
|
||||||
|
await mount(logo({ reportCount: 2 }));
|
||||||
|
fx.listLogoReports.mockResolvedValueOnce([report('sonstiges')]);
|
||||||
|
choose(logo({ id: 'b', reportCount: 1 }));
|
||||||
|
await flush();
|
||||||
|
expect(reasons()).toEqual(['Sonstiges']);
|
||||||
|
slow.resolve([report('spam'), report('spam', 'zwei')]);
|
||||||
|
await flush();
|
||||||
|
expect(reasons()).toEqual(['Sonstiges']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('„Ausblenden“: setLogoVisibility(id, false), forgetLogo, die Zeile wird hidden; die Anzeige folgt', async () => {
|
||||||
|
const order: string[] = [];
|
||||||
|
fx.setLogoVisibility.mockImplementation(async () => {
|
||||||
|
order.push('visibility');
|
||||||
|
});
|
||||||
|
mocks.forgetLogo.mockImplementation(() => {
|
||||||
|
order.push('forget');
|
||||||
|
});
|
||||||
|
host.patchRow.mockImplementation((id, patch) => {
|
||||||
|
order.push('patch');
|
||||||
|
if (row?.id === id) row = { ...row, ...patch };
|
||||||
|
});
|
||||||
|
fx.listLogoReports.mockResolvedValue([report('spam')]);
|
||||||
|
await mount(logo({ reportCount: 1 }));
|
||||||
|
button('Ausblenden').click();
|
||||||
|
await flush();
|
||||||
|
expect(fx.setLogoVisibility).toHaveBeenCalledExactlyOnceWith('a', false);
|
||||||
|
expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('a');
|
||||||
|
expect(host.patchRow).toHaveBeenCalledExactlyOnceWith('a', { hidden: true });
|
||||||
|
expect(order).toEqual(['visibility', 'forget', 'patch']);
|
||||||
|
expect(buttons()).toEqual(['Löschen', 'Einblenden']); // „Melden“ gibt es für ein ausgeblendetes Logo nicht
|
||||||
|
expect(hiddenNote()).toBe('Ausgeblendet');
|
||||||
|
expect(reportCount()).toBe('1 Meldung'); // Ausblenden ändert die Meldungen nicht
|
||||||
|
expect(reasons()).toEqual(['Spam']);
|
||||||
|
expect(message()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('„Einblenden“: setLogoVisibility(id, true), forgetLogo; die Meldungen sind danach gelöscht (0)', async () => {
|
||||||
|
fx.listLogoReports.mockResolvedValue([report('spam'), report('sonstiges')]);
|
||||||
|
await mount(logo({ hidden: true, reportCount: 2 }));
|
||||||
|
expect(buttons()).toEqual(['Löschen', 'Einblenden']);
|
||||||
|
expect(reasons()).toEqual(['Spam', 'Sonstiges']);
|
||||||
|
button('Einblenden').click();
|
||||||
|
await flush();
|
||||||
|
expect(fx.setLogoVisibility).toHaveBeenCalledExactlyOnceWith('a', true);
|
||||||
|
expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('a');
|
||||||
|
expect(host.patchRow).toHaveBeenCalledExactlyOnceWith('a', { hidden: false, reportCount: 0 });
|
||||||
|
expect(buttons()).toEqual(['Melden', 'Löschen', 'Ausblenden']);
|
||||||
|
expect(hiddenNote()).toBe('');
|
||||||
|
expect(reportCount()).toBe('0 Meldungen');
|
||||||
|
expect(reasons()).toEqual([]);
|
||||||
|
expect(fx.listLogoReports).toHaveBeenCalledTimes(1); // nichts mehr zu holen
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fehler beim Ein- oder Ausblenden: Text in der Meldezeile, die Zeile bleibt, kein forgetLogo', async () => {
|
||||||
|
fx.setLogoVisibility.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'));
|
||||||
|
await mount(logo());
|
||||||
|
button('Ausblenden').click();
|
||||||
|
await flush();
|
||||||
|
expect(message()).toBe('Der Dienst ist gerade nicht erreichbar.');
|
||||||
|
expect(host.patchRow).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.forgetLogo).not.toHaveBeenCalled();
|
||||||
|
expect(buttons()).toEqual(['Melden', 'Löschen', 'Ausblenden']);
|
||||||
|
button('Ausblenden').click();
|
||||||
|
await flush();
|
||||||
|
expect(message()).toBe('');
|
||||||
|
expect(buttons()).toEqual(['Löschen', 'Einblenden']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Admin löscht wie der Hochlader, auch ein fremdes Logo', async () => {
|
||||||
|
await mount(logo({ ownerId: 'u5' }));
|
||||||
|
button('Löschen').click();
|
||||||
|
await flush();
|
||||||
|
expect(fx.confirm).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fx.deleteLogo).toHaveBeenCalledExactlyOnceWith('a');
|
||||||
|
expect(host.removeLogo).toHaveBeenCalledExactlyOnceWith('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wird jemand Admin (Anmeldung), erscheinen die Admin-Teile nach update(); verliert er die Rolle, verschwinden sie', async () => {
|
||||||
|
admin = false;
|
||||||
|
fx.listLogoReports.mockResolvedValue([report('spam')]);
|
||||||
|
await mount(logo({ reportCount: 1 }));
|
||||||
|
expect(buttons()).toEqual(['Melden']);
|
||||||
|
admin = true;
|
||||||
|
info.update();
|
||||||
|
await flush();
|
||||||
|
expect(buttons()).toEqual(['Melden', 'Löschen', 'Ausblenden']);
|
||||||
|
expect(reasons()).toEqual(['Spam']);
|
||||||
|
admin = false;
|
||||||
|
info.update();
|
||||||
|
expect(buttons()).toEqual(['Melden']);
|
||||||
|
expect(reportCount()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Englisch: Meldungen, Gründe und Knöpfe sind übersetzt', async () => {
|
||||||
|
setLang('en', { persist: false });
|
||||||
|
fx.listLogoReports.mockResolvedValue([report('spam')]);
|
||||||
|
await mount(logo({ reportCount: 1, ownerId: 'u1' }));
|
||||||
|
expect(buttons()).toEqual(['Report', 'Delete', 'Hide']);
|
||||||
|
expect(reasons()).toEqual(['Spam']);
|
||||||
|
row = logo({ hidden: true, reportCount: 1 });
|
||||||
|
info.update();
|
||||||
|
expect(buttons()).toEqual(['Delete', 'Unhide']);
|
||||||
|
expect(hiddenNote()).toBe('Hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Meldezeile', () => {
|
||||||
|
it('zeigt einen Text in einer Live-Region (role="alert") und löscht ihn mit einem leeren Text', async () => {
|
||||||
|
await mount();
|
||||||
|
expect(info.el.querySelector('[role="alert"]')!.classList.contains('auth-msg-error')).toBe(true);
|
||||||
|
info.setMessage('Etwas ging schief.');
|
||||||
|
expect(message()).toBe('Etwas ging schief.');
|
||||||
|
info.setMessage('');
|
||||||
|
expect(message()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Text steht auch ohne Auswahl (Fehler am Herz eines nicht gewählten Logos) und überlebt update()', async () => {
|
||||||
|
await mount(null);
|
||||||
|
info.setMessage('Dieses Logo ist nicht (mehr) verfügbar.');
|
||||||
|
info.update();
|
||||||
|
expect(message()).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine andere Auswahl löscht den Text; dieselbe Auswahl lässt ihn stehen', async () => {
|
||||||
|
await mount();
|
||||||
|
info.setMessage('Etwas ging schief.');
|
||||||
|
info.update();
|
||||||
|
row = logo({ likeCount: 13 });
|
||||||
|
info.update();
|
||||||
|
expect(message()).toBe('Etwas ging schief.');
|
||||||
|
choose(logo({ id: 'b', name: 'Logo B' }));
|
||||||
|
expect(message()).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('failureText', () => {
|
||||||
|
it('Fehler des Dienstes über ihren Schlüssel, alles andere über errorMessage', () => {
|
||||||
|
expect(failureText(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'))).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
|
||||||
|
expect(failureText(new TypeError('offline'))).toBe('Keine Verbindung zum Server.');
|
||||||
|
setLang('en', { persist: false });
|
||||||
|
expect(failureText(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'))).toBe('This logo is no longer available.');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
// Infozeile zum gewählten Logo unter dem Raster (Spec § 3 und § 6): „<Name> · von <Anzeigename> · ♥ <Zahl>“ und „Melden“. Wer das Logo
|
||||||
|
// hochgeladen hat, sieht dazu „Löschen“ (mit Rückfrage), Admins die Meldungen mit ihren Gründen sowie „Ausblenden“/„Einblenden“
|
||||||
|
// und „Löschen“. Zugleich die Meldezeile des Bereichs: Fehler von Herz, Hochladen und diesen Aktionen erscheinen hier, so bleibt
|
||||||
|
// der Statusbereich des Pickers den Ladefehlern der Liste vorbehalten.
|
||||||
|
// Die Zeilen und die Auswahl gehören dem Picker (`host`); die Infozeile liest sie und ändert sie nur über `patchRow` und `removeLogo`.
|
||||||
|
// Der Aufbau steht einmal da und `update()` frischt nur Texte und Sichtbarkeit auf: ein Knopf, der gerade den Fokus hat, behält ihn.
|
||||||
|
// Alle Texte gehen als Text in den DOM, nie als HTML.
|
||||||
|
import '../account/account.css';
|
||||||
|
import './logos.css';
|
||||||
|
import { errorMessage } from '../account/errors';
|
||||||
|
import { ApiError, apiErrorText } from '../community/api';
|
||||||
|
import { REPORT_REASONS, type ReportDialogOptions, type ReportOutcome } from '../community/report-dialog';
|
||||||
|
import type { ConfirmOptions } from '../editor/confirm-dialog';
|
||||||
|
import { el } from '../editor/dom';
|
||||||
|
import { msg, t } from '../i18n/i18n';
|
||||||
|
import { reportsLabel } from '../pages/format';
|
||||||
|
import type { deleteLogo, reportLogo, setLogoVisibility } from './logo-api';
|
||||||
|
import { forgetLogo, LOGO_MISSING } from './logo-asset';
|
||||||
|
import type { CommunityLogo, listLogoReports, LogoReport } from './logos';
|
||||||
|
|
||||||
|
export interface LogoInfoDeps {
|
||||||
|
reportLogo: typeof reportLogo;
|
||||||
|
deleteLogo: typeof deleteLogo;
|
||||||
|
setLogoVisibility: typeof setLogoVisibility;
|
||||||
|
listLogoReports: typeof listLogoReports;
|
||||||
|
/** Die Rückfrage vor dem Löschen; `signal` schließt sie (Seite verlassen). Löst mit true auf, wenn bestätigt wurde. */
|
||||||
|
confirm(opts: ConfirmOptions, signal?: AbortSignal): Promise<boolean>;
|
||||||
|
/** Der Melden-Dialog (für Logos: `subject: 'logo'`). */
|
||||||
|
reportDialog(opts: ReportDialogOptions): Promise<ReportOutcome>;
|
||||||
|
userId(): string | null;
|
||||||
|
isAdmin(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was die Infozeile vom Picker braucht: Auswahl und Zeile lesen, die Zeile ändern, ein gelöschtes Logo melden. */
|
||||||
|
export interface LogoInfoHost {
|
||||||
|
/** Die ID des gewählten Logos; null: kein Logo. */
|
||||||
|
selectedId(): string | null;
|
||||||
|
/** Die Zeile des gewählten Logos, sobald bekannt; null: kein Logo oder (noch) unbekannt. */
|
||||||
|
selected(): CommunityLogo | null;
|
||||||
|
/** Das gewählte Logo gibt es nicht (mehr): die Abfrage danach hat es nicht gefunden. */
|
||||||
|
gone(): boolean;
|
||||||
|
/** Ändert Felder der bekannten Zeile von `id` (Kachel und Infozeile folgen). */
|
||||||
|
patchRow(id: string, patch: Partial<CommunityLogo>): void;
|
||||||
|
/** Das Logo ist gelöscht: Kachel und Zeile entfernen, steht es in der Auswahl, wird sie aufgehoben. */
|
||||||
|
removeLogo(id: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogoInfo {
|
||||||
|
el: HTMLElement;
|
||||||
|
/** Frischt die Anzeige nach dem Stand des Pickers auf (Auswahl, Zeile, Zähler, Anmeldung). */
|
||||||
|
update(): void;
|
||||||
|
/** Zeigt einen Fehlertext in der Meldezeile; ein leerer Text löscht ihn. */
|
||||||
|
setMessage(text: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REASONS_ERROR = msg('Die Gründe konnten nicht geladen werden.');
|
||||||
|
const DELETE_TEXT = msg(
|
||||||
|
'Das Logo, seine Herzen und Meldungen werden endgültig gelöscht. Gespeicherte Karten zeigen es danach nicht mehr. Bereits veröffentlichte Kartenbilder bleiben unverändert.',
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Der Grund eines Fehlers zum Anzeigen: Meldungen des Dienstes über ihren Schlüssel, alles andere über `errorMessage`. */
|
||||||
|
export function failureText(err: unknown): string {
|
||||||
|
return err instanceof ApiError ? apiErrorText(err) : errorMessage(err, 'load');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Grund einer Meldung als Text: bekannte Werte mit ihrer Beschriftung, alles andere unverändert. */
|
||||||
|
function reasonLabel(reason: string): string {
|
||||||
|
const known = REPORT_REASONS.find((r) => r.value === reason);
|
||||||
|
return known ? t(known.label) : reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Gründe der Meldungen zu einem Stand (`key`: Logo-ID und Zahl der Meldungen); ein Admin holt sie einmal je Stand. */
|
||||||
|
interface Reasons {
|
||||||
|
key: string;
|
||||||
|
status: 'loading' | 'ready' | 'error';
|
||||||
|
list: LogoReport[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `signal` schließt offene Dialoge und beendet laufende Aktionen (Seite verlassen). */
|
||||||
|
export function createLogoInfo(host: LogoInfoHost, deps: LogoInfoDeps, signal: AbortSignal): LogoInfo {
|
||||||
|
/** Eine Aktion (Rückfrage, Dialog oder Anfrage) läuft: weitere Klicks bleiben ohne Wirkung. */
|
||||||
|
let busy = false;
|
||||||
|
/** Für diese Auswahl wurde zuletzt gezeichnet: ein Wechsel räumt Meldung und Gründe ab. */
|
||||||
|
let shownId: string | null = null;
|
||||||
|
let reasons: Reasons | null = null;
|
||||||
|
|
||||||
|
// ---- Aufbau ---------------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const nameEl = el('strong', { class: 'logo-info-name' });
|
||||||
|
const byEl = el('span', { class: 'logo-info-by' });
|
||||||
|
const heartsEl = el('span', { class: 'logo-info-hearts' });
|
||||||
|
const textEl = el('p', { class: 'logo-info-text' }, nameEl, ' · ', byEl, ' · ', heartsEl);
|
||||||
|
const hiddenEl = el('span', { class: 'logo-badge logo-info-hidden' }, t('Ausgeblendet'));
|
||||||
|
const reportButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small logo-info-report' }, t('Melden'));
|
||||||
|
const deleteButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small auth-btn-danger logo-info-delete' }, t('Löschen'));
|
||||||
|
const visibilityButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small logo-info-visibility' });
|
||||||
|
const actionsEl = el('div', { class: 'logo-info-actions' }, reportButton, deleteButton, visibilityButton);
|
||||||
|
const reportsEl = el('p', { class: 'logo-info-reports' });
|
||||||
|
const reasonsEl = el('ul', { class: 'logo-info-reasons' });
|
||||||
|
const noteEl = el('p', { class: 'logo-info-note' });
|
||||||
|
const adminEl = el('div', { class: 'logo-info-admin' }, reportsEl, reasonsEl, noteEl);
|
||||||
|
const cardEl = el('div', { class: 'logo-info-card' }, textEl, hiddenEl, actionsEl, adminEl);
|
||||||
|
// Immer da (leer nehmen sie keinen Platz ein), damit Screenreader den Text melden, der später hineinkommt.
|
||||||
|
const goneEl = el('p', { class: 'auth-msg logo-info-gone', role: 'status' });
|
||||||
|
const messageEl = el('p', { class: 'auth-msg auth-msg-error logo-info-msg', role: 'alert' });
|
||||||
|
const root = el('div', { class: 'logo-info' }, cardEl, goneEl, messageEl);
|
||||||
|
|
||||||
|
// ---- Anzeige --------------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function setMessage(text: string): void {
|
||||||
|
messageEl.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwner = (logo: CommunityLogo): boolean => {
|
||||||
|
const me = deps.userId();
|
||||||
|
return me !== null && logo.ownerId !== '' && logo.ownerId === me;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Löschen dürfen der Hochlader und Admins (der Dienst prüft es noch einmal). */
|
||||||
|
const mayDelete = (logo: CommunityLogo): boolean => deps.isAdmin() || isOwner(logo);
|
||||||
|
|
||||||
|
/** Lädt die Gründe der Meldungen eines Logos; eine Antwort für ein inzwischen anderes Logo oder einen anderen Stand zählt nicht. */
|
||||||
|
async function loadReasons(id: string, key: string): Promise<void> {
|
||||||
|
let loaded: Reasons;
|
||||||
|
try {
|
||||||
|
loaded = { key, status: 'ready', list: await deps.listLogoReports(id) };
|
||||||
|
} catch {
|
||||||
|
loaded = { key, status: 'error', list: [] };
|
||||||
|
}
|
||||||
|
if (signal.aborted || reasons?.key !== key) return;
|
||||||
|
reasons = loaded;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Meldungen eines Admins: Anzahl aus der Zeile, Gründe nachgeladen (nur, wenn es Meldungen gibt). */
|
||||||
|
function renderReports(logo: CommunityLogo): void {
|
||||||
|
reportsEl.textContent = reportsLabel(logo.reportCount);
|
||||||
|
const key = `${logo.id}:${logo.reportCount}`;
|
||||||
|
if (logo.reportCount > 0 && reasons?.key !== key) {
|
||||||
|
reasons = { key, status: 'loading', list: [] };
|
||||||
|
void loadReasons(logo.id, key);
|
||||||
|
}
|
||||||
|
const current = reasons?.key === key ? reasons : null;
|
||||||
|
reasonsEl.replaceChildren(...(current?.status === 'ready' ? current.list.map((r) => el('li', {}, reasonLabel(r.reason))) : []));
|
||||||
|
noteEl.textContent = current?.status === 'error' ? t(REASONS_ERROR) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(): void {
|
||||||
|
const id = host.selectedId();
|
||||||
|
if (id !== shownId) {
|
||||||
|
shownId = id;
|
||||||
|
reasons = null;
|
||||||
|
setMessage(''); // ein Fehler gehört zur Auswahl, bei der er auftrat
|
||||||
|
}
|
||||||
|
const logo = host.selected();
|
||||||
|
cardEl.hidden = logo === null;
|
||||||
|
goneEl.textContent = logo === null && host.gone() ? t(LOGO_MISSING) : '';
|
||||||
|
if (logo === null) return;
|
||||||
|
|
||||||
|
const admin = deps.isAdmin();
|
||||||
|
nameEl.textContent = logo.name;
|
||||||
|
byEl.textContent = t('von {name}', { name: logo.displayName || t('Anonym') });
|
||||||
|
heartsEl.textContent = `♥ ${logo.likeCount}`;
|
||||||
|
hiddenEl.hidden = !logo.hidden;
|
||||||
|
// Ein ausgeblendetes Logo kennt der Dienst für Besucher nicht: es lässt sich nicht melden.
|
||||||
|
reportButton.hidden = logo.hidden;
|
||||||
|
deleteButton.hidden = !mayDelete(logo);
|
||||||
|
visibilityButton.hidden = !admin;
|
||||||
|
visibilityButton.textContent = logo.hidden ? t('Einblenden') : t('Ausblenden');
|
||||||
|
actionsEl.hidden = reportButton.hidden && deleteButton.hidden && visibilityButton.hidden;
|
||||||
|
adminEl.hidden = !admin;
|
||||||
|
if (admin) renderReports(logo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Aktionen -------------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Führt eine Aktion aus: immer nur eine, die Meldezeile wird vorher geleert, ein Fehler erscheint dort als Text. */
|
||||||
|
async function run(action: () => Promise<void>): Promise<void> {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
setMessage('');
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
} catch (err) {
|
||||||
|
if (!signal.aborted) setMessage(failureText(err));
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function report(): Promise<void> {
|
||||||
|
const logo = host.selected();
|
||||||
|
if (!logo) return;
|
||||||
|
await deps.reportDialog({ subject: 'logo', signal, send: (reason) => deps.reportLogo(logo.id, reason) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(): Promise<void> {
|
||||||
|
const logo = host.selected();
|
||||||
|
if (!logo || !mayDelete(logo)) return;
|
||||||
|
const confirmed = await deps.confirm(
|
||||||
|
{
|
||||||
|
title: t('Logo „{name}“ löschen?', { name: logo.name }),
|
||||||
|
text: t(DELETE_TEXT),
|
||||||
|
confirmLabel: t('Löschen'),
|
||||||
|
cancelLabel: t('Abbrechen'),
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
// Während der Rückfrage kann die Seite verlassen oder die Sitzung gewechselt haben: dann nichts mehr senden.
|
||||||
|
if (!confirmed || signal.aborted || !mayDelete(logo)) return;
|
||||||
|
await deps.deleteLogo(logo.id);
|
||||||
|
forgetLogo(logo.id); // auch nach dem Verlassen der Seite: der Zwischenspeicher darf das gelöschte Logo nicht behalten
|
||||||
|
if (signal.aborted) return;
|
||||||
|
host.removeLogo(logo.id);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleVisibility(): Promise<void> {
|
||||||
|
const logo = host.selected();
|
||||||
|
if (!logo || !deps.isAdmin()) return;
|
||||||
|
const show = logo.hidden; // ausgeblendet: einblenden, sonst ausblenden
|
||||||
|
await deps.setLogoVisibility(logo.id, show);
|
||||||
|
// Zeile und Bilder neu holen: ein ausgeblendetes Logo gilt für die Karte als nicht verfügbar, ein eingeblendetes wieder als da.
|
||||||
|
forgetLogo(logo.id);
|
||||||
|
if (signal.aborted) return;
|
||||||
|
// Einblenden löscht die Meldungen (Spec § 6).
|
||||||
|
host.patchRow(logo.id, show ? { hidden: false, reportCount: 0 } : { hidden: true });
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
reportButton.addEventListener('click', () => void run(report));
|
||||||
|
deleteButton.addEventListener('click', () => void run(remove));
|
||||||
|
visibilityButton.addEventListener('click', () => void run(toggleVisibility));
|
||||||
|
|
||||||
|
render();
|
||||||
|
return { el: root, update: render, setMessage };
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
// Bereich „Logo unten rechts“ (Spec § 3 und § 5): Raster, Sortierung, Auswahl, Herzen und Hochladen. Datenschicht, Anmeldung und
|
// Bereich „Logo unten rechts“ (Spec § 3, § 5 und § 6): Raster, Sortierung, Auswahl, Herzen, Hochladen und die Infozeile zum gewählten
|
||||||
// Dialog sind Attrappen; die Kachelbilder laufen über das echte logo-image.ts mit ersetztem `Image` (dessen Einzelfälle stehen in
|
// Logo (Melden, Löschen, Admin). Datenschicht, Anmeldung und Dialoge sind Attrappen; die Kachelbilder laufen über das echte
|
||||||
// logo-image.test.ts).
|
// logo-image.ts mit ersetztem `Image` (dessen Einzelfälle stehen in logo-image.test.ts), die Infozeile über das echte logo-info.ts
|
||||||
|
// (dessen Einzelfälle stehen in logo-info.test.ts: hier zählt das Zusammenspiel mit Raster, Auswahl und Herzen).
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({ rememberLogo: vi.fn() }));
|
const mocks = vi.hoisted(() => ({ rememberLogo: vi.fn(), forgetLogo: vi.fn() }));
|
||||||
|
|
||||||
// Der Picker hängt (über die Standardwerte) an Appwrite, Anmeldung und Dialogen; hier kommt alles über `deps`.
|
// Der Picker hängt (über die Standardwerte) an Appwrite, Anmeldung und Dialogen; hier kommt alles über `deps`.
|
||||||
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
|
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
|
||||||
@@ -19,6 +20,7 @@ vi.mock('./logo-dialog', () => ({ logoUploadDialog: vi.fn() }));
|
|||||||
vi.mock('./logo-asset', async (importOriginal) => ({
|
vi.mock('./logo-asset', async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import('./logo-asset')>()),
|
...(await importOriginal<typeof import('./logo-asset')>()),
|
||||||
rememberLogo: mocks.rememberLogo,
|
rememberLogo: mocks.rememberLogo,
|
||||||
|
forgetLogo: mocks.forgetLogo,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { ApiError } from '../community/api';
|
import { ApiError } from '../community/api';
|
||||||
@@ -71,6 +73,12 @@ const fx = {
|
|||||||
loadLogoBlob: vi.fn<LogoPickerDeps['loadLogoBlob']>(),
|
loadLogoBlob: vi.fn<LogoPickerDeps['loadLogoBlob']>(),
|
||||||
requireLogin: vi.fn<LogoPickerDeps['requireLogin']>(),
|
requireLogin: vi.fn<LogoPickerDeps['requireLogin']>(),
|
||||||
uploadDialog: vi.fn<LogoPickerDeps['uploadDialog']>(),
|
uploadDialog: vi.fn<LogoPickerDeps['uploadDialog']>(),
|
||||||
|
reportLogo: vi.fn<LogoPickerDeps['reportLogo']>(),
|
||||||
|
deleteLogo: vi.fn<LogoPickerDeps['deleteLogo']>(),
|
||||||
|
setLogoVisibility: vi.fn<LogoPickerDeps['setLogoVisibility']>(),
|
||||||
|
listLogoReports: vi.fn<LogoPickerDeps['listLogoReports']>(),
|
||||||
|
confirm: vi.fn<LogoPickerDeps['confirm']>(),
|
||||||
|
reportDialog: vi.fn<LogoPickerDeps['reportDialog']>(),
|
||||||
onSelect: vi.fn<(id: string | null) => void>(),
|
onSelect: vi.fn<(id: string | null) => void>(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -88,6 +96,12 @@ const deps: LogoPickerDeps = {
|
|||||||
return () => authListeners.delete(fn);
|
return () => authListeners.delete(fn);
|
||||||
},
|
},
|
||||||
uploadDialog: fx.uploadDialog,
|
uploadDialog: fx.uploadDialog,
|
||||||
|
reportLogo: fx.reportLogo,
|
||||||
|
deleteLogo: fx.deleteLogo,
|
||||||
|
setLogoVisibility: fx.setLogoVisibility,
|
||||||
|
listLogoReports: fx.listLogoReports,
|
||||||
|
confirm: fx.confirm,
|
||||||
|
reportDialog: fx.reportDialog,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Bildprüfung: `loadLogoBlob` liefert einen Blob, den die Attrappe einem Logo zuordnet; die Object-URL erbt das, und das
|
// Bildprüfung: `loadLogoBlob` liefert einen Blob, den die Attrappe einem Logo zuordnet; die Object-URL erbt das, und das
|
||||||
@@ -122,7 +136,7 @@ let host: HTMLElement;
|
|||||||
let picker: LogoPicker;
|
let picker: LogoPicker;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
for (const fn of [...Object.values(fx), mocks.rememberLogo]) fn.mockReset();
|
for (const fn of [...Object.values(fx), mocks.rememberLogo, mocks.forgetLogo]) fn.mockReset();
|
||||||
user = 'u1';
|
user = 'u1';
|
||||||
admin = false;
|
admin = false;
|
||||||
authListeners.clear();
|
authListeners.clear();
|
||||||
@@ -142,6 +156,12 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
fx.requireLogin.mockResolvedValue(true);
|
fx.requireLogin.mockResolvedValue(true);
|
||||||
fx.uploadDialog.mockResolvedValue(null);
|
fx.uploadDialog.mockResolvedValue(null);
|
||||||
|
fx.reportLogo.mockResolvedValue({ schonGemeldet: false });
|
||||||
|
fx.deleteLogo.mockResolvedValue(undefined);
|
||||||
|
fx.setLogoVisibility.mockResolvedValue(undefined);
|
||||||
|
fx.listLogoReports.mockResolvedValue([]);
|
||||||
|
fx.confirm.mockResolvedValue(true);
|
||||||
|
fx.reportDialog.mockResolvedValue('cancelled');
|
||||||
URL.createObjectURL = vi.fn((blob: Blob) => {
|
URL.createObjectURL = vi.fn((blob: Blob) => {
|
||||||
const url = `blob:logo-${created.length + 1}`;
|
const url = `blob:logo-${created.length + 1}`;
|
||||||
created.push(url);
|
created.push(url);
|
||||||
@@ -190,6 +210,18 @@ const uploadButton = () => picker.el.querySelector<HTMLButtonElement>('button.lo
|
|||||||
const moreButton = () => picker.el.querySelector<HTMLButtonElement>('button.logo-more')!;
|
const moreButton = () => picker.el.querySelector<HTMLButtonElement>('button.logo-more')!;
|
||||||
const retryButton = () => picker.el.querySelector<HTMLButtonElement>('button.logo-retry')!;
|
const retryButton = () => picker.el.querySelector<HTMLButtonElement>('button.logo-retry')!;
|
||||||
const emptyText = () => picker.el.querySelector('.logo-empty')!.textContent;
|
const emptyText = () => picker.el.querySelector('.logo-empty')!.textContent;
|
||||||
|
// Infozeile (logo-info.ts): sichtbar ist, was weder selbst noch in einem Vorfahren mit `hidden` verborgen ist.
|
||||||
|
const infoEl = () => picker.el.querySelector<HTMLElement>('.logo-info')!;
|
||||||
|
const shown = (node: Element) => node.closest('[hidden]') === null;
|
||||||
|
const infoLine = () => {
|
||||||
|
const text = infoEl().querySelector('.logo-info-text')!;
|
||||||
|
return shown(text) ? text.textContent : '';
|
||||||
|
};
|
||||||
|
const infoGone = () => infoEl().querySelector('.logo-info-gone')!.textContent;
|
||||||
|
const infoMessage = () => infoEl().querySelector('[role="alert"]')!.textContent;
|
||||||
|
const infoButtons = () => [...infoEl().querySelectorAll('button')].filter(shown).map((b) => b.textContent);
|
||||||
|
const infoButton = (label: string) =>
|
||||||
|
[...infoEl().querySelectorAll('button')].find((b) => b.textContent === label && shown(b)) as HTMLButtonElement;
|
||||||
|
|
||||||
describe('Aufbau', () => {
|
describe('Aufbau', () => {
|
||||||
it('Kopf: Überschrift „Logo unten rechts“, Plakette „Community“, Knopf „Hochladen“ und der Satz zur Community', async () => {
|
it('Kopf: Überschrift „Logo unten rechts“, Plakette „Community“, Knopf „Hochladen“ und der Satz zur Community', async () => {
|
||||||
@@ -645,6 +677,7 @@ describe('Herzen', () => {
|
|||||||
expect(heart('Logo A').textContent).toBe('7');
|
expect(heart('Logo A').textContent).toBe('7');
|
||||||
expect(heart('Logo A').getAttribute('aria-label')).toBe('Herz für „Logo A“: 7');
|
expect(heart('Logo A').getAttribute('aria-label')).toBe('Herz für „Logo A“: 7');
|
||||||
expect(alertText()).toBe('');
|
expect(alertText()).toBe('');
|
||||||
|
expect(infoMessage()).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ein zweiter Klick nimmt das Herz zurück: setHeart(id, false), Zahl − 1', async () => {
|
it('ein zweiter Klick nimmt das Herz zurück: setHeart(id, false), Zahl − 1', async () => {
|
||||||
@@ -673,7 +706,7 @@ describe('Herzen', () => {
|
|||||||
expect(heart('Logo A').textContent).toBe('0');
|
expect(heart('Logo A').textContent).toBe('0');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Fehler: das Herz springt samt Zahl zurück, der Grund steht im Statusbereich (role="alert")', async () => {
|
it('Fehler: das Herz springt samt Zahl zurück, der Grund steht in der Infozeile (role="alert"), nicht im Statusbereich der Liste', async () => {
|
||||||
fx.listLogos.mockResolvedValue({ logos: [logo('a', { likeCount: 2 })], nextCursor: null });
|
fx.listLogos.mockResolvedValue({ logos: [logo('a', { likeCount: 2 })], nextCursor: null });
|
||||||
fx.setHeart.mockRejectedValue(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'));
|
fx.setHeart.mockRejectedValue(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'));
|
||||||
await mount();
|
await mount();
|
||||||
@@ -681,7 +714,8 @@ describe('Herzen', () => {
|
|||||||
await flush();
|
await flush();
|
||||||
expect(heart('Logo A').getAttribute('aria-pressed')).toBe('false');
|
expect(heart('Logo A').getAttribute('aria-pressed')).toBe('false');
|
||||||
expect(heart('Logo A').textContent).toBe('2');
|
expect(heart('Logo A').textContent).toBe('2');
|
||||||
expect(alertText()).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
|
expect(infoMessage()).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
|
||||||
|
expect(alertText()).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Fehler anderer Art: allgemeiner Text; der Fehler verschwindet beim nächsten Versuch, der dann auch wieder möglich ist', async () => {
|
it('Fehler anderer Art: allgemeiner Text; der Fehler verschwindet beim nächsten Versuch, der dann auch wieder möglich ist', async () => {
|
||||||
@@ -689,10 +723,10 @@ describe('Herzen', () => {
|
|||||||
await mount();
|
await mount();
|
||||||
heart('Logo A').click();
|
heart('Logo A').click();
|
||||||
await flush();
|
await flush();
|
||||||
expect(alertText()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
|
expect(infoMessage()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
|
||||||
expect(heartsOn()).toEqual([]);
|
expect(heartsOn()).toEqual([]);
|
||||||
heart('Logo A').click(); // die Anfrage ist zu Ende: der Knopf ist wieder frei
|
heart('Logo A').click(); // die Anfrage ist zu Ende: der Knopf ist wieder frei
|
||||||
expect(alertText()).toBe('');
|
expect(infoMessage()).toBe('');
|
||||||
await flush();
|
await flush();
|
||||||
expect(fx.setHeart).toHaveBeenCalledTimes(2);
|
expect(fx.setHeart).toHaveBeenCalledTimes(2);
|
||||||
expect(heartsOn()).toEqual(['Logo A']);
|
expect(heartsOn()).toEqual(['Logo A']);
|
||||||
@@ -752,6 +786,7 @@ describe('Herzen', () => {
|
|||||||
expect(heartsOn()).toEqual([]);
|
expect(heartsOn()).toEqual([]);
|
||||||
expect(heart('Logo A').textContent).toBe('0');
|
expect(heart('Logo A').textContent).toBe('0');
|
||||||
expect(alertText()).toBe('');
|
expect(alertText()).toBe('');
|
||||||
|
expect(infoMessage()).toBe('');
|
||||||
heart('Logo A').click(); // kein hängender Zustand: der nächste Klick fragt wieder nach dem Login
|
heart('Logo A').click(); // kein hängender Zustand: der nächste Klick fragt wieder nach dem Login
|
||||||
await flush();
|
await flush();
|
||||||
expect(fx.requireLogin).toHaveBeenCalledTimes(2);
|
expect(fx.requireLogin).toHaveBeenCalledTimes(2);
|
||||||
@@ -1000,12 +1035,13 @@ describe('Hochladen', () => {
|
|||||||
expect(mocks.rememberLogo).not.toHaveBeenCalled();
|
expect(mocks.rememberLogo).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lässt sich der Dialog gar nicht öffnen (Fehler), steht der Grund im Statusbereich', async () => {
|
it('lässt sich der Dialog gar nicht öffnen (Fehler), steht der Grund in der Infozeile (der Statusbereich der Liste bleibt für Ladefehler)', async () => {
|
||||||
fx.uploadDialog.mockRejectedValue(new Error('kein <dialog>'));
|
fx.uploadDialog.mockRejectedValue(new Error('kein <dialog>'));
|
||||||
await mount();
|
await mount();
|
||||||
uploadButton().click();
|
uploadButton().click();
|
||||||
await flush();
|
await flush();
|
||||||
expect(alertText()).toBe('Hochladen fehlgeschlagen – Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
|
expect(infoMessage()).toBe('Hochladen fehlgeschlagen – Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
|
||||||
|
expect(alertText()).toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1099,6 +1135,18 @@ describe('dispose', () => {
|
|||||||
expect(fx.myHearts).not.toHaveBeenCalled();
|
expect(fx.myHearts).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('select() nach dem Aufräumen ändert nichts und fragt nichts mehr an', async () => {
|
||||||
|
await mount();
|
||||||
|
picker.dispose();
|
||||||
|
picker.select('b');
|
||||||
|
picker.select('x');
|
||||||
|
await flush();
|
||||||
|
expect(pressedTiles()).toEqual(['Kein Logo']);
|
||||||
|
expect(fx.getLogo).not.toHaveBeenCalled();
|
||||||
|
expect(picker.selected()).toBeNull();
|
||||||
|
expect(infoLine()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
it('eine Herz-Antwort nach dem Aufräumen wird verworfen', async () => {
|
it('eine Herz-Antwort nach dem Aufräumen wird verworfen', async () => {
|
||||||
const answer = deferred<{ herz: boolean; likeCount: number }>();
|
const answer = deferred<{ herz: boolean; likeCount: number }>();
|
||||||
fx.setHeart.mockReturnValue(answer.promise);
|
fx.setHeart.mockReturnValue(answer.promise);
|
||||||
@@ -1110,3 +1158,436 @@ describe('dispose', () => {
|
|||||||
expect(heart('Logo A').textContent).toBe('1'); // der Stand von vor dem Aufräumen, kein Fehler
|
expect(heart('Logo A').textContent).toBe('1'); // der Stand von vor dem Aufräumen, kein Fehler
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Herzen: Fehler und Anmeldung', () => {
|
||||||
|
it('Gast: lässt sich der Login-Dialog nicht öffnen (requireLogin wirft), steht der Grund in der Infozeile; das Herz bleibt aus, und es geht danach wieder', async () => {
|
||||||
|
user = null;
|
||||||
|
fx.requireLogin.mockRejectedValueOnce(new Error('kein <dialog>'));
|
||||||
|
await mount();
|
||||||
|
heart('Logo A').click();
|
||||||
|
await flush();
|
||||||
|
expect(fx.setHeart).not.toHaveBeenCalled();
|
||||||
|
expect(heartsOn()).toEqual([]);
|
||||||
|
expect(infoMessage()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
|
||||||
|
heart('Logo A').click(); // kein hängender Zustand
|
||||||
|
await flush();
|
||||||
|
expect(fx.requireLogin).toHaveBeenCalledTimes(2);
|
||||||
|
expect(fx.setHeart).toHaveBeenCalledExactlyOnceWith('a', true);
|
||||||
|
expect(infoMessage()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Klick auf Herz oder „Hochladen“ löscht den Ladefehler der Liste nicht: der steht allein im Statusbereich', async () => {
|
||||||
|
fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' });
|
||||||
|
await mount();
|
||||||
|
fx.listLogos.mockRejectedValueOnce(new Error('offline'));
|
||||||
|
moreButton().click();
|
||||||
|
await flush();
|
||||||
|
expect(alertText()).toBe('Die Logos konnten nicht geladen werden.');
|
||||||
|
heart('Logo A').click();
|
||||||
|
await flush();
|
||||||
|
expect(alertText()).toBe('Die Logos konnten nicht geladen werden.');
|
||||||
|
uploadButton().click();
|
||||||
|
await flush();
|
||||||
|
expect(alertText()).toBe('Die Logos konnten nicht geladen werden.');
|
||||||
|
expect(retryButton().hidden).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Herz-Fehler steht auch dann in der Infozeile, wenn das Logo gar nicht gewählt ist', async () => {
|
||||||
|
fx.setHeart.mockRejectedValue(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'));
|
||||||
|
await mount();
|
||||||
|
picker.select('c');
|
||||||
|
heart('Logo A').click();
|
||||||
|
await flush();
|
||||||
|
expect(infoMessage()).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
|
||||||
|
expect(infoLine()).toBe('Logo C · von Kenso · ♥ 0');
|
||||||
|
picker.select('b'); // eine andere Auswahl räumt den alten Fehler ab
|
||||||
|
expect(infoMessage()).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Infozeile', () => {
|
||||||
|
it('sitzt als Letztes im Bereich, unter dem Raster und „Mehr laden“; ohne Auswahl ist sie leer', async () => {
|
||||||
|
await mount();
|
||||||
|
expect(picker.el.lastElementChild).toBe(infoEl());
|
||||||
|
const grid = picker.el.querySelector('ul.logo-grid')!;
|
||||||
|
expect(grid.compareDocumentPosition(infoEl()) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(infoLine()).toBe('');
|
||||||
|
expect(infoButtons()).toEqual([]);
|
||||||
|
expect(infoMessage()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('select(id) zeigt „<Name> · von <Anzeigename> · ♥ <Zahl>“ und „Melden“ – auch für Gäste; „Kein Logo“ leert sie', async () => {
|
||||||
|
user = null;
|
||||||
|
fx.listLogos.mockResolvedValue({ logos: [logo('a', { likeCount: 7 }), logo('b', { displayName: '' })], nextCursor: null });
|
||||||
|
await mount();
|
||||||
|
picker.select('a');
|
||||||
|
expect(infoLine()).toBe('Logo A · von Kenso · ♥ 7');
|
||||||
|
expect(infoButtons()).toEqual(['Melden']);
|
||||||
|
picker.select('b');
|
||||||
|
expect(infoLine()).toBe('Logo B · von Anonym · ♥ 0');
|
||||||
|
picker.select(null);
|
||||||
|
expect(infoLine()).toBe('');
|
||||||
|
expect(infoButtons()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Zähler der Infozeile folgt dem Herz: sofort und mit der Antwort des Dienstes', async () => {
|
||||||
|
const answer = deferred<{ herz: boolean; likeCount: number }>();
|
||||||
|
fx.setHeart.mockReturnValue(answer.promise);
|
||||||
|
await mount();
|
||||||
|
picker.select('a');
|
||||||
|
heart('Logo A').click();
|
||||||
|
expect(infoLine()).toBe('Logo A · von Kenso · ♥ 1');
|
||||||
|
answer.resolve({ herz: true, likeCount: 5 });
|
||||||
|
await flush();
|
||||||
|
expect(infoLine()).toBe('Logo A · von Kenso · ♥ 5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Logo außerhalb der Liste: die Zeile erscheint, sobald getLogo es geholt hat; vorher steht dort nichts', async () => {
|
||||||
|
const slow = deferred<CommunityLogo | null>();
|
||||||
|
fx.getLogo.mockReturnValue(slow.promise);
|
||||||
|
await mount();
|
||||||
|
picker.select('x');
|
||||||
|
await flush();
|
||||||
|
expect(infoLine()).toBe('');
|
||||||
|
expect(infoGone()).toBe('');
|
||||||
|
slow.resolve(logo('x', { name: 'Fremdes Logo', displayName: 'Anna', likeCount: 2 }));
|
||||||
|
await flush();
|
||||||
|
expect(infoLine()).toBe('Fremdes Logo · von Anna · ♥ 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('solange die Liste noch lädt und das Logo noch unbekannt ist, steht nichts da; danach die Zeile aus der Liste', async () => {
|
||||||
|
const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
|
||||||
|
fx.listLogos.mockReturnValue(list.promise);
|
||||||
|
picker = createLogoPicker({ onSelect: fx.onSelect }, deps);
|
||||||
|
host.append(picker.el);
|
||||||
|
picker.select('b');
|
||||||
|
await flush();
|
||||||
|
expect(infoLine()).toBe('');
|
||||||
|
expect(infoGone()).toBe('');
|
||||||
|
list.resolve({ logos: [logo('a'), logo('b', { likeCount: 3 })], nextCursor: null });
|
||||||
|
await flush();
|
||||||
|
expect(infoLine()).toBe('Logo B · von Kenso · ♥ 3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Logo, das es nicht mehr gibt (getLogo → null): „Das gewählte Logo ist nicht mehr verfügbar.“; eine andere Wahl nimmt den Hinweis weg', async () => {
|
||||||
|
await mount();
|
||||||
|
picker.select('weg');
|
||||||
|
await flush();
|
||||||
|
expect(infoGone()).toBe('Das gewählte Logo ist nicht mehr verfügbar.');
|
||||||
|
expect(infoLine()).toBe('');
|
||||||
|
expect(infoButtons()).toEqual([]);
|
||||||
|
picker.select('b');
|
||||||
|
expect(infoGone()).toBe('');
|
||||||
|
expect(infoLine()).toBe('Logo B · von Kenso · ♥ 0');
|
||||||
|
picker.select('weg'); // noch einmal: die Antwort von vorhin gilt nicht mehr, es wird neu gefragt
|
||||||
|
expect(infoGone()).toBe('');
|
||||||
|
await flush();
|
||||||
|
expect(infoGone()).toBe('Das gewählte Logo ist nicht mehr verfügbar.');
|
||||||
|
expect(fx.getLogo).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scheitert getLogo (Netz), sagt die Infozeile nichts: das Logo kann es ja geben', async () => {
|
||||||
|
fx.getLogo.mockRejectedValue(new Error('offline'));
|
||||||
|
await mount();
|
||||||
|
picker.select('x');
|
||||||
|
await flush();
|
||||||
|
expect(infoGone()).toBe('');
|
||||||
|
expect(infoLine()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine späte „gibt es nicht“-Antwort für eine inzwischen verlassene Auswahl bleibt ohne Wirkung', async () => {
|
||||||
|
const slow = deferred<CommunityLogo | null>();
|
||||||
|
fx.getLogo.mockReturnValueOnce(slow.promise);
|
||||||
|
await mount();
|
||||||
|
picker.select('x');
|
||||||
|
picker.select('b');
|
||||||
|
slow.resolve(null);
|
||||||
|
await flush();
|
||||||
|
expect(infoGone()).toBe('');
|
||||||
|
expect(infoLine()).toBe('Logo B · von Kenso · ♥ 0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('„Melden“ öffnet den Dialog für Logos (mit dem Signal des Pickers) und sendet reportLogo(id, Grund)', async () => {
|
||||||
|
await mount();
|
||||||
|
picker.select('b');
|
||||||
|
infoButton('Melden').click();
|
||||||
|
await flush();
|
||||||
|
expect(fx.reportDialog).toHaveBeenCalledTimes(1);
|
||||||
|
const opts = fx.reportDialog.mock.calls[0][0];
|
||||||
|
expect(opts.subject).toBe('logo');
|
||||||
|
await opts.send('urheberrecht');
|
||||||
|
expect(fx.reportLogo).toHaveBeenCalledExactlyOnceWith('b', 'urheberrecht');
|
||||||
|
expect(opts.signal!.aborted).toBe(false);
|
||||||
|
picker.dispose();
|
||||||
|
expect(opts.signal!.aborted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Anmelden und Abmelden: „Löschen“ erscheint für den Hochlader und verschwindet wieder', async () => {
|
||||||
|
user = null;
|
||||||
|
fx.listLogos.mockResolvedValue({ logos: [logo('a', { ownerId: 'u1' })], nextCursor: null });
|
||||||
|
await mount();
|
||||||
|
picker.select('a');
|
||||||
|
expect(infoButtons()).toEqual(['Melden']);
|
||||||
|
user = 'u1';
|
||||||
|
emitAuth();
|
||||||
|
await flush();
|
||||||
|
expect(infoButtons()).toEqual(['Melden', 'Löschen']);
|
||||||
|
user = null;
|
||||||
|
emitAuth();
|
||||||
|
expect(infoButtons()).toEqual(['Melden']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Admin: Anzahl und Gründe der Meldungen, „Ausblenden“ und „Löschen“ – aus der Zeile des Pickers und listLogoReports', async () => {
|
||||||
|
admin = true;
|
||||||
|
fx.listLogos.mockResolvedValue({ logos: [logo('a', { reportCount: 2 })], nextCursor: null });
|
||||||
|
fx.listLogoReports.mockResolvedValue([
|
||||||
|
{ id: 'r1', logoId: 'a', reason: 'spam', createdAt: '2026-10-04T12:00:00.000Z' },
|
||||||
|
{ id: 'r2', logoId: 'a', reason: 'sonstiges', createdAt: '2026-10-04T12:01:00.000Z' },
|
||||||
|
]);
|
||||||
|
await mount();
|
||||||
|
picker.select('a');
|
||||||
|
await flush();
|
||||||
|
expect(fx.listLogoReports).toHaveBeenCalledExactlyOnceWith('a');
|
||||||
|
expect(infoEl().querySelector('.logo-info-reports')!.textContent).toBe('2 Meldungen');
|
||||||
|
expect([...infoEl().querySelectorAll('.logo-info-reasons li')].map((li) => li.textContent)).toEqual(['Spam', 'Sonstiges']);
|
||||||
|
expect(infoButtons()).toEqual(['Melden', 'Löschen', 'Ausblenden']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Englisch: Zeile und Knöpfe sind übersetzt', async () => {
|
||||||
|
setLang('en', { persist: false });
|
||||||
|
fx.listLogos.mockResolvedValue({ logos: [logo('a', { ownerId: 'u1', displayName: '' })], nextCursor: null });
|
||||||
|
await mount();
|
||||||
|
picker.select('a');
|
||||||
|
expect(infoLine()).toBe('Logo A · by Anonymous · ♥ 0');
|
||||||
|
expect(infoButtons()).toEqual(['Report', 'Delete']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Löschen (mit Raster und Auswahl)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b', { ownerId: 'u1' }), logo('c')], nextCursor: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bestätigt: deleteLogo, forgetLogo, die Kachel ist weg, die Auswahl wird aufgehoben (onSelect(null)); die übrigen bleiben', async () => {
|
||||||
|
await mount();
|
||||||
|
picker.select('b');
|
||||||
|
infoButton('Löschen').click();
|
||||||
|
await flush();
|
||||||
|
expect(fx.deleteLogo).toHaveBeenCalledExactlyOnceWith('b');
|
||||||
|
expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('b');
|
||||||
|
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo C']);
|
||||||
|
expect(fx.onSelect).toHaveBeenCalledExactlyOnceWith(null);
|
||||||
|
expect(picker.selected()).toBeNull();
|
||||||
|
expect(infoLine()).toBe('');
|
||||||
|
expect(infoGone()).toBe('');
|
||||||
|
expect(infoMessage()).toBe('');
|
||||||
|
expect(moreButton().hidden).toBe(true); // es gab keine weitere Seite, und das Löschen erfindet keine
|
||||||
|
expect(revoked).toContain(created[1]); // das Bild der Kachel ist freigegeben
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Editor übernimmt onSelect(null) mit select(null): „Kein Logo“ ist gewählt, nichts bleibt hängen', async () => {
|
||||||
|
await mount();
|
||||||
|
picker.select('b');
|
||||||
|
infoButton('Löschen').click();
|
||||||
|
await flush();
|
||||||
|
picker.select(null);
|
||||||
|
expect(pressedTiles()).toEqual(['Kein Logo']);
|
||||||
|
expect(infoLine()).toBe('');
|
||||||
|
expect(fx.getLogo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('abgelehnt: nichts passiert', async () => {
|
||||||
|
fx.confirm.mockResolvedValue(false);
|
||||||
|
await mount();
|
||||||
|
picker.select('b');
|
||||||
|
infoButton('Löschen').click();
|
||||||
|
await flush();
|
||||||
|
expect(fx.deleteLogo).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.forgetLogo).not.toHaveBeenCalled();
|
||||||
|
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
|
||||||
|
expect(fx.onSelect).not.toHaveBeenCalled();
|
||||||
|
expect(infoMessage()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fehler: der Text steht in der Infozeile; Kachel, Zeile und Auswahl bleiben', async () => {
|
||||||
|
fx.deleteLogo.mockRejectedValue(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden'));
|
||||||
|
await mount();
|
||||||
|
picker.select('b');
|
||||||
|
infoButton('Löschen').click();
|
||||||
|
await flush();
|
||||||
|
expect(infoMessage()).toBe('Dazu hast du keine Berechtigung.');
|
||||||
|
expect(alertText()).toBe('');
|
||||||
|
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
|
||||||
|
expect(fx.onSelect).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.forgetLogo).not.toHaveBeenCalled();
|
||||||
|
expect(picker.selected()).toMatchObject({ id: 'b' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Löschen sehen nur Hochlader und Admins', async () => {
|
||||||
|
await mount();
|
||||||
|
picker.select('a'); // gehört u9
|
||||||
|
expect(infoButtons()).toEqual(['Melden']);
|
||||||
|
picker.select('b'); // gehört u1 = ich
|
||||||
|
expect(infoButtons()).toEqual(['Melden', 'Löschen']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('das letzte Logo gelöscht: der Leer-Text erscheint', async () => {
|
||||||
|
fx.listLogos.mockResolvedValue({ logos: [logo('a', { ownerId: 'u1' })], nextCursor: null });
|
||||||
|
await mount();
|
||||||
|
picker.select('a');
|
||||||
|
infoButton('Löschen').click();
|
||||||
|
await flush();
|
||||||
|
expect(names()).toEqual(['Kein Logo']);
|
||||||
|
expect(emptyText()).toBe('Noch keine Community-Logos – lade das erste hoch.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein angeheftetes Logo (nicht in der Liste) lässt sich löschen: seine Kachel verschwindet', async () => {
|
||||||
|
fx.getLogo.mockResolvedValue(logo('x', { name: 'Fremdes Logo', ownerId: 'u1' }));
|
||||||
|
await mount();
|
||||||
|
picker.select('x');
|
||||||
|
await flush();
|
||||||
|
expect(names()).toContain('Fremdes Logo');
|
||||||
|
infoButton('Löschen').click();
|
||||||
|
await flush();
|
||||||
|
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
|
||||||
|
expect(fx.onSelect).toHaveBeenCalledExactlyOnceWith(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hat sich die Auswahl während der Anfrage geändert, bleibt die neue Auswahl unberührt (kein onSelect)', async () => {
|
||||||
|
const request = deferred<void>();
|
||||||
|
fx.deleteLogo.mockReturnValue(request.promise);
|
||||||
|
await mount();
|
||||||
|
picker.select('b');
|
||||||
|
infoButton('Löschen').click();
|
||||||
|
await flush();
|
||||||
|
picker.select('c');
|
||||||
|
request.resolve();
|
||||||
|
await flush();
|
||||||
|
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo C']);
|
||||||
|
expect(fx.onSelect).not.toHaveBeenCalled();
|
||||||
|
expect(pressedTiles()).toEqual(['Logo C']);
|
||||||
|
expect(infoLine()).toBe('Logo C · von Kenso · ♥ 0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('war das gelöschte Logo der Cursor für „Mehr laden“ (die Anfrage braucht ein Logo, das es gibt), setzt das Nachladen beim Logo davor an', async () => {
|
||||||
|
fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b'), logo('c', { ownerId: 'u1' })], nextCursor: 'c' });
|
||||||
|
await mount();
|
||||||
|
picker.select('c');
|
||||||
|
infoButton('Löschen').click();
|
||||||
|
await flush();
|
||||||
|
expect(moreButton().hidden).toBe(false);
|
||||||
|
fx.listLogos.mockResolvedValue({ logos: [logo('d')], nextCursor: null });
|
||||||
|
moreButton().click();
|
||||||
|
await flush();
|
||||||
|
expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all', cursor: 'b' });
|
||||||
|
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo D']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein anderes gelöschtes Logo lässt den Cursor für „Mehr laden“ stehen', async () => {
|
||||||
|
fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b', { ownerId: 'u1' }), logo('c')], nextCursor: 'c' });
|
||||||
|
await mount();
|
||||||
|
picker.select('b');
|
||||||
|
infoButton('Löschen').click();
|
||||||
|
await flush();
|
||||||
|
fx.listLogos.mockResolvedValue({ logos: [logo('d')], nextCursor: null });
|
||||||
|
moreButton().click();
|
||||||
|
await flush();
|
||||||
|
expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all', cursor: 'c' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Logo mit derselben ID, das später auftaucht, erbt das Herz des gelöschten nicht', async () => {
|
||||||
|
fx.myHearts.mockResolvedValueOnce(new Set(['b']));
|
||||||
|
await mount();
|
||||||
|
expect(heartsOn()).toEqual(['Logo B']);
|
||||||
|
picker.select('b');
|
||||||
|
infoButton('Löschen').click();
|
||||||
|
await flush();
|
||||||
|
picker.select(null);
|
||||||
|
fx.myHearts.mockReturnValue(new Promise(() => {})); // die Herzen der neuen Liste kommen noch
|
||||||
|
fx.listLogos.mockResolvedValue({ logos: [logo('b', { name: 'Neues B' })], nextCursor: null });
|
||||||
|
sortButton('Neu').click();
|
||||||
|
await flush();
|
||||||
|
expect(names()).toEqual(['Kein Logo', 'Neues B']);
|
||||||
|
expect(heartsOn()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Fokus wandert von „Löschen“, das verschwindet, auf „Kein Logo“', async () => {
|
||||||
|
await mount();
|
||||||
|
picker.select('b');
|
||||||
|
infoButton('Löschen').focus();
|
||||||
|
expect(document.activeElement).toBe(infoButton('Löschen'));
|
||||||
|
infoButton('Löschen').click();
|
||||||
|
await flush();
|
||||||
|
expect(document.activeElement).toBe(tile('Kein Logo'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Fokus bleibt, wo er ist, wenn er nicht in der Infozeile lag', async () => {
|
||||||
|
await mount();
|
||||||
|
picker.select('b');
|
||||||
|
infoButton('Löschen').click();
|
||||||
|
tile('Logo C').focus();
|
||||||
|
await flush();
|
||||||
|
expect(document.activeElement).toBe(tile('Logo C'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Ausblenden und Einblenden (Admin, mit Raster und Auswahl)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
admin = true;
|
||||||
|
fx.listLogos.mockResolvedValue({
|
||||||
|
logos: [logo('a'), logo('b', { reportCount: 3 }), logo('c', { hidden: true, reportCount: 3 })],
|
||||||
|
nextCursor: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('„Ausblenden“: setLogoVisibility(id, false), forgetLogo; die Zeile des Pickers ist hidden, die Kachel bleibt', async () => {
|
||||||
|
await mount();
|
||||||
|
picker.select('b');
|
||||||
|
infoButton('Ausblenden').click();
|
||||||
|
await flush();
|
||||||
|
expect(fx.setLogoVisibility).toHaveBeenCalledExactlyOnceWith('b', false);
|
||||||
|
expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('b');
|
||||||
|
expect(picker.selected()).toMatchObject({ id: 'b', hidden: true, reportCount: 3 });
|
||||||
|
expect(names()).toContain('Logo B');
|
||||||
|
expect(infoButtons()).toEqual(['Löschen', 'Einblenden']);
|
||||||
|
expect(infoEl().querySelector('.logo-info-reports')!.textContent).toBe('3 Meldungen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('„Einblenden“: setLogoVisibility(id, true), forgetLogo; die Zeile ist sichtbar und hat 0 Meldungen', async () => {
|
||||||
|
await mount();
|
||||||
|
picker.select('c');
|
||||||
|
expect(infoButtons()).toEqual(['Löschen', 'Einblenden']);
|
||||||
|
infoButton('Einblenden').click();
|
||||||
|
await flush();
|
||||||
|
expect(fx.setLogoVisibility).toHaveBeenCalledExactlyOnceWith('c', true);
|
||||||
|
expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('c');
|
||||||
|
expect(picker.selected()).toMatchObject({ id: 'c', hidden: false, reportCount: 0 });
|
||||||
|
expect(infoButtons()).toEqual(['Melden', 'Löschen', 'Ausblenden']);
|
||||||
|
expect(infoEl().querySelector('.logo-info-reports')!.textContent).toBe('0 Meldungen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fehler: der Text steht in der Infozeile, die Zeile bleibt unverändert', async () => {
|
||||||
|
fx.setLogoVisibility.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'));
|
||||||
|
await mount();
|
||||||
|
picker.select('b');
|
||||||
|
infoButton('Ausblenden').click();
|
||||||
|
await flush();
|
||||||
|
expect(infoMessage()).toBe('Der Dienst ist gerade nicht erreichbar.');
|
||||||
|
expect(picker.selected()).toMatchObject({ id: 'b', hidden: false });
|
||||||
|
expect(mocks.forgetLogo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Herz-Zähler, der sich während der Anfrage ändert, geht nicht verloren (die Zeile wird mit dem aktuellen Stand fortgeschrieben)', async () => {
|
||||||
|
const request = deferred<void>();
|
||||||
|
fx.setLogoVisibility.mockReturnValue(request.promise);
|
||||||
|
fx.setHeart.mockResolvedValue({ herz: true, likeCount: 9 });
|
||||||
|
await mount();
|
||||||
|
picker.select('b');
|
||||||
|
infoButton('Ausblenden').click();
|
||||||
|
heart('Logo B').click();
|
||||||
|
await flush();
|
||||||
|
request.resolve();
|
||||||
|
await flush();
|
||||||
|
expect(picker.selected()).toMatchObject({ id: 'b', hidden: true, likeCount: 9 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,29 +1,29 @@
|
|||||||
// Bereich „Logo unten rechts“ im Editor (Spec § 3 und § 5): Raster der Community-Logos mit Sortierung, Auswahl, Herzen und dem
|
// Bereich „Logo unten rechts“ im Editor (Spec § 3, § 5 und § 6): Raster der Community-Logos mit Sortierung, Auswahl, Herzen und dem
|
||||||
// Knopf „Hochladen“. Die Daten kommen über `deps` (Standard: logos.ts, logo-api.ts, Anmeldung, Dialog „Logo hochladen“); alle Texte
|
// Knopf „Hochladen“, darunter die Infozeile zum gewählten Logo (logo-info.ts: Melden, Löschen, Admin-Aktionen). Die Daten kommen
|
||||||
// gehen als Text in den DOM, nie als HTML. Die Auswahl gehört dem Editor (`config.logo`): ein Klick meldet nur `onSelect`, markiert
|
// über `deps` (Standard: logos.ts, logo-api.ts, Anmeldung, Dialoge); alle Texte gehen als Text in den DOM, nie als HTML. Die Auswahl
|
||||||
// wird über `select()`. Infozeile, Melden, Löschen und die Admin-Aktionen zum gewählten Logo kommen mit Task 9 dazu.
|
// gehört dem Editor (`config.logo`): ein Klick meldet nur `onSelect`, markiert wird über `select()`. Fehler von Herz und Hochladen
|
||||||
|
// zeigt die Infozeile; der Statusbereich des Pickers ist den Ladefehlern der Liste vorbehalten.
|
||||||
import '../account/account.css';
|
import '../account/account.css';
|
||||||
import './logos.css';
|
import './logos.css';
|
||||||
import { currentUser, currentUserId, isAdmin, onAuthChange } from '../account/auth';
|
import { currentUser, currentUserId, isAdmin, onAuthChange } from '../account/auth';
|
||||||
import { errorMessage } from '../account/errors';
|
|
||||||
import { requireLogin } from '../account/login-dialog';
|
import { requireLogin } from '../account/login-dialog';
|
||||||
import { ApiError, apiErrorText } from '../community/api';
|
import { reportDialog } from '../community/report-dialog';
|
||||||
|
import { confirmDialog } from '../editor/confirm-dialog';
|
||||||
import { el, icon } from '../editor/dom';
|
import { el, icon } from '../editor/dom';
|
||||||
import { msg, t } from '../i18n/i18n';
|
import { msg, t } from '../i18n/i18n';
|
||||||
import { setHeart } from './logo-api';
|
import { deleteLogo, reportLogo, setHeart, setLogoVisibility } from './logo-api';
|
||||||
import { rememberLogo } from './logo-asset';
|
import { rememberLogo } from './logo-asset';
|
||||||
import { logoUploadDialog } from './logo-dialog';
|
import { logoUploadDialog } from './logo-dialog';
|
||||||
import { showLogoImage } from './logo-image';
|
import { showLogoImage } from './logo-image';
|
||||||
import { getLogo, listLogos, loadLogoBlob, myHearts, type CommunityLogo, type LogoFilter, type LogoSort } from './logos';
|
import { createLogoInfo, failureText, type LogoInfoDeps } from './logo-info';
|
||||||
|
import { getLogo, listLogoReports, listLogos, loadLogoBlob, myHearts, type CommunityLogo, type LogoFilter, type LogoSort } from './logos';
|
||||||
|
|
||||||
export interface LogoPickerDeps {
|
export interface LogoPickerDeps extends LogoInfoDeps {
|
||||||
listLogos: typeof listLogos;
|
listLogos: typeof listLogos;
|
||||||
getLogo: typeof getLogo;
|
getLogo: typeof getLogo;
|
||||||
myHearts: typeof myHearts;
|
myHearts: typeof myHearts;
|
||||||
setHeart: typeof setHeart;
|
setHeart: typeof setHeart;
|
||||||
loadLogoBlob: typeof loadLogoBlob;
|
loadLogoBlob: typeof loadLogoBlob;
|
||||||
userId(): string | null;
|
|
||||||
isAdmin(): boolean;
|
|
||||||
requireLogin(): Promise<boolean>;
|
requireLogin(): Promise<boolean>;
|
||||||
/** Meldet jede Änderung des Anmeldestatus; gibt die Abmeldung zurück. */
|
/** Meldet jede Änderung des Anmeldestatus; gibt die Abmeldung zurück. */
|
||||||
onAuthChange(fn: () => void): () => void;
|
onAuthChange(fn: () => void): () => void;
|
||||||
@@ -35,7 +35,7 @@ export interface LogoPicker {
|
|||||||
el: HTMLElement;
|
el: HTMLElement;
|
||||||
/** Markiert die Auswahl (null: kein Logo). Ein Logo, das nicht in der Liste steht, wird geholt und als zweite Kachel angeheftet. */
|
/** Markiert die Auswahl (null: kein Logo). Ein Logo, das nicht in der Liste steht, wird geholt und als zweite Kachel angeheftet. */
|
||||||
select(logoId: string | null): void;
|
select(logoId: string | null): void;
|
||||||
/** Die Zeile des gewählten Logos, sobald bekannt (für die Infozeile in Task 9); null = kein Logo oder unbekannt. */
|
/** Die Zeile des gewählten Logos, sobald bekannt; null = kein Logo oder unbekannt. */
|
||||||
selected(): CommunityLogo | null;
|
selected(): CommunityLogo | null;
|
||||||
/** Gibt die Object-URLs der Kacheln frei, schließt einen offenen Upload-Dialog und beendet das Abonnement der Anmeldung. */
|
/** Gibt die Object-URLs der Kacheln frei, schließt einen offenen Upload-Dialog und beendet das Abonnement der Anmeldung. */
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
@@ -82,14 +82,15 @@ function defaultDeps(): LogoPickerDeps {
|
|||||||
requireLogin: () => requireLogin('login'),
|
requireLogin: () => requireLogin('login'),
|
||||||
onAuthChange,
|
onAuthChange,
|
||||||
uploadDialog: (signal) => logoUploadDialog({ signal }),
|
uploadDialog: (signal) => logoUploadDialog({ signal }),
|
||||||
|
reportLogo,
|
||||||
|
deleteLogo,
|
||||||
|
setLogoVisibility,
|
||||||
|
listLogoReports,
|
||||||
|
confirm: (opts, signal) => confirmDialog(opts, signal),
|
||||||
|
reportDialog,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Der Grund eines Fehlers zum Anzeigen: Meldungen des Dienstes über ihren Schlüssel, alles andere über `errorMessage`. */
|
|
||||||
function failureText(err: unknown): string {
|
|
||||||
return err instanceof ApiError ? apiErrorText(err) : errorMessage(err, 'load');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createLogoPicker(handlers: { onSelect(id: string | null): void }, deps: LogoPickerDeps = defaultDeps()): LogoPicker {
|
export function createLogoPicker(handlers: { onSelect(id: string | null): void }, deps: LogoPickerDeps = defaultDeps()): LogoPicker {
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
@@ -115,6 +116,8 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
|||||||
/** Das gewählte Logo, wenn es nicht in der Liste steht: zweite Kachel direkt nach „Kein Logo“. */
|
/** Das gewählte Logo, wenn es nicht in der Liste steht: zweite Kachel direkt nach „Kein Logo“. */
|
||||||
let pinnedId: string | null = null;
|
let pinnedId: string | null = null;
|
||||||
let resolvingId: string | null = null;
|
let resolvingId: string | null = null;
|
||||||
|
/** Das gewählte Logo, nach dem `getLogo` nichts fand: es gibt es nicht (mehr). */
|
||||||
|
let goneId: string | null = null;
|
||||||
|
|
||||||
// Herzen
|
// Herzen
|
||||||
/** Die Logos, die ich mit einem Herz markiert habe. */
|
/** Die Logos, die ich mit einem Herz markiert habe. */
|
||||||
@@ -130,6 +133,24 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
|||||||
|
|
||||||
// ---- Aufbau ---------------------------------------------------------------------------------------------------------------
|
// ---- Aufbau ---------------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Die Zeile des gewählten Logos, sobald bekannt. */
|
||||||
|
const selectedRow = (): CommunityLogo | null => (selectedId === null ? null : (known.get(selectedId) ?? null));
|
||||||
|
|
||||||
|
const info = createLogoInfo(
|
||||||
|
{
|
||||||
|
selectedId: () => selectedId,
|
||||||
|
selected: selectedRow,
|
||||||
|
gone: () => selectedId !== null && goneId === selectedId,
|
||||||
|
patchRow(id, patch) {
|
||||||
|
const row = known.get(id);
|
||||||
|
if (row) setRow({ ...row, ...patch });
|
||||||
|
},
|
||||||
|
removeLogo,
|
||||||
|
},
|
||||||
|
deps,
|
||||||
|
abort.signal,
|
||||||
|
);
|
||||||
|
|
||||||
const uploadButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small logo-upload' }, icon('plus'), t('Hochladen'));
|
const uploadButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small logo-upload' }, icon('plus'), t('Hochladen'));
|
||||||
const sortBar = el('div', { class: 'logo-sort', role: 'group', 'aria-label': t('Sortierung') });
|
const sortBar = el('div', { class: 'logo-sort', role: 'group', 'aria-label': t('Sortierung') });
|
||||||
const sortButtons = new Map<View, HTMLButtonElement>();
|
const sortButtons = new Map<View, HTMLButtonElement>();
|
||||||
@@ -142,7 +163,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
|||||||
const noneCell = el('li', { class: 'logo-cell' }, noneButton);
|
const noneCell = el('li', { class: 'logo-cell' }, noneButton);
|
||||||
const grid = el('ul', { class: 'logo-grid', 'aria-labelledby': 'logo-picker-heading' }, noneCell);
|
const grid = el('ul', { class: 'logo-grid', 'aria-labelledby': 'logo-picker-heading' }, noneCell);
|
||||||
const emptyEl = el('p', { class: 'logo-empty' });
|
const emptyEl = el('p', { class: 'logo-empty' });
|
||||||
// Immer da (leer nimmt sie keinen Platz ein), damit Screenreader den Text melden, der später hineinkommt.
|
// Ladefehler der Liste. Immer da (leer nimmt sie keinen Platz ein), damit Screenreader den Text melden, der später hineinkommt.
|
||||||
const statusEl = el('p', { class: 'auth-msg auth-msg-error logo-status', role: 'alert' });
|
const statusEl = el('p', { class: 'auth-msg auth-msg-error logo-status', role: 'alert' });
|
||||||
const retryButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small logo-retry', hidden: true }, t('Nochmal versuchen'));
|
const retryButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small logo-retry', hidden: true }, t('Nochmal versuchen'));
|
||||||
const moreButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small logo-more', hidden: true }, t('Mehr laden'));
|
const moreButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small logo-more', hidden: true }, t('Mehr laden'));
|
||||||
@@ -163,6 +184,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
|||||||
statusEl,
|
statusEl,
|
||||||
retryButton,
|
retryButton,
|
||||||
moreButton,
|
moreButton,
|
||||||
|
info.el,
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---- Kacheln --------------------------------------------------------------------------------------------------------------
|
// ---- Kacheln --------------------------------------------------------------------------------------------------------------
|
||||||
@@ -176,13 +198,15 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
|||||||
tile.count.textContent = String(logo.likeCount);
|
tile.count.textContent = String(logo.likeCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Merkt die jüngste Zeile eines Logos und frischt seine Kachel auf. */
|
/** Merkt die jüngste Zeile eines Logos und frischt seine Kachel (und, wenn es gewählt ist, die Infozeile) auf. */
|
||||||
function setRow(row: CommunityLogo): void {
|
function setRow(row: CommunityLogo): void {
|
||||||
known.set(row.id, row);
|
known.set(row.id, row);
|
||||||
const tile = tiles.get(row.id);
|
const tile = tiles.get(row.id);
|
||||||
if (!tile) return;
|
if (tile) {
|
||||||
tile.logo = row;
|
tile.logo = row;
|
||||||
renderTile(tile);
|
renderTile(tile);
|
||||||
|
}
|
||||||
|
if (row.id === selectedId) info.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTile(logo: CommunityLogo): Tile {
|
function createTile(logo: CommunityLogo): Tile {
|
||||||
@@ -214,6 +238,26 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
|||||||
tiles.delete(tile.logo.id);
|
tiles.delete(tile.logo.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Das Logo ist gelöscht: Kachel und Zeile fallen weg; war es gewählt, hebt der Editor die Auswahl auf (`onSelect(null)`). */
|
||||||
|
function removeLogo(id: string): void {
|
||||||
|
const tile = tiles.get(id);
|
||||||
|
if (tile) dropTile(tile);
|
||||||
|
known.delete(id);
|
||||||
|
order = order.filter((other) => other !== id);
|
||||||
|
// „Mehr laden“ setzt hinter dem letzten geladenen Logo an (Cursor); die Abfrage braucht ein Logo, das es noch gibt.
|
||||||
|
if (nextCursor === id) nextCursor = order[order.length - 1] ?? null;
|
||||||
|
hearts.delete(id); // eine später vergebene gleiche ID erbt das Herz nicht
|
||||||
|
if (pinnedId === id) pinnedId = null;
|
||||||
|
renderState(); // aus der letzten Kachel wird der Leer-Text
|
||||||
|
if (selectedId === id) {
|
||||||
|
// Der Fokus lag auf „Löschen“, das mit dem Logo verschwindet: er geht auf „Kein Logo“, die neue Auswahl.
|
||||||
|
const focused = info.el.contains(document.activeElement);
|
||||||
|
handlers.onSelect(null);
|
||||||
|
if (focused) noneButton.focus();
|
||||||
|
}
|
||||||
|
info.update();
|
||||||
|
}
|
||||||
|
|
||||||
/** Das gewählte Logo steht direkt nach „Kein Logo“, solange es nicht in der geladenen Liste ist (und seine Zeile bekannt ist). */
|
/** Das gewählte Logo steht direkt nach „Kein Logo“, solange es nicht in der geladenen Liste ist (und seine Zeile bekannt ist). */
|
||||||
function syncPin(): void {
|
function syncPin(): void {
|
||||||
const row = selectedId !== null && !order.includes(selectedId) ? known.get(selectedId) : undefined;
|
const row = selectedId !== null && !order.includes(selectedId) ? known.get(selectedId) : undefined;
|
||||||
@@ -340,25 +384,36 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
|||||||
if (id === null || known.has(id) || resolvingId === id) return;
|
if (id === null || known.has(id) || resolvingId === id) return;
|
||||||
resolvingId = id;
|
resolvingId = id;
|
||||||
let row: CommunityLogo | null = null;
|
let row: CommunityLogo | null = null;
|
||||||
|
let answered = false;
|
||||||
try {
|
try {
|
||||||
row = await deps.getLogo(id);
|
row = await deps.getLogo(id);
|
||||||
|
answered = true;
|
||||||
} catch {
|
} catch {
|
||||||
// bleibt ohne Kachel
|
// bleibt ohne Kachel; ein Netzfehler heißt nicht, dass es das Logo nicht mehr gibt
|
||||||
} finally {
|
} finally {
|
||||||
if (resolvingId === id) resolvingId = null;
|
if (resolvingId === id) resolvingId = null;
|
||||||
}
|
}
|
||||||
if (disposed || row === null || selectedId !== id) return; // inzwischen eine andere Auswahl
|
if (disposed || selectedId !== id) return; // inzwischen eine andere Auswahl
|
||||||
|
if (row === null) {
|
||||||
|
if (answered) {
|
||||||
|
goneId = id;
|
||||||
|
info.update();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
setRow(row);
|
setRow(row);
|
||||||
syncPin();
|
syncPin();
|
||||||
void loadHearts([id]);
|
void loadHearts([id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function select(id: string | null): void {
|
function select(id: string | null): void {
|
||||||
if (id === selectedId) return; // der Editor ruft das bei jeder Änderung seines Zustands auf
|
if (disposed || id === selectedId) return; // der Editor ruft das bei jeder Änderung seines Zustands auf
|
||||||
selectedId = id;
|
selectedId = id;
|
||||||
|
goneId = null;
|
||||||
noneButton.setAttribute('aria-pressed', String(id === null));
|
noneButton.setAttribute('aria-pressed', String(id === null));
|
||||||
for (const tile of tiles.values()) renderTile(tile);
|
for (const tile of tiles.values()) renderTile(tile);
|
||||||
syncPin();
|
syncPin();
|
||||||
|
info.update();
|
||||||
// Erst wenn die Liste da ist: steht das Logo darin, braucht es weder eine Anfrage noch einen angehefteten Eintrag.
|
// Erst wenn die Liste da ist: steht das Logo darin, braucht es weder eine Anfrage noch einen angehefteten Eintrag.
|
||||||
if (loaded || failed !== null) void resolveSelection();
|
if (loaded || failed !== null) void resolveSelection();
|
||||||
}
|
}
|
||||||
@@ -403,11 +458,18 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
|||||||
async function toggleHeart(id: string): Promise<void> {
|
async function toggleHeart(id: string): Promise<void> {
|
||||||
if (pending.has(id)) return; // eine Anfrage für dieses Herz läuft: weitere Klicks bleiben ohne Wirkung
|
if (pending.has(id)) return; // eine Anfrage für dieses Herz läuft: weitere Klicks bleiben ohne Wirkung
|
||||||
pending.add(id);
|
pending.add(id);
|
||||||
statusEl.textContent = '';
|
info.setMessage('');
|
||||||
try {
|
try {
|
||||||
let on = !hearts.has(id);
|
let on = !hearts.has(id);
|
||||||
if (deps.userId() === null) {
|
if (deps.userId() === null) {
|
||||||
if (!(await deps.requireLogin()) || disposed) return; // Anmeldung abgebrochen: es passiert nichts
|
let loggedIn: boolean;
|
||||||
|
try {
|
||||||
|
loggedIn = await deps.requireLogin();
|
||||||
|
} catch (err) {
|
||||||
|
if (!disposed) info.setMessage(failureText(err)); // der Login-Dialog ließ sich nicht öffnen
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!loggedIn || disposed) return; // Anmeldung abgebrochen: es passiert nichts
|
||||||
on = true; // nach der Anmeldung wird das Herz gesetzt
|
on = true; // nach der Anmeldung wird das Herz gesetzt
|
||||||
}
|
}
|
||||||
const row = known.get(id);
|
const row = known.get(id);
|
||||||
@@ -420,7 +482,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
showHeart(id, before.on, before.likeCount);
|
showHeart(id, before.on, before.likeCount);
|
||||||
statusEl.textContent = failureText(err);
|
info.setMessage(failureText(err));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
pending.delete(id);
|
pending.delete(id);
|
||||||
@@ -432,7 +494,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
|||||||
async function upload(): Promise<void> {
|
async function upload(): Promise<void> {
|
||||||
if (uploading) return;
|
if (uploading) return;
|
||||||
uploading = true;
|
uploading = true;
|
||||||
statusEl.textContent = '';
|
info.setMessage('');
|
||||||
try {
|
try {
|
||||||
if (deps.userId() === null && !(await deps.requireLogin())) return; // Gast: zuerst anmelden; abgebrochen: es passiert nichts
|
if (deps.userId() === null && !(await deps.requireLogin())) return; // Gast: zuerst anmelden; abgebrochen: es passiert nichts
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
@@ -442,7 +504,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
|||||||
addFirst(logo);
|
addFirst(logo);
|
||||||
handlers.onSelect(logo.id);
|
handlers.onSelect(logo.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!disposed) statusEl.textContent = t('Hochladen fehlgeschlagen – {reason}', { reason: failureText(err) });
|
if (!disposed) info.setMessage(t('Hochladen fehlgeschlagen – {reason}', { reason: failureText(err) }));
|
||||||
} finally {
|
} finally {
|
||||||
uploading = false;
|
uploading = false;
|
||||||
}
|
}
|
||||||
@@ -462,6 +524,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
|||||||
void load('reset');
|
void load('reset');
|
||||||
}
|
}
|
||||||
renderSortBar();
|
renderSortBar();
|
||||||
|
info.update(); // Hochlader und Admins sehen mehr Knöpfe
|
||||||
if (deps.userId() === null) {
|
if (deps.userId() === null) {
|
||||||
hearts.clear();
|
hearts.clear();
|
||||||
for (const tile of tiles.values()) renderTile(tile);
|
for (const tile of tiles.values()) renderTile(tile);
|
||||||
@@ -477,7 +540,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
|||||||
return {
|
return {
|
||||||
el: root,
|
el: root,
|
||||||
select,
|
select,
|
||||||
selected: () => (selectedId === null ? null : (known.get(selectedId) ?? null)),
|
selected: selectedRow,
|
||||||
dispose() {
|
dispose() {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
|||||||
+111
-10
@@ -1,6 +1,6 @@
|
|||||||
/* Community-Logos. Dialog „Logo hochladen“: Hülle, Felder, Pflicht-Häkchen, Meldungen und Knöpfe kommen aus community.css
|
/* Community-Logos. Dialog „Logo hochladen“: Hülle, Felder, Pflicht-Häkchen, Meldungen und Knöpfe kommen aus community.css
|
||||||
(Melden-/Veröffentlichen-Dialog) und account.css; hier steht nur, was neu ist: das Datei-Feld und die Vorschau.
|
(Melden-/Veröffentlichen-Dialog) und account.css; hier steht nur, was neu ist: das Datei-Feld und die Vorschau.
|
||||||
Weiter unten der Bereich „Logo unten rechts“ im Editor (Raster, Sortierung, Herzen); seine Kacheln beruhen auf `.tile` aus editor.css. */
|
Weiter unten der Bereich „Logo unten rechts“ im Editor (Raster, Sortierung, Herzen, Infozeile); seine Kacheln beruhen auf `.tile` aus editor.css. */
|
||||||
|
|
||||||
/* Etwas breiter als der Veröffentlichen-Dialog (480 px): zwei Vorschauen nebeneinander. Mit zwei Klassen, damit die Breite
|
/* Etwas breiter als der Veröffentlichen-Dialog (480 px): zwei Vorschauen nebeneinander. Mit zwei Klassen, damit die Breite
|
||||||
nicht von der Reihenfolge der Stylesheets abhängt. */
|
nicht von der Reihenfolge der Stylesheets abhängt. */
|
||||||
@@ -228,11 +228,13 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* `height: 100%` füllt die Zelle (das Raster streckt sie auf die Höhe der Reihe): „Kein Logo“ ist so hoch wie sein Nachbar. */
|
||||||
.logo-tile {
|
.logo-tile {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
@@ -293,13 +295,20 @@
|
|||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Herz: ein eigener Knopf neben dem Auswahlknopf, über der Bildfläche (also immer auf dunklem Grund). Gedrückt: gefülltes Herz. */
|
/* Name und „von …“ lassen rechts Platz für das Herz (nur Logo-Kacheln: „Kein Logo“ hat keins). */
|
||||||
|
.logo-tile:not(.logo-tile-none) .logo-tile-name,
|
||||||
|
.logo-tile:not(.logo-tile-none) .logo-tile-by {
|
||||||
|
padding-right: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Herz: ein eigener Knopf neben dem Auswahlknopf, am Ende der Textzeile unter der Bildfläche (liegt also nicht auf dem Logo, sondern
|
||||||
|
auf dem Grund der Kachel und folgt dem Theme), mittig neben Name und „von …“. Gedrückt: gefülltes Herz. */
|
||||||
|
|
||||||
.logo-heart {
|
.logo-heart {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 14px;
|
right: 8px;
|
||||||
right: 14px;
|
bottom: 11px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -308,24 +317,26 @@
|
|||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 9px;
|
padding: 0 9px;
|
||||||
border: 0;
|
border: 1px solid var(--line);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgb(15 24 40 / 0.78);
|
background: transparent;
|
||||||
color: #e8eef7;
|
color: var(--muted);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.12s;
|
transition: background-color 0.12s, border-color 0.12s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-heart:hover {
|
.logo-heart:hover {
|
||||||
background: rgb(15 24 40 / 0.95);
|
border-color: var(--line-strong);
|
||||||
|
background: var(--panel-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-heart[aria-pressed='true'] {
|
.logo-heart[aria-pressed='true'] {
|
||||||
color: #ff7a8a;
|
border-color: var(--danger-text);
|
||||||
|
color: var(--danger-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-heart[aria-pressed='true'] .icon {
|
.logo-heart[aria-pressed='true'] .icon {
|
||||||
@@ -355,6 +366,96 @@
|
|||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Infozeile zum gewählten Logo (unter dem Raster): Karte mit Name, Urheber und Herzen, Knöpfen und, für Admins, den Meldungen.
|
||||||
|
Ohne Auswahl ist die Karte verborgen; die beiden Meldezeilen (Hinweis, Fehler) sind immer da. */
|
||||||
|
|
||||||
|
.logo-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* `hidden` gilt auch dort, wo ein Stil `display` setzt (und außerhalb des Editors, der `.editor [hidden]` mitbringt). */
|
||||||
|
.logo-info [hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-info-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-info-text {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-info-name {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-info-hearts {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-info-hidden {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-info-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-info-admin {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-info-reports {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-info-reasons {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-info-reasons:empty,
|
||||||
|
.logo-info-note:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-info-note {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-info-gone {
|
||||||
|
padding-left: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.logo-sort-btn,
|
.logo-sort-btn,
|
||||||
.logo-heart {
|
.logo-heart {
|
||||||
|
|||||||
Reference in new issue
Block a user