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
@@ -1,29 +1,29 @@
|
||||
// Bereich „Logo unten rechts“ im Editor (Spec § 3 und § 5): 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
|
||||
// gehen als Text in den DOM, nie als HTML. Die Auswahl gehört dem Editor (`config.logo`): ein Klick meldet nur `onSelect`, markiert
|
||||
// wird über `select()`. Infozeile, Melden, Löschen und die Admin-Aktionen zum gewählten Logo kommen mit Task 9 dazu.
|
||||
// Bereich „Logo unten rechts“ im Editor (Spec § 3, § 5 und § 6): Raster der Community-Logos mit Sortierung, Auswahl, Herzen und dem
|
||||
// Knopf „Hochladen“, darunter die Infozeile zum gewählten Logo (logo-info.ts: Melden, Löschen, Admin-Aktionen). Die Daten kommen
|
||||
// über `deps` (Standard: logos.ts, logo-api.ts, Anmeldung, Dialoge); alle Texte gehen als Text in den DOM, nie als HTML. Die Auswahl
|
||||
// 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 './logos.css';
|
||||
import { currentUser, currentUserId, isAdmin, onAuthChange } from '../account/auth';
|
||||
import { errorMessage } from '../account/errors';
|
||||
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 { msg, t } from '../i18n/i18n';
|
||||
import { setHeart } from './logo-api';
|
||||
import { deleteLogo, reportLogo, setHeart, setLogoVisibility } from './logo-api';
|
||||
import { rememberLogo } from './logo-asset';
|
||||
import { logoUploadDialog } from './logo-dialog';
|
||||
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;
|
||||
getLogo: typeof getLogo;
|
||||
myHearts: typeof myHearts;
|
||||
setHeart: typeof setHeart;
|
||||
loadLogoBlob: typeof loadLogoBlob;
|
||||
userId(): string | null;
|
||||
isAdmin(): boolean;
|
||||
requireLogin(): Promise<boolean>;
|
||||
/** Meldet jede Änderung des Anmeldestatus; gibt die Abmeldung zurück. */
|
||||
onAuthChange(fn: () => void): () => void;
|
||||
@@ -35,7 +35,7 @@ export interface LogoPicker {
|
||||
el: HTMLElement;
|
||||
/** 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;
|
||||
/** 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;
|
||||
/** Gibt die Object-URLs der Kacheln frei, schließt einen offenen Upload-Dialog und beendet das Abonnement der Anmeldung. */
|
||||
dispose(): void;
|
||||
@@ -82,14 +82,15 @@ function defaultDeps(): LogoPickerDeps {
|
||||
requireLogin: () => requireLogin('login'),
|
||||
onAuthChange,
|
||||
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 {
|
||||
const abort = new AbortController();
|
||||
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“. */
|
||||
let pinnedId: 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
|
||||
/** Die Logos, die ich mit einem Herz markiert habe. */
|
||||
@@ -130,6 +133,24 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
||||
|
||||
// ---- 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 sortBar = el('div', { class: 'logo-sort', role: 'group', 'aria-label': t('Sortierung') });
|
||||
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 grid = el('ul', { class: 'logo-grid', 'aria-labelledby': 'logo-picker-heading' }, noneCell);
|
||||
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 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'));
|
||||
@@ -163,6 +184,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
||||
statusEl,
|
||||
retryButton,
|
||||
moreButton,
|
||||
info.el,
|
||||
);
|
||||
|
||||
// ---- Kacheln --------------------------------------------------------------------------------------------------------------
|
||||
@@ -176,13 +198,15 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
||||
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 {
|
||||
known.set(row.id, row);
|
||||
const tile = tiles.get(row.id);
|
||||
if (!tile) return;
|
||||
tile.logo = row;
|
||||
renderTile(tile);
|
||||
if (tile) {
|
||||
tile.logo = row;
|
||||
renderTile(tile);
|
||||
}
|
||||
if (row.id === selectedId) info.update();
|
||||
}
|
||||
|
||||
function createTile(logo: CommunityLogo): Tile {
|
||||
@@ -214,6 +238,26 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
||||
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). */
|
||||
function syncPin(): void {
|
||||
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;
|
||||
resolvingId = id;
|
||||
let row: CommunityLogo | null = null;
|
||||
let answered = false;
|
||||
try {
|
||||
row = await deps.getLogo(id);
|
||||
answered = true;
|
||||
} catch {
|
||||
// bleibt ohne Kachel
|
||||
// bleibt ohne Kachel; ein Netzfehler heißt nicht, dass es das Logo nicht mehr gibt
|
||||
} finally {
|
||||
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);
|
||||
syncPin();
|
||||
void loadHearts([id]);
|
||||
}
|
||||
|
||||
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;
|
||||
goneId = null;
|
||||
noneButton.setAttribute('aria-pressed', String(id === null));
|
||||
for (const tile of tiles.values()) renderTile(tile);
|
||||
syncPin();
|
||||
info.update();
|
||||
// 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();
|
||||
}
|
||||
@@ -403,11 +458,18 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): 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
|
||||
pending.add(id);
|
||||
statusEl.textContent = '';
|
||||
info.setMessage('');
|
||||
try {
|
||||
let on = !hearts.has(id);
|
||||
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
|
||||
}
|
||||
const row = known.get(id);
|
||||
@@ -420,7 +482,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
||||
} catch (err) {
|
||||
if (disposed) return;
|
||||
showHeart(id, before.on, before.likeCount);
|
||||
statusEl.textContent = failureText(err);
|
||||
info.setMessage(failureText(err));
|
||||
}
|
||||
} finally {
|
||||
pending.delete(id);
|
||||
@@ -432,7 +494,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
||||
async function upload(): Promise<void> {
|
||||
if (uploading) return;
|
||||
uploading = true;
|
||||
statusEl.textContent = '';
|
||||
info.setMessage('');
|
||||
try {
|
||||
if (deps.userId() === null && !(await deps.requireLogin())) return; // Gast: zuerst anmelden; abgebrochen: es passiert nichts
|
||||
if (disposed) return;
|
||||
@@ -442,7 +504,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
||||
addFirst(logo);
|
||||
handlers.onSelect(logo.id);
|
||||
} 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 {
|
||||
uploading = false;
|
||||
}
|
||||
@@ -462,6 +524,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
||||
void load('reset');
|
||||
}
|
||||
renderSortBar();
|
||||
info.update(); // Hochlader und Admins sehen mehr Knöpfe
|
||||
if (deps.userId() === null) {
|
||||
hearts.clear();
|
||||
for (const tile of tiles.values()) renderTile(tile);
|
||||
@@ -477,7 +540,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
||||
return {
|
||||
el: root,
|
||||
select,
|
||||
selected: () => (selectedId === null ? null : (known.get(selectedId) ?? null)),
|
||||
selected: selectedRow,
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
|
||||
Reference in new issue
Block a user