fix(community): Zeile „von …“ bleibt leer, solange der Name des Hochladers noch gesucht wird

Logo-Kacheln, Logo-Infozeile und Detailseite zeigen keinen Ersatz („von Anonym“, alter Anzeigename) mehr, der gleich auf „von @name“
wechselt: Solange die Abfrage für den Hochlader läuft, ist die Zeile leer und behält ihre Höhe (U+200B vor dem leeren Inhalt in
logos.css und community.css); danach steht der Name, ohne Profil oder bei einem Fehler der Ersatz. Schon bekannte Hochlader zeigen
ihre Zeile sofort, ein gemeldeter Name beendet das Warten. Die Infozeile lässt dabei Zeile und Trenner weg („<Name> · ♥ <Zahl>“),
LogoInfoHost bekommt byline(logo) statt names().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 08:20:24 +00:00
1 parent 56cb67be4b
commit dc6bd3362b
9 files changed
+470 -34

No files matched your search

+6
View File
@@ -564,6 +564,12 @@
overflow-wrap: anywhere;
}
/* Solange der Name des Autors noch gesucht wird, ist die Zeile leer (kein Knoten darin). Ein Zeichen ohne Breite (U+200B) hält die Zeile
in der Höhe des Textes, damit die Seite nicht springt, wenn „von @name · Datum“ dazukommt. */
.detail-meta:empty::before {
content: '\200b';
}
.detail-tags {
display: flex;
flex-wrap: wrap;
+46
View File
@@ -0,0 +1,46 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
// Als Text lesen: vitest verarbeitet CSS-Importe (auch ?raw) nicht zu Text (wie community/live-regions.test.ts).
const logosCss = readFileSync(resolve(import.meta.dirname, 'logos.css'), 'utf8');
const communityCss = readFileSync(resolve(import.meta.dirname, '../community/community.css'), 'utf8');
/** Die Deklarationen einer Regel, deren Auswahlkriterium genau `selector` ist (mehrere Regeln: alle zusammen). */
function declarations(css: string, selector: string): string {
const found: string[] = [];
for (const rule of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
const selectors = rule[1].replace(/\/\*[\s\S]*?\*\//g, '').split(',').map((part) => part.trim());
if (selectors.includes(selector)) found.push(rule[2]);
}
return found.join('\n');
}
// Solange der Name des Hochladers noch gesucht wird, ist die Zeile „von …“ leer (logo-picker.ts, community-page.ts: kein Knoten im Element).
// Ein leeres Block-Element hätte keine Höhe: die Kachel bzw. die Detailseite spränge, wenn der Name dazukommt. Darum hält ein Zeichen ohne
// Breite (U+200B) vor dem leeren Inhalt eine Zeile in der Höhe des Textes.
describe('Die leere Zeile „von …“ behält ihre Zeilenhöhe', () => {
it.each([
['logos.css', '.logo-tile-by:empty::before', logosCss],
['community.css', '.detail-meta:empty::before', communityCss],
])('%s: %s setzt ein Zeichen ohne Breite (U+200B) davor', (_file, selector, css) => {
const rule = declarations(css, selector);
expect(rule).not.toBe('');
expect(rule).toMatch(/content\s*:\s*['"]\\200b['"]/i);
});
it.each([
['logos.css', '.logo-tile-by', logosCss],
['community.css', '.detail-meta', communityCss],
])('%s: %s selbst bleibt im Layout (kein display: none, kein visibility: hidden), sonst entfiele die Zeile ganz', (_file, selector, css) => {
const rule = declarations(css, selector);
expect(rule).not.toMatch(/display\s*:\s*none/);
expect(rule).not.toMatch(/visibility\s*:\s*hidden/);
});
it('die Kachelzeile ist ein Block mit einer einzigen Zeile (kein Umbruch): die Höhe des Zeichens ist die Höhe der Zeile', () => {
const rule = declarations(logosCss, '.logo-tile-by');
expect(rule).toMatch(/display\s*:\s*block/);
expect(rule).toMatch(/white-space\s*:\s*nowrap/);
});
});
+74 -2
View File
@@ -19,6 +19,7 @@ vi.mock('./logo-asset', async (importOriginal) => ({
forgetLogo: mocks.forgetLogo,
}));
import { byline } from '../account/profile';
import { ApiError } from '../community/api';
import { setLang } from '../i18n/i18n';
import { createLogoInfo, failureText, type LogoInfo, type LogoInfoDeps, type LogoInfoHost } from './logo-info';
@@ -61,8 +62,10 @@ let row: CommunityLogo | null;
let gone: boolean;
let user: string | null;
let admin: boolean;
/** Die Benutzernamen, die der Picker kennt (Konto-ID → Name), so wie `host.names()` sie liefert. */
/** Die Benutzernamen, die der Picker kennt (Konto-ID → Name). */
let owners: Map<string, string>;
/** Die Hochlader, deren Name der Picker noch sucht: ihre Zeile „von …“ ist leer. */
let waiting: Set<string>;
let abort: AbortController;
let info: LogoInfo;
@@ -70,7 +73,8 @@ const host = {
selectedId: () => selectedId,
selected: () => row,
gone: () => gone,
names: () => owners,
// So bildet der Picker die Zeile: Profil, alter Anzeigename oder „Anonym“, solange der Name aber noch gesucht wird, nichts.
byline: (logo: CommunityLogo) => (waiting.has(logo.ownerId) ? '' : byline(logo.ownerId, logo.displayName, owners)),
patchRow: vi.fn((id: string, patch: Partial<CommunityLogo>) => {
if (row?.id === id) row = { ...row, ...patch };
}),
@@ -99,6 +103,7 @@ beforeEach(() => {
user = 'u1';
admin = false;
owners = new Map();
waiting = new Set();
abort = new AbortController();
fx.reportLogo.mockResolvedValue({ schonGemeldet: false });
fx.deleteLogo.mockResolvedValue(undefined);
@@ -225,6 +230,73 @@ describe('Anzeige', () => {
expect(buttons()).toEqual([]);
});
describe('solange der Name des Hochladers noch gesucht wird', () => {
it('steht keine Zeile „von …“, auch kein Ersatz und kein Trenner: „<Name> · ♥ <Zahl>“', async () => {
waiting.add('u9');
await mount(logo({ displayName: 'Alter Name' }));
expect(line()).toBe('Logo A · ♥ 12');
expect(info.el.querySelector('.logo-info-by')).toBeNull(); // die Zeile ist gar nicht eingehängt
expect(buttons()).toEqual(['Melden']); // der Rest der Infozeile ist da
});
it('ist der Name gefunden, steht die Zeile samt Trenner zwischen Name und Herzen (update())', async () => {
waiting.add('u9');
await mount(logo({ displayName: '' }));
owners.set('u9', 'Kenso');
waiting.delete('u9');
info.update();
expect(line()).toBe('Logo A · von @Kenso · ♥ 12');
expect([...info.el.querySelector('.logo-info-text')!.children].map((node) => node.className)).toEqual([
'logo-info-name',
'logo-info-by',
'logo-info-hearts',
]);
});
it('gibt es kein Profil (oder schlug die Suche fehl), steht danach der Ersatz: alter Anzeigename bzw. „von Anonym“', async () => {
waiting.add('u9');
await mount(logo({ displayName: 'Anna' }));
expect(line()).toBe('Logo A · ♥ 12');
waiting.delete('u9');
info.update();
expect(line()).toBe('Logo A · von Anna · ♥ 12');
choose(logo({ displayName: '' }));
expect(line()).toBe('Logo A · von Anonym · ♥ 12');
});
it('ein Wechsel der Auswahl zwischen wartendem und fertigem Hochlader baut die Zeile jedes Mal richtig auf', async () => {
owners.set('u9', 'Kenso');
waiting.add('u8');
await mount();
expect(line()).toBe('Logo A · von @Kenso · ♥ 12');
choose(logo({ id: 'b', name: 'Logo B', ownerId: 'u8', likeCount: 3 }));
expect(line()).toBe('Logo B · ♥ 3');
choose(logo({ id: 'c', name: 'Logo C', ownerId: 'u9', likeCount: 4 }));
expect(line()).toBe('Logo C · von @Kenso · ♥ 4');
choose(null);
expect(line()).toBe('');
});
it('die Zählung der Herzen und die Knöpfe laufen währenddessen weiter (update() ohne neue Zeile)', async () => {
waiting.add('u9');
await mount();
row = logo({ likeCount: 13 });
info.update();
expect(line()).toBe('Logo A · ♥ 13');
});
it('Englisch: dieselbe Reihenfolge, „by @name“ erst mit dem Namen', async () => {
setLang('en', { persist: false });
waiting.add('u9');
await mount(logo({ displayName: '' }));
expect(line()).toBe('Logo A · ♥ 12');
owners.set('u9', 'Kenso');
waiting.delete('u9');
info.update();
expect(line()).toBe('Logo A · by @Kenso · ♥ 12');
});
});
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
+13 -4
View File
@@ -9,7 +9,6 @@
import '../account/account.css';
import './logos.css';
import { errorMessage } from '../account/errors';
import { byline } from '../account/profile';
import { ApiError, apiErrorText } from '../community/api';
import { REPORT_REASONS, type ReportDialogOptions, type ReportOutcome } from '../community/report-dialog';
import type { ConfirmOptions } from '../editor/confirm-dialog';
@@ -41,8 +40,11 @@ export interface LogoInfoHost {
selected(): CommunityLogo | null;
/** Das gewählte Logo gibt es nicht (mehr): die Abfrage danach hat es nicht gefunden. */
gone(): boolean;
/** Die Benutzernamen der Hochlader, die der Picker kennt (Konto-ID → Name); ohne Eintrag gilt der alte Anzeigename bzw. „Anonym“. */
names(): ReadonlyMap<string, string>;
/**
* Die Zeile „von …“ des Logos (Benutzername, sonst alter Anzeigename, sonst „Anonym“). Leer, solange der Name des Hochladers noch gesucht
* wird: ein Ersatz, der gleich wieder wechselt, läse sich falsch.
*/
byline(logo: CommunityLogo): string;
/** Ä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. */
@@ -94,6 +96,8 @@ export function createLogoInfo(host: LogoInfoHost, deps: LogoInfoDeps, signal: A
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);
/** Steht die Zeile „von …“ (mit ihrem Trenner) in `textEl`? Ohne sie, solange der Name noch gesucht wird, heißt es „<Name> · ♥ <Zahl>“. */
let byShown = true;
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'));
@@ -167,7 +171,12 @@ export function createLogoInfo(host: LogoInfoHost, deps: LogoInfoDeps, signal: A
const admin = deps.isAdmin();
nameEl.textContent = logo.name;
byEl.textContent = byline(logo.ownerId, logo.displayName, host.names());
const by = host.byline(logo);
byEl.textContent = by;
if ((by !== '') !== byShown) {
byShown = by !== '';
textEl.replaceChildren(nameEl, ...(byShown ? [' · ', byEl] : []), ' · ', heartsEl);
}
heartsEl.textContent = `♥ ${logo.likeCount}`;
hiddenEl.hidden = !logo.hidden;
// Ein ausgeblendetes Logo kennt der Dienst für Besucher nicht: es lässt sich nicht melden.
+153 -5
View File
@@ -385,17 +385,165 @@ describe('Benutzernamen („von @name“)', () => {
expect(by('Logo A')).toBe('von @Kenso');
});
it('die Liste wartet nicht auf die Namen: bis sie da sind, steht der Ersatz, danach der Name', async () => {
it('die Liste wartet nicht auf die Namen: die Kacheln stehen sofort, ihre Zeile „von …“ folgt mit den Namen', async () => {
const lookup = deferred<Map<string, string>>();
fx.usernamesFor.mockReturnValue(lookup.promise);
await mount();
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von Anonym', 'von Anna', 'von Anonym']);
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['', '', '']);
lookup.resolve(new Map([['u1', 'Kenso'], ['u2', 'Mia']]));
await flush();
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von @Kenso', 'von @Mia', 'von Anonym']);
});
describe('solange der Name seines Hochladers noch gesucht wird, ist die Zeile „von …“ leer (kein Ersatz, der gleich wieder wechselt)', () => {
const line = (name: string) => cell(name).querySelector('.logo-tile-by')!;
it('gefunden: danach „von @name“, im selben Element; leer heißt ohne jeden Knoten (die Zeilenhöhe hält die CSS-Regel für :empty)', async () => {
const lookup = deferred<Map<string, string>>();
fx.usernamesFor.mockReturnValue(lookup.promise);
await mount();
const span = line('Logo A');
expect(span.textContent).toBe('');
expect(span.childNodes).toHaveLength(0); // sonst griffe :empty nicht
lookup.resolve(new Map([['u1', 'Kenso']]));
await flush();
expect(line('Logo A')).toBe(span);
expect(span.textContent).toBe('von @Kenso');
});
it('kein Profil: erst jetzt der Ersatz, der alte Anzeigename (ohne @) bzw. „von Anonym“', async () => {
const lookup = deferred<Map<string, string>>();
fx.usernamesFor.mockReturnValue(lookup.promise);
await mount();
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['', '', '']);
lookup.resolve(new Map());
await flush();
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von Anonym', 'von Anna', 'von Anonym']);
});
it('die Suche scheitert: erst jetzt der Ersatz, kein Fehlertext', async () => {
const lookup = deferred<Map<string, string>>();
fx.usernamesFor.mockReturnValue(lookup.promise);
await mount();
expect(by('Logo B')).toBe('');
lookup.reject(new TypeError('Failed to fetch'));
await flush();
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von Anonym', 'von Anna', 'von Anonym']);
expect(alertText()).toBe('');
expect(infoMessage()).toBe('');
});
it('nur die Hochlader, deren Name noch gesucht wird, sind leer; die anderen Zeilen stehen schon (Mehr laden: Namen und „kein Profil“ von der ersten Seite gelten sofort)', async () => {
fx.listLogos.mockResolvedValueOnce({ logos: [owned('a', 'u1'), owned('b', 'u2', 'Anna')], nextCursor: 'b' });
fx.listLogos.mockResolvedValueOnce({
logos: [owned('c', 'u3'), owned('d', 'u1'), owned('e', 'u2', 'Berta')],
nextCursor: null,
});
fx.usernamesFor.mockResolvedValueOnce(new Map([['u1', 'Kenso']])); // erste Seite: u1 hat ein Profil, u2 keins
const second = deferred<Map<string, string>>();
fx.usernamesFor.mockReturnValueOnce(second.promise);
await mount();
expect(['Logo A', 'Logo B'].map(by)).toEqual(['von @Kenso', 'von Anna']);
moreButton().click();
await flush();
expect(by('Logo D')).toBe('von @Kenso'); // der Name steht schon
expect(by('Logo E')).toBe('von Berta'); // „kein Profil“ steht schon fest
expect(by('Logo C')).toBe(''); // u3 wird noch gesucht
second.resolve(new Map([['u3', 'Zora']]));
await flush();
expect(by('Logo C')).toBe('von @Zora');
expect(by('Logo D')).toBe('von @Kenso');
});
it('Einträge ohne Konto-ID haben nichts zu suchen: ihre Zeile steht sofort', async () => {
fx.listLogos.mockResolvedValue({ logos: [owned('a', 'u1'), owned('b', '', 'Anna'), owned('c', '')], nextCursor: null });
fx.usernamesFor.mockReturnValue(new Promise(() => {}));
await mount();
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['', 'von Anna', 'von Anonym']);
});
it('die Infozeile: ohne Zeile „von …“ („<Name> · ♥ <Zahl>“), danach mit dem Namen oder dem Ersatz', async () => {
const lookup = deferred<Map<string, string>>();
fx.usernamesFor.mockReturnValue(lookup.promise);
await mount();
picker.select('a');
expect(infoLine()).toBe('Logo A · ♥ 0');
picker.select('b');
expect(infoLine()).toBe('Logo B · ♥ 0');
lookup.resolve(new Map([['u1', 'Kenso']]));
await flush();
expect(infoLine()).toBe('Logo B · von Anna · ♥ 0'); // u2: kein Profil, der alte Anzeigename
picker.select('a');
expect(infoLine()).toBe('Logo A · von @Kenso · ♥ 0');
});
it('die Infozeile bei gescheiterter Suche: der Ersatz', async () => {
const lookup = deferred<Map<string, string>>();
fx.usernamesFor.mockReturnValue(lookup.promise);
await mount();
picker.select('b');
expect(infoLine()).toBe('Logo B · ♥ 0');
lookup.reject(new Error('weg'));
await flush();
expect(infoLine()).toBe('Logo B · von Anna · ♥ 0');
expect(infoMessage()).toBe('');
});
it('ein gemeldeter Name (Umbenennen, Lesen des eigenen Profils) beendet das Warten dieses Kontos sofort, die anderen bleiben leer', async () => {
const lookup = deferred<Map<string, string>>();
fx.usernamesFor.mockReturnValue(lookup.promise);
await mount();
picker.select('a');
emitName('u1', 'Kenso');
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von @Kenso', '', '']);
expect(infoLine()).toBe('Logo A · von @Kenso · ♥ 0');
lookup.resolve(new Map([['u1', 'Kenso']]));
await flush();
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von @Kenso', 'von Anna', 'von Anonym']);
});
it('das angeheftete Logo (gewählt, nicht in der Liste): leer, bis sein Hochlader nachgeschlagen ist', async () => {
fx.getLogo.mockResolvedValue(owned('x', 'u7'));
const lookup = deferred<Map<string, string>>();
fx.usernamesFor.mockResolvedValueOnce(new Map()); // die Liste
fx.usernamesFor.mockReturnValueOnce(lookup.promise); // das angeheftete Logo
await mount();
picker.select('x');
await flush();
expect(by('Logo X')).toBe('');
expect(infoLine()).toBe('Logo X · ♥ 0');
lookup.resolve(new Map([['u7', 'Zora']]));
await flush();
expect(by('Logo X')).toBe('von @Zora');
expect(infoLine()).toBe('Logo X · von @Zora · ♥ 0');
});
it('ein frisch hochgeladenes Logo eines noch unbekannten Hochladers: leer, bis sein Name da ist', async () => {
const lookup = deferred<Map<string, string>>();
fx.usernamesFor.mockResolvedValueOnce(new Map()); // die Liste
fx.usernamesFor.mockReturnValueOnce(lookup.promise); // das neue Logo
fx.uploadDialog.mockResolvedValue(logo('n', { name: 'Neues Logo', ownerId: 'u5', displayName: '' }));
await mount();
uploadButton().click();
await flush();
expect(by('Neues Logo')).toBe('');
lookup.resolve(new Map([['u5', 'Kenso']]));
await flush();
expect(by('Neues Logo')).toBe('von @Kenso');
});
it('wird die Seite aufgeräumt, bevor die Suche fertig ist, ändert eine spätere Antwort nichts', async () => {
const lookup = deferred<Map<string, string>>();
fx.usernamesFor.mockReturnValue(lookup.promise);
await mount();
picker.dispose();
lookup.resolve(new Map([['u1', 'Kenso']]));
await flush();
expect(by('Logo A')).toBe('');
});
});
it('der Name als Text, nie als HTML', async () => {
fx.usernamesFor.mockResolvedValue(new Map([['u1', '<b>fett</b>']]));
await mount();
@@ -408,7 +556,7 @@ describe('Benutzernamen („von @name“)', () => {
fx.usernamesFor.mockReturnValue(lookup.promise);
await mount();
picker.select('a');
expect(infoLine()).toBe('Logo A · von Anonym · ♥ 0');
expect(infoLine()).toBe('Logo A · ♥ 0'); // der Name wird noch gesucht: keine Zeile „von …“
lookup.resolve(new Map([['u1', 'Kenso']]));
await flush();
expect(infoLine()).toBe('Logo A · von @Kenso · ♥ 0');
@@ -538,9 +686,9 @@ describe('Benutzernamen („von @name“)', () => {
expect(nameListeners.size).toBe(0);
lookup.resolve(new Map([['u1', 'Kenso']]));
await flush();
expect(by('Logo A')).toBe('von Anonym');
expect(by('Logo A')).toBe(''); // die Antwort kam nach dem Aufräumen: nichts ändert sich
emitName('u1', 'Neu');
expect(by('Logo A')).toBe('von Anonym');
expect(by('Logo A')).toBe('');
});
it('Englisch: „by @name“, „by Anna“, „by Anonymous“', async () => {
+28 -10
View File
@@ -4,7 +4,9 @@
// 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.
// „von @name“ (Spec § 5): Kacheln und Infozeile zeigen den Benutzernamen des Hochladers. Die Namen kommen nach dem Laden einer Seite in
// einer Abfrage für alle ihre Hochlader nach (bis dahin, und wenn sie scheitert, steht der alte Anzeigename bzw. „Anonym“); ein eigenes
// einer Abfrage für alle ihre Hochlader nach. Solange der Name eines Hochladers noch gesucht wird, ist seine Zeile „von …“ leer (ein
// Ersatz, der gleich wieder wechselt, läse sich falsch; die Kachel behält ihre Zeilenhöhe, siehe logos.css); danach steht der Name,
// ohne Profil oder bei einem Fehler der alte Anzeigename bzw. „Anonym“. Schon bekannte Hochlader zeigen ihre Zeile sofort. Ein eigenes
// Umbenennen aktualisiert die eigenen Einträge sofort. Hochladen verlangt vorher einen Benutzernamen.
import '../account/account.css';
import './logos.css';
@@ -151,6 +153,13 @@ export function createLogoPicker(handlers: LogoPickerHandlers, deps: LogoPickerD
const tiles = new Map<string, Tile>();
/** Die Benutzernamen der Hochlader (Konto-ID → Name), soweit bekannt; wer fehlt, hat kein Profil oder wurde noch nicht gefragt. */
const ownerNames = new Map<string, string>();
/** Die Hochlader, deren Name nachgeschlagen ist (gefunden oder nicht, auch bei einem Fehler): ihre Zeile „von …“ steht fest. */
const settledOwners = new Set<string>();
/** Wird der Name dieses Hochladers noch gesucht? Wer schon einen Namen hat oder nachgeschlagen ist, nicht; Einträge ohne Konto-ID haben nichts zu suchen. */
const ownerPending = (ownerId: string): boolean => ownerId !== '' && !ownerNames.has(ownerId) && !settledOwners.has(ownerId);
/** Die Zeile „von …“ eines Logos; leer, solange der Name seines Hochladers noch gesucht wird. */
const bylineOf = (logo: CommunityLogo): string =>
ownerPending(logo.ownerId) ? '' : byline(logo.ownerId, logo.displayName, ownerNames);
let selectedId: string | null = null;
/** Das gewählte Logo, wenn es nicht in der Liste steht: zweite Kachel direkt nach „Kein Logo“. */
let pinnedId: string | null = null;
@@ -180,7 +189,7 @@ export function createLogoPicker(handlers: LogoPickerHandlers, deps: LogoPickerD
selectedId: () => selectedId,
selected: selectedRow,
gone: () => selectedId !== null && goneId === selectedId,
names: () => ownerNames,
byline: bylineOf,
patchRow(id, patch) {
const row = known.get(id);
if (row) setRow({ ...row, ...patch });
@@ -236,7 +245,7 @@ export function createLogoPicker(handlers: LogoPickerHandlers, deps: LogoPickerD
/** Auswahl, „von @name“, Herz und Zähler einer Kachel nach dem aktuellen Stand. */
function renderTile(tile: Tile): void {
const { logo } = tile;
const by = byline(logo.ownerId, logo.displayName, ownerNames);
const by = bylineOf(logo);
if (tile.by.textContent !== by) tile.by.textContent = by; // unverändert bleibt der Text stehen (nichts wird neu gezeichnet)
tile.select.setAttribute('aria-pressed', String(logo.id === selectedId));
tile.heart.setAttribute('aria-pressed', String(hearts.has(logo.id)));
@@ -496,9 +505,18 @@ export function createLogoPicker(handlers: LogoPickerHandlers, deps: LogoPickerD
// ---- Benutzernamen --------------------------------------------------------------------------------------------------------
/** Übernimmt Namen und zeichnet die Zeilen „von @name“ der betroffenen Kacheln (und der Infozeile) neu; nur, was sich ändert. */
function applyNames(found: Iterable<readonly [string, string]>): void {
/**
* Hält fest, was über Hochlader feststeht: `asked` sind nachgeschlagen (auch ohne Profil oder bei einem Fehler), `found` sind ihre Namen
* (auch ein gemeldeter eigener Name). Zeichnet die Zeilen „von …“ der Kacheln (und der Infozeile) neu, die sich dadurch ändern: die
* der Hochlader, die gerade fertig wurden oder einen neuen Namen haben.
*/
function learnOwners(asked: Iterable<string>, found: Iterable<readonly [string, string]>): void {
const changed = new Set<string>();
for (const userId of asked) {
if (settledOwners.has(userId)) continue;
settledOwners.add(userId);
changed.add(userId);
}
for (const [userId, name] of found) {
if (name === '' || ownerNames.get(userId) === name) continue;
ownerNames.set(userId, name);
@@ -512,18 +530,18 @@ export function createLogoPicker(handlers: LogoPickerHandlers, deps: LogoPickerD
/**
* Holt die Benutzernamen der Hochlader dieser Zeilen, eine Abfrage für alle (jeder nur einmal). Die Anzeige braucht sie nur als
* Beiwerk: schlägt die Abfrage fehl, bleibt es bei dem Ersatz (alter Anzeigename bzw. „Anonym“), ohne Fehlertext.
* Beiwerk: schlägt die Abfrage fehl, steht danach der Ersatz (alter Anzeigename bzw. „Anonym“), ohne Fehlertext.
*/
async function loadOwners(rows: readonly CommunityLogo[]): Promise<void> {
const ids = [...new Set(rows.map((row) => row.ownerId).filter((id) => id !== ''))];
if (ids.length === 0) return;
let found: Map<string, string>;
let found = new Map<string, string>();
try {
found = await deps.usernamesFor(ids);
} catch {
return;
// nur Beiwerk: es bleibt beim Ersatz
}
if (!disposed) applyNames(found);
if (!disposed) learnOwners(ids, found);
}
// ---- Herzen ---------------------------------------------------------------------------------------------------------------
@@ -630,7 +648,7 @@ export function createLogoPicker(handlers: LogoPickerHandlers, deps: LogoPickerD
retryButton.addEventListener('click', () => void load(failed ?? 'reset'));
// Das eigene Umbenennen (und das Lesen des eigenen Namens nach dem Start): die Einträge des Kontos zeigen gleich den neuen Namen.
const offName = deps.onUsernameChange((userId, username) => applyNames([[userId, username]]));
const offName = deps.onUsernameChange((userId, username) => learnOwners([], [[userId, username]]));
// Anmelden oder Abmelden: Admin-Knöpfe, Herzen. Ohne Admin-Rechte gibt es die Admin-Ansichten nicht mehr.
const offAuth = deps.onAuthChange(() => {
+6
View File
@@ -295,6 +295,12 @@
line-height: 1.3;
}
/* Solange der Name des Hochladers noch gesucht wird, ist die Zeile leer (kein Knoten darin). Ein Zeichen ohne Breite (U+200B) hält die
Zeile in der Höhe des Textes, damit die Kachel nicht springt, wenn der Name dazukommt. */
.logo-tile-by:empty::before {
content: '\200b';
}
/* 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 {
+122 -4
View File
@@ -43,10 +43,12 @@ const report = (n: number, reason: string): GalleryReport => ({
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve };
return { promise, resolve, reject };
}
const settle = async () => {
@@ -398,18 +400,134 @@ describe('Detailseite: „von @name“', () => {
expect(meta()).toBe('von Anonym · 04.10.2026');
});
it('die Karte steht, ohne auf den Namen zu warten; er erscheint, sobald er da ist', async () => {
it('die Karte steht, ohne auf den Namen zu warten; die Zeile „von … · Datum“ erscheint, sobald der Name da ist', async () => {
const lookup = deferred<Map<string, string>>();
usernamesFor.mockReturnValue(lookup.promise);
await mount();
expect(q('h1')?.textContent).toBe('Mitternachtsblau');
expect(q('.detail-card img')).not.toBeNull();
expect(meta()).toBe('von Kenso · 04.10.2026');
expect(buttonTexts()).toEqual(['Herunterladen', 'Als Vorlage gestalten', 'Melden']);
expect(meta()).toBe('');
lookup.resolve(new Map([['u1', 'Kenso.K']]));
await settle();
expect(meta()).toBe('von @Kenso.K · 04.10.2026');
});
describe('solange der Name des Autors noch gesucht wird, ist die Zeile leer (kein Ersatz, der gleich wieder wechselt)', () => {
it('die Zeile bleibt als leeres Element stehen (die Zeilenhöhe hält die CSS-Regel für :empty), danach steht dasselbe Element voll da', async () => {
const lookup = deferred<Map<string, string>>();
usernamesFor.mockReturnValue(lookup.promise);
await mount();
const line = q('.detail-meta')!;
expect(line.textContent).toBe('');
expect(line.childNodes).toHaveLength(0); // sonst griffe :empty nicht
lookup.resolve(new Map([['u1', 'Kenso.K']]));
await settle();
expect(q('.detail-meta')).toBe(line);
expect(line.textContent).toBe('von @Kenso.K · 04.10.2026');
});
it('kein Profil: erst jetzt der Ersatz, der alte Anzeigename (ohne @) bzw. „von Anonym“, mit dem Datum', async () => {
const lookup = deferred<Map<string, string>>();
usernamesFor.mockReturnValue(lookup.promise);
await mount();
expect(meta()).toBe('');
lookup.resolve(new Map());
await settle();
expect(meta()).toBe('von Kenso · 04.10.2026');
unmount?.();
const second = deferred<Map<string, string>>();
usernamesFor.mockReturnValue(second.promise);
getCard.mockResolvedValue(card({ displayName: '' }));
await mount();
expect(meta()).toBe('');
second.resolve(new Map());
await settle();
expect(meta()).toBe('von Anonym · 04.10.2026');
});
it('die Suche scheitert: erst jetzt der Ersatz, kein Fehlertext', async () => {
const lookup = deferred<Map<string, string>>();
usernamesFor.mockReturnValue(lookup.promise);
await mount();
expect(meta()).toBe('');
lookup.reject(new TypeError('Failed to fetch'));
await settle();
expect(meta()).toBe('von Kenso · 04.10.2026');
expect(errorText()).toBe('');
});
it('ein gemeldeter Name (Umbenennen, Lesen des eigenen Profils) beendet das Warten sofort', async () => {
const lookup = deferred<Map<string, string>>();
usernamesFor.mockReturnValue(lookup.promise);
await mount();
emitName('u2', 'Fremd'); // ein anderes Konto ändert nichts
expect(meta()).toBe('');
emitName('u1', 'Kenso.K');
expect(meta()).toBe('von @Kenso.K · 04.10.2026');
lookup.resolve(new Map([['u1', 'Kenso.K']]));
await settle();
expect(meta()).toBe('von @Kenso.K · 04.10.2026');
});
it('ein Konto, dessen Name schon feststeht (dieselbe Seite zeigt die Karte noch einmal), zeigt die Zeile sofort', async () => {
user = owner;
getCard.mockResolvedValue(card({ hidden: true })); // sichtbar nur für den Autor
usernamesFor.mockResolvedValueOnce(new Map([['u1', 'Kenso.K']]));
await mount();
expect(meta()).toBe('von @Kenso.K · 04.10.2026');
login(null); // abgemeldet: die Karte ist nicht mehr zu sehen
expect(q('h1')?.textContent).toBe(UNAVAILABLE);
login(owner); // wieder angemeldet: die Seite lädt die Karte neu
await settle();
expect(meta()).toBe('von @Kenso.K · 04.10.2026');
expect(usernamesFor).toHaveBeenCalledTimes(1); // der Name steht schon: keine zweite Abfrage
});
it('ein Konto, für das schon „kein Profil“ feststeht, zeigt seinen Ersatz sofort, auch während die neue Abfrage läuft', async () => {
user = owner;
getCard.mockResolvedValue(card({ hidden: true }));
await mount(); // die erste Abfrage liefert nichts: kein Profil
expect(meta()).toBe('von Kenso · 04.10.2026');
login(null);
usernamesFor.mockReturnValue(new Promise(() => {})); // die nächste Abfrage hängt
login(owner);
await settle();
expect(meta()).toBe('von Kenso · 04.10.2026');
});
it('eine Karte ohne Konto-ID hat nichts zu suchen: der Ersatz steht sofort', async () => {
getCard.mockResolvedValue(card({ ownerId: '', displayName: 'Anna' }));
usernamesFor.mockReturnValue(new Promise(() => {}));
await mount();
expect(meta()).toBe('von Anna · 04.10.2026');
});
it('wird die Seite verlassen, bevor die Suche fertig ist, ändert eine spätere Antwort nichts', async () => {
const lookup = deferred<Map<string, string>>();
usernamesFor.mockReturnValue(lookup.promise);
await mount();
unmount?.();
unmount = null;
const html = root.innerHTML;
lookup.resolve(new Map([['u1', 'Kenso.K']]));
await settle();
expect(root.innerHTML).toBe(html);
});
it('Englisch: leer, dann „by @name · Datum“', async () => {
setLang('en', { persist: false });
const lookup = deferred<Map<string, string>>();
usernamesFor.mockReturnValue(lookup.promise);
await mount();
expect(meta()).toBe('');
lookup.resolve(new Map([['u1', 'Kenso.K']]));
await settle();
expect(meta()).toBe('by @Kenso.K · Oct 4, 2026');
});
});
it('schlägt die Abfrage fehl, bleibt es beim Ersatz: kein Fehlertext, die Seite geht weiter', async () => {
usernamesFor.mockRejectedValue(new TypeError('Failed to fetch'));
await mount();
+22 -9
View File
@@ -1,7 +1,9 @@
// Detailseite „/community/<id>“: große Karte, Angaben, Herunterladen, „Als Vorlage gestalten“, Melden.
// Autor: Zurückziehen. Admin (Label `admin`): Ausblenden/Einblenden, Löschen, Meldungszahl und Gründe.
// „von @name“ (Spec § 5): Der Benutzername des Autors kommt nach dem Laden der Karte in einer Abfrage nach (bis dahin, und wenn sie
// scheitert, steht der alte Anzeigename bzw. „Anonym“); ein eigenes Umbenennen aktualisiert die Zeile sofort.
// „von @name“ (Spec § 5): Der Benutzername des Autors kommt nach dem Laden der Karte in einer Abfrage nach. Solange er noch gesucht
// wird, bleibt die Zeile „von … · Datum“ leer (ein Ersatz, der gleich wieder wechselt, läse sich falsch; die Zeile behält ihre Höhe,
// siehe community.css); danach steht der Name, ohne Profil oder bei einem Fehler der alte Anzeigename bzw. „Anonym“. Ein eigenes
// Umbenennen aktualisiert die Zeile sofort.
// Ausgeblendete oder fehlende Karten sieht ein Besucher nur als „nicht (mehr) verfügbar“: Appwrite antwortet ihm mit 404,
// Autor und Admin dürfen die Zeile lesen und bekommen die Seite mit dem Hinweis „Ausgeblendet“.
// Alle Texte kommen in der Sprache der Seite (t()); ein Sprachwechsel baut die Seite neu auf (main.ts).
@@ -118,6 +120,8 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
let imageUrl: string | null = null;
/** Die Benutzernamen, die diese Seite kennt (Konto-ID → Name); wer fehlt, hat kein Profil oder wurde noch nicht gefragt. */
const names = new Map<string, string>();
/** Die Konten, deren Name nachgeschlagen ist (gefunden oder nicht, auch bei einem Fehler): ihre Zeile „von …“ steht fest. */
const settled = new Set<string>();
const page = el('div', { class: 'detail-page' });
root.replaceChildren(page);
@@ -214,7 +218,7 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
// ---- Karte ----
const infoSlot = el('div', { class: 'detail-info' });
/** „von @name · Datum“. Eigenes Element, damit ein nachkommender oder geänderter Name nur diese Zeile neu schreibt. */
/** „von @name · Datum“. Eigenes Element, damit ein nachkommender oder geänderter Name nur diese Zeile neu schreibt; leer, solange er gesucht wird. */
const metaEl = el('p', { class: 'detail-meta' });
const noticeSlot = el('div', { class: 'detail-notice-slot' });
const actionsSlot = el('div', { class: 'detail-actions' });
@@ -234,9 +238,16 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
return b;
}
/** „von @name · Datum“ der gezeigten Karte; ohne Profil des Autors steht sein alter Anzeigename bzw. „Anonym“. */
/**
* „von @name · Datum“ der gezeigten Karte; ohne Profil des Autors steht sein alter Anzeigename bzw. „Anonym“. Solange der Name des Autors
* noch gesucht wird, bleibt die ganze Zeile leer: so springt auch das Datum nicht, wenn der Name dazukommt.
*/
function renderMeta(): void {
if (!card) return;
if (card.ownerId !== '' && !names.has(card.ownerId) && !settled.has(card.ownerId)) {
metaEl.textContent = '';
return;
}
const date = formatDate(card.createdAt);
const author = byline(card.ownerId, card.displayName, names);
metaEl.textContent = date ? `${author} · ${date}` : author;
@@ -250,20 +261,22 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
}
/**
* Holt den Benutzernamen des Autors (eine Abfrage). Die Anzeige braucht ihn nur als Beiwerk: schlägt die Abfrage fehl, bleibt der
* Ersatz stehen, ohne Fehlertext. Ein Name gehört dem Konto, nicht der Ansicht: eine späte Antwort ändert höchstens die Zeile, wenn
* die gezeigte Karte demselben Konto gehört.
* Holt den Benutzernamen des Autors (eine Abfrage). Die Anzeige braucht ihn nur als Beiwerk: schlägt die Abfrage fehl, steht danach der
* Ersatz, ohne Fehlertext. Ein Name gehört dem Konto, nicht der Ansicht: eine späte Antwort ändert höchstens die Zeile, wenn die
* gezeigte Karte demselben Konto gehört.
*/
async function loadAuthor(c: GalleryCard): Promise<void> {
if (c.ownerId === '' || names.has(c.ownerId)) return;
let found: Map<string, string>;
let found = new Map<string, string>();
try {
found = await deps.usernamesFor([c.ownerId]);
} catch {
return;
// nur Beiwerk: es bleibt beim Ersatz
}
if (disposed) return;
settled.add(c.ownerId);
for (const [userId, username] of found) applyName(userId, username);
if (card?.ownerId === c.ownerId) renderMeta(); // auch ohne Profil: jetzt steht der Ersatz
}
/** Baut Hinweis, Knöpfe und Admin-Bereich aus dem Zustand neu auf; der Fokus bleibt auf dem gleichen Knopf. */