diff --git a/web/src/community/community.css b/web/src/community/community.css index 466564d..cd63635 100644 --- a/web/src/community/community.css +++ b/web/src/community/community.css @@ -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; diff --git a/web/src/logos/byline-height.test.ts b/web/src/logos/byline-height.test.ts new file mode 100644 index 0000000..bdd38a7 --- /dev/null +++ b/web/src/logos/byline-height.test.ts @@ -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/); + }); +}); diff --git a/web/src/logos/logo-info.test.ts b/web/src/logos/logo-info.test.ts index 2bc2913..965e4c1 100644 --- a/web/src/logos/logo-info.test.ts +++ b/web/src/logos/logo-info.test.ts @@ -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; +/** Die Hochlader, deren Name der Picker noch sucht: ihre Zeile „von …“ ist leer. */ +let waiting: Set; 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) => { 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: „ · ♥ “', 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 diff --git a/web/src/logos/logo-info.ts b/web/src/logos/logo-info.ts index 6349f0b..de86c09 100644 --- a/web/src/logos/logo-info.ts +++ b/web/src/logos/logo-info.ts @@ -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; + /** + * 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): 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 „ · ♥ “. */ + 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. diff --git a/web/src/logos/logo-picker.test.ts b/web/src/logos/logo-picker.test.ts index c901b50..74144f8 100644 --- a/web/src/logos/logo-picker.test.ts +++ b/web/src/logos/logo-picker.test.ts @@ -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>(); 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>(); + 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>(); + 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>(); + 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>(); + 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 …“ („ · ♥ “), danach mit dem Namen oder dem Ersatz', async () => { + const lookup = deferred>(); + 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>(); + 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>(); + 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>(); + 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>(); + 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>(); + 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', 'fett']])); 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 () => { diff --git a/web/src/logos/logo-picker.ts b/web/src/logos/logo-picker.ts index c91700f..8d9d0b1 100644 --- a/web/src/logos/logo-picker.ts +++ b/web/src/logos/logo-picker.ts @@ -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(); /** Die Benutzernamen der Hochlader (Konto-ID → Name), soweit bekannt; wer fehlt, hat kein Profil oder wurde noch nicht gefragt. */ const ownerNames = new Map(); + /** Die Hochlader, deren Name nachgeschlagen ist (gefunden oder nicht, auch bei einem Fehler): ihre Zeile „von …“ steht fest. */ + const settledOwners = new Set(); + /** 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): 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, found: Iterable): void { const changed = new Set(); + 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 { const ids = [...new Set(rows.map((row) => row.ownerId).filter((id) => id !== ''))]; if (ids.length === 0) return; - let found: Map; + let found = new Map(); 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(() => { diff --git a/web/src/logos/logos.css b/web/src/logos/logos.css index a626876..eff9c78 100644 --- a/web/src/logos/logos.css +++ b/web/src/logos/logos.css @@ -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 { diff --git a/web/src/pages/community-page.test.ts b/web/src/pages/community-page.test.ts index c0e03b0..74e24be 100644 --- a/web/src/pages/community-page.test.ts +++ b/web/src/pages/community-page.test.ts @@ -43,10 +43,12 @@ const report = (n: number, reason: string): GalleryReport => ({ function deferred() { let resolve!: (value: T) => void; - const promise = new Promise((res) => { + let reject!: (reason: unknown) => void; + const promise = new Promise((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>(); 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>(); + 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>(); + 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>(); + 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>(); + 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>(); + 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>(); + 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>(); + 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(); diff --git a/web/src/pages/community-page.ts b/web/src/pages/community-page.ts index 511e2d7..cd7eca7 100644 --- a/web/src/pages/community-page.ts +++ b/web/src/pages/community-page.ts @@ -1,7 +1,9 @@ // Detailseite „/community/“: 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(); + /** Die Konten, deren Name nachgeschlagen ist (gefunden oder nicht, auch bei einem Fehler): ihre Zeile „von …“ steht fest. */ + const settled = new Set(); 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 { if (c.ownerId === '' || names.has(c.ownerId)) return; - let found: Map; + let found = new Map(); 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. */