diff --git a/web/src/account/display-name.test.ts b/web/src/account/display-name.test.ts
new file mode 100644
index 0000000..41978bb
--- /dev/null
+++ b/web/src/account/display-name.test.ts
@@ -0,0 +1,59 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { DISPLAY_NAME_KEY, rememberDisplayName, rememberedDisplayName } from './display-name';
+
+beforeEach(() => {
+ localStorage.clear();
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ localStorage.clear();
+});
+
+describe('gemerkter Anzeigename (localStorage)', () => {
+ it('der Schlüssel heißt aircard.displayName', () => {
+ expect(DISPLAY_NAME_KEY).toBe('aircard.displayName');
+ });
+
+ it('ohne gespeicherten Namen ist er leer', () => {
+ expect(rememberedDisplayName()).toBe('');
+ });
+
+ it('liest den gespeicherten Namen', () => {
+ localStorage.setItem(DISPLAY_NAME_KEY, 'Kenso');
+ expect(rememberedDisplayName()).toBe('Kenso');
+ });
+
+ it('speichert den Namen getrimmt unter dem Schlüssel und überschreibt den alten', () => {
+ rememberDisplayName(' Kenso ');
+ expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBe('Kenso');
+ rememberDisplayName('Zoë');
+ expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBe('Zoë');
+ expect(rememberedDisplayName()).toBe('Zoë');
+ });
+
+ it.each([[''], [' ']])('ein leerer Name (%j) entfernt den Schlüssel („Anonym“)', (empty) => {
+ localStorage.setItem(DISPLAY_NAME_KEY, 'Kenso');
+ rememberDisplayName(empty);
+ expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBeNull();
+ expect(rememberedDisplayName()).toBe('');
+ });
+
+ it('gesperrter Speicher beim Lesen: leer, kein Fehler', () => {
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
+ throw new DOMException('gesperrt', 'SecurityError');
+ });
+ expect(rememberedDisplayName()).toBe('');
+ });
+
+ it('gesperrter Speicher beim Schreiben und beim Entfernen: kein Fehler', () => {
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
+ throw new DOMException('voll', 'QuotaExceededError');
+ });
+ vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {
+ throw new DOMException('gesperrt', 'SecurityError');
+ });
+ expect(() => rememberDisplayName('Kenso')).not.toThrow();
+ expect(() => rememberDisplayName('')).not.toThrow();
+ });
+});
diff --git a/web/src/account/display-name.ts b/web/src/account/display-name.ts
new file mode 100644
index 0000000..610a5f2
--- /dev/null
+++ b/web/src/account/display-name.ts
@@ -0,0 +1,25 @@
+// Der zuletzt benutzte Anzeigename (Spec § 4): Der Veröffentlichen-Dialog und der Dialog „Logo hochladen“ lesen und schreiben
+// denselben Schlüssel in localStorage. Jeder Zugriff steht in try/catch: ist der Speicher gesperrt (z. B. privates Fenster),
+// bleibt das Feld leer und der Name wird nicht gemerkt, ohne dass etwas scheitert.
+
+export const DISPLAY_NAME_KEY = 'aircard.displayName';
+
+/** Der gemerkte Anzeigename; leer, wenn keiner gemerkt ist oder der Speicher nicht lesbar ist. */
+export function rememberedDisplayName(): string {
+ try {
+ return localStorage.getItem(DISPLAY_NAME_KEY) ?? '';
+ } catch {
+ return '';
+ }
+}
+
+/** Merkt den Anzeigenamen (getrimmt); ein leerer Name („Anonym“) entfernt den gemerkten. */
+export function rememberDisplayName(name: string): void {
+ const trimmed = name.trim();
+ try {
+ if (trimmed === '') localStorage.removeItem(DISPLAY_NAME_KEY);
+ else localStorage.setItem(DISPLAY_NAME_KEY, trimmed);
+ } catch {
+ // Gesperrt oder voll: der Name wird dann nur nicht gemerkt.
+ }
+}
diff --git a/web/src/community/publish-dialog.test.ts b/web/src/community/publish-dialog.test.ts
index f949b70..3c4b7c7 100644
--- a/web/src/community/publish-dialog.test.ts
+++ b/web/src/community/publish-dialog.test.ts
@@ -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/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} }));
+import { DISPLAY_NAME_KEY } from '../account/display-name';
import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
import { ApiError } from './api';
import { LOGO_MISSING_PUBLISH, NOT_RENDERED, TOO_LARGE, type PublishForm } from './publish';
@@ -48,10 +49,13 @@ function open(opts: { cardName?: string; signal?: AbortSignal } = {}): Promise
{
submit.mockReset();
document.body.innerHTML = '';
+ localStorage.clear(); // der Anzeigename wird gemerkt und im nächsten Test wieder vorbelegt
});
afterEach(() => {
document.body.innerHTML = '';
+ localStorage.clear();
+ vi.restoreAllMocks();
});
describe('Veröffentlichen-Dialog: Felder', () => {
@@ -402,6 +406,81 @@ describe('Veröffentlichen-Dialog: fehlendes Logo der Karte', () => {
});
});
+describe('Veröffentlichen-Dialog: gemerkter Anzeigename', () => {
+ it('der Anzeigename ist mit dem zuletzt benutzten Namen (aircard.displayName) vorbelegt, die anderen Felder bleiben leer', () => {
+ expect(DISPLAY_NAME_KEY).toBe('aircard.displayName');
+ localStorage.setItem('aircard.displayName', 'Kenso');
+ void open();
+ expect(input('displayName').value).toBe('Kenso');
+ for (const name of ['title', 'issuer', 'tags']) expect(input(name).value).toBe('');
+ expect(input('displayName').placeholder).toBe('Anonym');
+ });
+
+ it('gesperrter Speicher: das Feld bleibt leer, der Dialog öffnet sich trotzdem', () => {
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
+ throw new DOMException('gesperrt', 'SecurityError');
+ });
+ void open();
+ expect(dialogEl()?.open).toBe(true);
+ expect(input('displayName').value).toBe('');
+ });
+
+ it('nach dem Veröffentlichen wird der (getrimmte) Anzeigename gemerkt', async () => {
+ submit.mockResolvedValue({ id: 'g1' });
+ const outcome = open();
+ fill({ displayName: ' Kenso ' });
+ send();
+ await expect(outcome).resolves.toBe('published');
+ expect(localStorage.getItem('aircard.displayName')).toBe('Kenso');
+ });
+
+ it('ein geleerter Anzeigename entfernt den gemerkten (Anonym)', async () => {
+ localStorage.setItem('aircard.displayName', 'Kenso');
+ submit.mockResolvedValue({ id: 'g1' });
+ const outcome = open();
+ fill({ displayName: '' });
+ send();
+ await outcome;
+ expect(localStorage.getItem('aircard.displayName')).toBeNull();
+ });
+
+ it('bei einem Fehler wird nichts gemerkt, auch nicht bei „schon veröffentlicht“', async () => {
+ localStorage.setItem('aircard.displayName', 'Alt');
+ submit.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
+ const outcome = open();
+ fill({ displayName: 'Neu' });
+ send();
+ await settle();
+ expect(localStorage.getItem('aircard.displayName')).toBe('Alt');
+ submit.mockRejectedValueOnce(new ApiError(409, 'Diese Karte ist schon veröffentlicht.'));
+ send();
+ await settle();
+ expect(localStorage.getItem('aircard.displayName')).toBe('Alt');
+ buttonByText('Schließen')!.click();
+ await expect(outcome).resolves.toBe('already');
+ expect(localStorage.getItem('aircard.displayName')).toBe('Alt');
+ });
+
+ it('beim Abbrechen und beim Schließen durch das Signal wird nichts gemerkt', async () => {
+ const controller = new AbortController();
+ let finish!: (r: { id: string }) => void;
+ submit.mockReturnValue(new Promise((r) => (finish = r)));
+ const outcome = open({ signal: controller.signal });
+ fill({ displayName: 'Kenso' });
+ send();
+ controller.abort();
+ await outcome;
+ finish({ id: 'g1' });
+ await settle();
+ expect(localStorage.getItem('aircard.displayName')).toBeNull();
+
+ void open();
+ fill({ displayName: 'Kenso' });
+ buttonByText('Abbrechen')!.click();
+ expect(localStorage.getItem('aircard.displayName')).toBeNull();
+ });
+});
+
describe('Veröffentlichen-Dialog: Schließen', () => {
it('„Abbrechen“ schließt ohne zu senden', async () => {
const outcome = open();
diff --git a/web/src/community/publish-dialog.ts b/web/src/community/publish-dialog.ts
index 34d983e..4526635 100644
--- a/web/src/community/publish-dialog.ts
+++ b/web/src/community/publish-dialog.ts
@@ -2,6 +2,7 @@
// Eigenes