feat(home): kompakter Hero und Community-Galerie

Startseite "/" mit kompaktem Hero (grosse schwebende Karte rechts: neueste
sichtbare Community-Karte, sonst das eigene Beispielmotiv hero-beispiel.png)
und direkt darunter der Galerie #community, damit Ueberschrift und Oberkante der
ersten Kartenreihe bei 1440x900 und 390x844 im ersten Bildschirm stehen.

- mountHomePage(root, deps): Suche ?q= und Tag ?tag= aus der Adresse (aktiver
  Begriff mit "x"), Suchfeld der Kopfzeile per .value gefuellt, Mehr laden per
  Cursor, Leer-/Kein-Treffer-/Fehlertexte, Admin-Filter Alle/Gemeldet/
  Ausgeblendet (reagiert auf Anmelden/Abmelden), Anker nach dem Laden erneut
  angesprungen (router.scrollToHash exportiert), Object-URLs beim Verlassen frei.
- mountGalleryGrid: Kacheln mit Vorschau (Object-URL, max. 6 parallel), Titel,
  Herausgeber, bis 3 Tag-Knoepfe (Tag-Klick -> /?tag=<tag>#community), Link auf
  /community/<id>; Moderations-Marken nur in den Admin-Ansichten.
- Hero hoechstens 60vh auf breiten, hohen Fenstern; Schwebe-Animation nur ohne
  prefers-reduced-motion.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 15:07:40 +00:00
1 parent ae73dc4a0b
commit c4a66f674f
7 files changed
+1609 -8

No files matched your search

+432
View File
@@ -0,0 +1,432 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { User } from '../account/auth';
import type { GalleryCard, GalleryOptions } from '../community/gallery';
// gallery.ts und auth.ts hängen am Appwrite-Client; hier läuft alles über die Abhängigkeiten von mountHomePage.
vi.mock('../account/appwrite', () => ({
account: {},
client: {},
tables: {},
storage: {},
IDS: {},
APPWRITE_ENDPOINT: '',
}));
import { mountHomePage, type HomeDeps } from './home-page';
const card = (n: number, patch: Partial<GalleryCard> = {}): GalleryCard => ({
id: `g${n}`,
title: `Karte ${n}`,
issuer: 'Beispielbank',
tags: ['visa', 'gold'],
displayName: 'Kenso',
imageFileId: `img${n}`,
previewFileId: `pre${n}`,
sourceCardId: `src${n}`,
ownerId: 'u1',
hidden: false,
reportCount: 0,
createdAt: '2026-10-04T10:00:00.000Z',
...patch,
});
type Page = { cards: GalleryCard[]; nextCursor: string | null };
const page = (cards: GalleryCard[], nextCursor: string | null = null): Page => ({ cards, nextCursor });
const settle = async () => {
for (let i = 0; i < 40; i++) await Promise.resolve();
};
const blob = () => new Blob(['x'], { type: 'image/png' });
const admin: User = { $id: 'a1', email: 'kenso@example.com', labels: ['admin'] };
const member: User = { $id: 'u1', email: 'u@example.com', labels: [] };
let root: HTMLElement;
let unmount: (() => void) | null;
let user: User | null;
let authListeners: Set<(u: User | null) => void>;
let urlCounter: number;
const revoked: string[] = [];
const listGallery = vi.fn<(opts: GalleryOptions) => Promise<Page>>();
const loadImage = vi.fn<(c: GalleryCard, size: 'full' | 'preview') => Promise<Blob>>();
const navigate = vi.fn<(path: string) => void>();
const scrollToHash = vi.fn<(hash: string) => void>();
function deps(): HomeDeps {
return {
listGallery,
loadImage,
currentUser: () => user,
onAuthChange: (fn) => {
authListeners.add(fn);
return () => authListeners.delete(fn);
},
navigate,
scrollToHash,
};
}
async function mount(url = '/'): Promise<void> {
history.replaceState(null, '', url);
unmount = mountHomePage(root, deps());
await settle();
}
const q = <T extends Element = HTMLElement>(sel: string) => root.querySelector<T>(sel);
const qa = <T extends Element = HTMLElement>(sel: string) => [...root.querySelectorAll<T>(sel)];
const buttonByText = (text: string) => qa<HTMLButtonElement>('button').find((b) => b.textContent === text);
beforeEach(() => {
listGallery.mockReset();
loadImage.mockReset();
navigate.mockReset();
scrollToHash.mockReset();
loadImage.mockImplementation(async () => blob());
listGallery.mockResolvedValue(page([]));
user = null;
authListeners = new Set();
urlCounter = 0;
revoked.length = 0;
URL.createObjectURL = vi.fn(() => `blob:home-${++urlCounter}`);
URL.revokeObjectURL = vi.fn((url: string) => {
revoked.push(url);
});
document.body.innerHTML = '<input class="header-search-input" value="alt"><main id="app-root"></main>';
root = document.getElementById('app-root') as HTMLElement;
unmount = null;
});
afterEach(() => {
unmount?.();
history.replaceState(null, '', '/');
});
describe('Startseite: Hero', () => {
it('zeigt die Texte aus der Spec und die zwei Knöpfe', async () => {
await mount();
expect(q('.home-hero-eyebrow')?.textContent).toBe('Ein bisschen Kunst für dein Wallet');
expect(q('h1')?.textContent).toBe('Kartendesigns für Apple Wallet');
expect(q('.home-hero-lead')?.textContent).toBe(
'Entdecke Motive der Community, gestalte dein eigenes und lade es als PNG herunter.',
);
const discover = q<HTMLAnchorElement>('.home-hero a[href="#community"]');
expect(discover?.textContent).toBe('Karten entdecken ↓');
expect(discover?.hasAttribute('data-link')).toBe(true);
const design = q<HTMLAnchorElement>('.home-hero a[href="/gestalten"]');
expect(design?.textContent).toBe('Gestalten');
expect(design?.hasAttribute('data-link')).toBe(true);
});
it('leere Galerie: Beispielmotiv in der Hero-Karte und Leertext mit „Gestalten“', async () => {
await mount();
expect(q<HTMLImageElement>('.hero-card img')?.getAttribute('src')).toBe('/hero-beispiel.png');
expect(q('.hero-card a')).toBeNull();
const empty = q('.community-empty');
expect(empty?.textContent).toContain('Noch keine Community-Karten – sei die erste!');
expect(empty?.querySelector('a[href="/gestalten"]')?.textContent).toBe('Gestalten');
expect(buttonByText('Mehr laden')).toBeUndefined();
expect(loadImage).not.toHaveBeenCalled();
});
it('Hero-Karte = Vollbild der neuesten Karte, verlinkt auf ihre Detailseite', async () => {
listGallery.mockResolvedValue(page([card(1), card(2)]));
await mount();
expect(loadImage).toHaveBeenCalledWith(expect.objectContaining({ id: 'g1' }), 'full');
const link = q<HTMLAnchorElement>('.hero-card a[data-link]');
expect(link?.getAttribute('href')).toBe('/community/g1');
expect(link?.querySelector('img')?.getAttribute('src')).toMatch(/^blob:home-/);
// genau eine Abfrage: die erste Seite liefert auch die neueste Karte
expect(listGallery).toHaveBeenCalledTimes(1);
});
it('fällt auf das Beispielmotiv zurück, wenn das Kartenbild nicht lädt', async () => {
listGallery.mockResolvedValue(page([card(1)]));
loadImage.mockImplementation(async (_c, size) => {
if (size === 'full') throw new Error('404');
return blob();
});
await mount();
expect(q<HTMLImageElement>('.hero-card img')?.getAttribute('src')).toBe('/hero-beispiel.png');
});
it('Ladefehler der Galerie: Hero zeigt das Beispielmotiv', async () => {
listGallery.mockRejectedValue(new Error('offline'));
await mount();
expect(q<HTMLImageElement>('.hero-card img')?.getAttribute('src')).toBe('/hero-beispiel.png');
});
});
describe('Startseite: Galerie', () => {
it('hat den Abschnitt #community mit Überschrift, Untertitel und „Gestalten & veröffentlichen“', async () => {
await mount();
const section = q('section#community');
expect(section).not.toBeNull();
expect(section?.querySelector('h2')?.textContent).toBe('Community-Karten');
expect(section?.querySelector('.community-sub')?.textContent).toBe(
'Entdecke Motive der Community – und veröffentliche deine eigenen.',
);
const publish = section?.querySelector<HTMLAnchorElement>('a[href="/gestalten"]');
expect(publish?.textContent).toBe('Gestalten & veröffentlichen');
});
it('lädt die erste Seite ohne Suche und zeigt die Kacheln mit Vorschau', async () => {
listGallery.mockResolvedValue(page([card(1), card(2), card(3)]));
await mount();
expect(listGallery).toHaveBeenCalledWith({ q: undefined, tag: undefined, filter: 'all' });
expect(qa('.gallery-tile')).toHaveLength(3);
expect(loadImage).toHaveBeenCalledWith(expect.objectContaining({ id: 'g2' }), 'preview');
expect(q('.community-empty')).toBeNull();
});
it('„Mehr laden“ nur bei nextCursor, hängt die nächste Seite an und verschwindet am Ende', async () => {
listGallery.mockResolvedValueOnce(page([card(1), card(2)], 'g2'));
await mount();
const more = buttonByText('Mehr laden');
expect(more).toBeDefined();
listGallery.mockResolvedValueOnce(page([card(3)], null));
more!.click();
await settle();
expect(listGallery).toHaveBeenLastCalledWith({ q: undefined, tag: undefined, filter: 'all', cursor: 'g2' });
expect(qa('.gallery-tile')).toHaveLength(3);
expect(buttonByText('Mehr laden')).toBeUndefined();
});
it('ohne nextCursor gibt es kein „Mehr laden“', async () => {
listGallery.mockResolvedValue(page([card(1)], null));
await mount();
expect(buttonByText('Mehr laden')).toBeUndefined();
});
it('„Mehr laden“ scheitert: Hinweis, Knopf bleibt, zweiter Versuch klappt', async () => {
listGallery.mockResolvedValueOnce(page([card(1)], 'g1'));
await mount();
listGallery.mockRejectedValueOnce(new Error('offline'));
buttonByText('Mehr laden')!.click();
await settle();
expect(q('.community-more-msg')?.textContent).toContain('konnten nicht geladen werden');
expect(qa('.gallery-tile')).toHaveLength(1);
listGallery.mockResolvedValueOnce(page([card(2)], null));
buttonByText('Mehr laden')!.click();
await settle();
expect(qa('.gallery-tile')).toHaveLength(2);
expect(buttonByText('Mehr laden')).toBeUndefined();
});
it('Ladefehler: Hinweis und „Nochmal versuchen“', async () => {
listGallery.mockRejectedValueOnce(new Error('offline'));
await mount();
expect(q('[role="alert"]')?.textContent).toContain('konnten gerade nicht geladen werden');
listGallery.mockResolvedValue(page([card(1)]));
buttonByText('Nochmal versuchen')!.click();
await settle();
expect(q('[role="alert"]')).toBeNull();
expect(qa('.gallery-tile')).toHaveLength(1);
});
it('Tag-Klick navigiert zu /?tag=<tag>#community', async () => {
listGallery.mockResolvedValue(page([card(1, { tags: ['visa', 'dunkel blau'] })]));
await mount();
qa<HTMLButtonElement>('button.gallery-tag')[0].click();
expect(navigate).toHaveBeenLastCalledWith('/?tag=visa#community');
qa<HTMLButtonElement>('button.gallery-tag')[1].click();
expect(navigate).toHaveBeenLastCalledWith('/?tag=dunkel%20blau#community');
});
});
describe('Startseite: Suche und Tag aus der Adresse', () => {
it('?q= landet in der Abfrage, zeigt den Begriff mit „ד und füllt das Suchfeld der Kopfzeile', async () => {
listGallery.mockResolvedValue(page([card(1)]));
await mount('/?q=visa%20gold#community');
expect(listGallery).toHaveBeenCalledWith({ q: 'visa gold', tag: undefined, filter: 'all' });
const active = q('.community-active');
expect(active?.textContent).toContain('visa gold');
const clear = active?.querySelector<HTMLButtonElement>('button');
expect(clear?.textContent).toBe('×');
expect(clear?.getAttribute('aria-label')).toBe('Suche zurücksetzen');
expect(document.querySelector<HTMLInputElement>('.header-search-input')?.value).toBe('visa gold');
});
it('„ד entfernt die Suche (zurück zu /#community)', async () => {
await mount('/?q=visa#community');
q<HTMLButtonElement>('.community-active button')!.click();
expect(navigate).toHaveBeenCalledWith('/#community');
});
it('ohne ?q= bleibt die Suchzeile weg und das Suchfeld der Kopfzeile wird geleert', async () => {
await mount('/');
expect(q('.community-active')).toBeNull();
expect(document.querySelector<HTMLInputElement>('.header-search-input')?.value).toBe('');
});
it('setzt das Suchfeld über .value (kein HTML)', async () => {
await mount('/?q=%3Cb%3Ex%3C%2Fb%3E');
const input = document.querySelector<HTMLInputElement>('.header-search-input')!;
expect(input.value).toBe('<b>x</b>');
expect(document.querySelector('.header-search-input b')).toBeNull();
expect(q('.community-active b')).toBeNull();
});
it('?tag= landet in der Abfrage und zeigt den Tag mit „ד', async () => {
listGallery.mockResolvedValue(page([card(1)]));
await mount('/?tag=gold#community');
expect(listGallery).toHaveBeenCalledWith({ q: undefined, tag: 'gold', filter: 'all' });
const active = q('.community-active');
expect(active?.textContent).toContain('Tag');
expect(active?.textContent).toContain('gold');
active!.querySelector<HTMLButtonElement>('button')!.click();
expect(navigate).toHaveBeenCalledWith('/#community');
});
it('mit Suche: Hero-Karte bleibt die neueste sichtbare Karte (eigene Abfrage ohne Suche)', async () => {
listGallery.mockImplementation(async (opts) =>
opts.q ? page([card(7, { title: 'Treffer' })]) : page([card(1, { title: 'Neueste' })]),
);
await mount('/?q=treffer');
expect(listGallery).toHaveBeenCalledWith({});
expect(q('.hero-card a')?.getAttribute('href')).toBe('/community/g1');
expect(qa('.gallery-title').map((t) => t.textContent)).toEqual(['Treffer']);
});
it('Mehrere Treffer-Seiten: der Cursor gilt für dieselbe Suche', async () => {
listGallery.mockImplementation(async (opts) =>
opts.q ? page([card(2)], 'g2') : page([card(1)], 'g1'),
);
await mount('/?q=abc');
listGallery.mockImplementation(async () => page([card(3)]));
buttonByText('Mehr laden')!.click();
await settle();
expect(listGallery).toHaveBeenLastCalledWith({ q: 'abc', tag: undefined, filter: 'all', cursor: 'g2' });
});
it('keine Treffer: Nichts gefunden für „…“', async () => {
listGallery.mockResolvedValue(page([]));
await mount('/?q=xyz');
expect(q('.community-empty')?.textContent).toContain('Nichts gefunden für „xyz“');
expect(q('.community-empty')?.textContent).not.toContain('sei die erste');
});
it('keine Treffer bei Tag: der Tag steht im Text', async () => {
listGallery.mockResolvedValue(page([]));
await mount('/?tag=selten');
expect(q('.community-empty')?.textContent).toContain('Nichts gefunden für „selten“');
});
it('Anker: springt nach dem Laden zu #community, ohne Anker nicht', async () => {
await mount('/?q=abc#community');
expect(scrollToHash).toHaveBeenCalledWith('#community');
unmount!();
scrollToHash.mockClear();
await mount('/');
expect(scrollToHash).not.toHaveBeenCalled();
});
});
describe('Startseite: Admin-Filter', () => {
it('Gäste und normale Konten sehen keine Filter-Chips', async () => {
await mount();
expect(q('.community-filter')).toBeNull();
user = member;
for (const fn of authListeners) fn(member);
expect(q('.community-filter')).toBeNull();
});
it('Admin sieht „Alle“, „Gemeldet“, „Ausgeblendet“; Wechsel lädt neu mit dem Filter', async () => {
user = admin;
listGallery.mockResolvedValue(page([card(1)]));
await mount();
const chips = qa<HTMLButtonElement>('.community-filter button');
expect(chips.map((b) => b.textContent)).toEqual(['Alle', 'Gemeldet', 'Ausgeblendet']);
expect(chips.map((b) => b.getAttribute('aria-pressed'))).toEqual(['true', 'false', 'false']);
listGallery.mockResolvedValue(page([card(5, { hidden: true, reportCount: 3 })]));
chips[2].click();
await settle();
expect(listGallery).toHaveBeenLastCalledWith({ q: undefined, tag: undefined, filter: 'hidden' });
expect(qa<HTMLButtonElement>('.community-filter button').map((b) => b.getAttribute('aria-pressed'))).toEqual([
'false',
'false',
'true',
]);
expect(qa('.gallery-tile')).toHaveLength(1);
// Admin-Ansicht zeigt die Status-Marken, und die Hero-Karte bleibt unverändert
expect(q('.gallery-badge')?.textContent).toBe('Ausgeblendet');
expect(q('.hero-card a')?.getAttribute('href')).toBe('/community/g1');
chips[1].click();
await settle();
expect(listGallery).toHaveBeenLastCalledWith({ q: undefined, tag: undefined, filter: 'reported' });
});
it('leere Admin-Ansicht: eigener Text statt „sei die erste“', async () => {
user = admin;
listGallery.mockResolvedValue(page([card(1)]));
await mount();
listGallery.mockResolvedValue(page([]));
buttonByText('Gemeldet')!.click();
await settle();
expect(q('.community-empty')?.textContent).toContain('Keine Karten in dieser Ansicht.');
expect(q('.community-empty')?.textContent).not.toContain('sei die erste');
});
it('wird der Admin erst nach dem Start erkannt, erscheinen die Chips; Abmelden entfernt sie und setzt auf „Alle“', async () => {
listGallery.mockResolvedValue(page([card(1)]));
await mount();
expect(q('.community-filter')).toBeNull();
user = admin;
for (const fn of authListeners) fn(admin);
expect(qa('.community-filter button')).toHaveLength(3);
listGallery.mockResolvedValue(page([card(5)]));
buttonByText('Gemeldet')!.click();
await settle();
expect(listGallery).toHaveBeenLastCalledWith(expect.objectContaining({ filter: 'reported' }));
user = null;
for (const fn of authListeners) fn(null);
await settle();
expect(q('.community-filter')).toBeNull();
expect(listGallery).toHaveBeenLastCalledWith(expect.objectContaining({ filter: 'all' }));
});
it('eine späte Antwort der alten Ansicht überschreibt die neue nicht', async () => {
user = admin;
let resolveOld!: (p: Page) => void;
listGallery.mockResolvedValueOnce(page([card(1)])); // erste Seite
await mount();
listGallery.mockImplementationOnce(() => new Promise<Page>((r) => (resolveOld = r))); // „Gemeldet“ (langsam)
buttonByText('Gemeldet')!.click();
listGallery.mockResolvedValueOnce(page([card(9, { title: 'Neu' })])); // „Alle“ (schnell)
buttonByText('Alle')!.click();
await settle();
resolveOld(page([card(8, { title: 'Alt' })]));
await settle();
expect(qa('.gallery-title').map((t) => t.textContent)).toEqual(['Neu']);
});
});
describe('Startseite: Aufräumen', () => {
it('gibt beim Verlassen alle Object-URLs frei (Hero und Vorschauen) und hört nicht mehr auf Anmeldungen', async () => {
listGallery.mockResolvedValue(page([card(1), card(2)]));
await mount();
expect(urlCounter).toBe(3); // Hero + 2 Vorschauen
expect(authListeners.size).toBe(1);
unmount!();
unmount = null;
expect(revoked.sort()).toEqual(['blob:home-1', 'blob:home-2', 'blob:home-3']);
expect(authListeners.size).toBe(0);
});
it('legt nach dem Verlassen keine URLs mehr an (späte Antworten)', async () => {
let resolveList!: (p: Page) => void;
listGallery.mockImplementationOnce(() => new Promise<Page>((r) => (resolveList = r)));
history.replaceState(null, '', '/');
unmount = mountHomePage(root, deps());
unmount();
unmount = null;
resolveList(page([card(1)]));
await settle();
expect(urlCounter).toBe(0);
expect(loadImage).not.toHaveBeenCalled();
});
});
+337 -6
View File
@@ -1,10 +1,341 @@
// Startseite „/“: Hero + Community-Galerie. Platzhalter – Task 5 füllt sie.
// Startseite „/“: kompakter Hero mit großer Karte und direkt darunter die Community-Galerie (Anker #community).
// Suche (`?q=`) und Tag (`?tag=`) kommen aus der Adresse; Label-Admins sehen zusätzlich die Filter Alle/Gemeldet/Ausgeblendet.
import '../community/community.css';
import { currentUser, isAdmin, onAuthChange, type User } from '../account/auth';
import { mountGalleryGrid, type GalleryGrid } from '../community/gallery-grid';
import { listGallery, loadGalleryImage, type GalleryCard, type GalleryFilter, type GalleryOptions } from '../community/gallery';
import { el } from '../editor/dom';
import { navigate, scrollToHash } from './router';
export function mountHomePage(root: HTMLElement): () => void {
root.replaceChildren(
el('h1', {}, 'Kartendesigns für Apple Wallet'),
el('section', { id: 'community', class: 'home-community' }, el('h2', {}, 'Community-Karten')),
type Page = { cards: GalleryCard[]; nextCursor: string | null };
/** Alles, was die Seite von außen braucht (für Tests austauschbar). */
export interface HomeDeps {
listGallery(opts: GalleryOptions): Promise<Page>;
loadImage(card: GalleryCard, size: 'full' | 'preview'): Promise<Blob>;
currentUser(): User | null;
onAuthChange(fn: (user: User | null) => void): () => void;
navigate(path: string): void;
scrollToHash(hash: string): void;
}
export const defaultDeps: HomeDeps = {
listGallery,
loadImage: loadGalleryImage,
currentUser,
onAuthChange,
navigate,
scrollToHash,
};
const EXAMPLE_IMAGE = '/hero-beispiel.png';
const EXAMPLE_ALT = 'Beispielmotiv: violetter Verlauf mit Wellen und dem AirCard-IOS-Symbol';
const CARD_W = 1536;
const CARD_H = 969;
const LOADING = 'Karten werden geladen …';
const LOAD_ERROR = 'Die Community-Karten konnten gerade nicht geladen werden.';
const MORE_ERROR = 'Weitere Karten konnten nicht geladen werden. Bitte nochmal versuchen.';
const EMPTY = 'Noch keine Community-Karten – sei die erste!';
const EMPTY_VIEW = 'Keine Karten in dieser Ansicht.';
const FILTERS: ReadonlyArray<readonly [GalleryFilter, string]> = [
['all', 'Alle'],
['reported', 'Gemeldet'],
['hidden', 'Ausgeblendet'],
];
/** `/` plus Suche und/oder Tag, mit Anker auf die Galerie. */
function communityPath(next: { q?: string; tag?: string }): string {
const parts: string[] = [];
if (next.q) parts.push(`q=${encodeURIComponent(next.q)}`);
if (next.tag) parts.push(`tag=${encodeURIComponent(next.tag)}`);
return `/${parts.length > 0 ? `?${parts.join('&')}` : ''}#community`;
}
export function mountHomePage(root: HTMLElement, deps: HomeDeps = defaultDeps): () => void {
const params = new URLSearchParams(location.search);
const query = (params.get('q') ?? '').trim();
const tag = (params.get('tag') ?? '').trim();
let disposed = false;
let filter: GalleryFilter = 'all';
let loadToken = 0;
let cursor: string | null = null;
let loadingMore = false;
let moreError = '';
let grid: GalleryGrid | null = null;
let heroUrl: string | null = null;
const options = (): GalleryOptions => ({ q: query || undefined, tag: tag || undefined, filter });
// Das Suchfeld der Kopfzeile gehört zu main.ts; hier bekommt es den Suchbegriff der Adresse (per .value, nie als HTML).
const headerInput = document.querySelector<HTMLInputElement>('.header-search-input');
if (headerInput) headerInput.value = query;
// ---- Hero ----
const heroCard = el('div', { class: 'hero-card is-loading' });
const hero = el(
'section',
{ class: 'home-hero', 'aria-label': 'Willkommen' },
el(
'div',
{ class: 'home-hero-copy' },
el('p', { class: 'home-hero-eyebrow' }, 'Ein bisschen Kunst für dein Wallet'),
el('h1', { class: 'home-hero-title' }, 'Kartendesigns für Apple Wallet'),
el('p', { class: 'home-hero-lead' }, 'Entdecke Motive der Community, gestalte dein eigenes und lade es als PNG herunter.'),
el(
'div',
{ class: 'home-hero-actions' },
el('a', { class: 'home-btn home-btn-primary', href: '#community', 'data-link': true }, 'Karten entdecken ↓'),
el('a', { class: 'home-btn', href: '/gestalten', 'data-link': true }, 'Gestalten'),
),
),
el('div', { class: 'home-hero-visual' }, heroCard),
);
return () => {};
function setHeroImage(src: string, alt: string, card: GalleryCard | null): void {
const img = el('img', { src, alt, width: CARD_W, height: CARD_H });
heroCard.classList.remove('is-loading');
heroCard.replaceChildren(
card ? el('a', { class: 'hero-card-link', href: `/community/${encodeURIComponent(card.id)}`, 'data-link': true }, img) : img,
);
}
const showExample = () => setHeroImage(EXAMPLE_IMAGE, EXAMPLE_ALT, null);
/** Die große Karte: Vollbild der neuesten sichtbaren Karte; ohne Karte oder bei Fehlern das Beispielmotiv. */
function showHero(newest: Promise<GalleryCard | null>): void {
newest.then(
async (card) => {
if (disposed) return;
if (!card) return showExample();
try {
const blob = await deps.loadImage(card, 'full');
if (disposed) return;
heroUrl = URL.createObjectURL(blob);
setHeroImage(heroUrl, `Community-Karte „${card.title}“`, card);
} catch {
if (!disposed) showExample();
}
},
() => {
if (!disposed) showExample();
},
);
}
// ---- Galerie-Abschnitt ----
const filterSlot = el('div', { class: 'community-filter-slot' });
const activeSlot = el('div', { class: 'community-active-slot' });
const statusSlot = el('div', { class: 'community-status-slot' });
const gridHost = el('div', { class: 'community-grid-host' });
const moreSlot = el('div', { class: 'community-more' });
const section = el(
'section',
{ id: 'community', class: 'home-community', 'aria-labelledby': 'community-title' },
el(
'div',
{ class: 'community-head' },
el(
'div',
{ class: 'community-head-text' },
el('h2', { id: 'community-title', class: 'community-title' }, 'Community-Karten'),
el('p', { class: 'community-sub' }, 'Entdecke Motive der Community – und veröffentliche deine eigenen.'),
),
el('a', { class: 'home-btn home-btn-primary community-publish', href: '/gestalten', 'data-link': true }, 'Gestalten & veröffentlichen'),
),
el('div', { class: 'community-toolbar' }, activeSlot, filterSlot),
statusSlot,
gridHost,
moreSlot,
);
root.replaceChildren(hero, section);
// Aktiver Suchbegriff / Tag mit „ד (zurück zur Galerie ohne diesen Begriff)
function chip(label: string, value: string, clearLabel: string, target: string): HTMLElement {
const clear = el('button', { type: 'button', class: 'community-chip-clear', 'aria-label': clearLabel }, '×');
clear.addEventListener('click', () => deps.navigate(target));
return el(
'span',
{ class: 'community-chip' },
el('span', { class: 'community-chip-label' }, `${label}:`),
el('strong', { class: 'community-chip-value' }, value),
clear,
);
}
if (query || tag) {
const active = el('div', { class: 'community-active' });
if (query) active.append(chip('Suche', query, 'Suche zurücksetzen', communityPath({ tag })));
if (tag) active.append(chip('Tag', tag, 'Tag-Filter zurücksetzen', communityPath({ q: query })));
activeSlot.append(active);
}
// Admin-Filter
const filterButtons = new Map<GalleryFilter, HTMLButtonElement>();
function syncFilterButtons(): void {
for (const [value, button] of filterButtons) button.setAttribute('aria-pressed', String(value === filter));
}
function renderFilter(user: User | null): void {
filterButtons.clear();
if (!isAdmin(user)) {
filterSlot.replaceChildren();
return;
}
const group = el('div', { class: 'community-filter', role: 'group', 'aria-label': 'Ansicht' });
for (const [value, label] of FILTERS) {
const button = el('button', { type: 'button', class: 'community-filter-btn' }, label);
button.addEventListener('click', () => setFilter(value));
filterButtons.set(value, button);
group.append(button);
}
filterSlot.replaceChildren(group);
syncFilterButtons();
}
function setFilter(next: GalleryFilter): void {
if (next === filter) return;
filter = next;
syncFilterButtons();
loadFirst(deps.listGallery(options()));
}
// ---- Laden ----
function showStatus(text: string, role: 'status' | 'alert', retry = false): void {
const message = el('p', { class: `community-status${role === 'alert' ? ' is-error' : ''}`, role }, text);
const nodes: Node[] = [message];
if (retry) {
const button = el('button', { type: 'button', class: 'home-btn home-btn-primary' }, 'Nochmal versuchen');
button.addEventListener('click', () => loadFirst(deps.listGallery(options())));
nodes.push(button);
}
statusSlot.replaceChildren(...nodes);
}
function showEmpty(): void {
const nodes: Node[] = [];
if (filter !== 'all') {
nodes.push(el('p', {}, EMPTY_VIEW));
} else if (query || tag) {
nodes.push(el('p', {}, `Nichts gefunden für „${[query, tag].filter(Boolean).join(' · ')}“`));
} else {
nodes.push(
el('p', {}, EMPTY),
el('a', { class: 'home-btn home-btn-primary', href: '/gestalten', 'data-link': true }, 'Gestalten'),
);
}
statusSlot.replaceChildren(el('div', { class: 'community-empty' }, ...nodes));
}
function renderMore(): void {
if (!cursor) {
moreSlot.replaceChildren();
return;
}
const button = el('button', { type: 'button', class: 'home-btn community-more-btn' }, 'Mehr laden');
if (loadingMore) {
button.disabled = true;
button.setAttribute('aria-busy', 'true');
}
button.addEventListener('click', () => void loadMore());
moreSlot.replaceChildren(
...(moreError ? [el('p', { class: 'community-more-msg is-error', role: 'alert' }, moreError)] : []),
button,
);
}
/** Beginnt die Liste neu (andere Ansicht, Wiederholen); `pending` ist die schon laufende Abfrage der ersten Seite. */
function loadFirst(pending: Promise<Page>): void {
const token = ++loadToken;
grid?.dispose();
grid = mountGalleryGrid(gridHost, {
load: (card) => deps.loadImage(card, 'preview'),
onTag: (name) => deps.navigate(communityPath({ tag: name })),
showStatus: filter !== 'all', // Moderations-Marken nur in den Admin-Ansichten
});
cursor = null;
loadingMore = false;
moreError = '';
moreSlot.replaceChildren();
showStatus(LOADING, 'status');
pending.then(
(res) => {
if (disposed || token !== loadToken) return;
statusSlot.replaceChildren();
if (res.cards.length === 0) showEmpty();
else grid?.append(res.cards);
cursor = res.nextCursor;
renderMore();
},
() => {
if (disposed || token !== loadToken) return;
showStatus(LOAD_ERROR, 'alert', true);
},
);
}
async function loadMore(): Promise<void> {
if (!cursor || loadingMore) return;
const token = loadToken;
loadingMore = true;
moreError = '';
renderMore();
try {
const res = await deps.listGallery({ ...options(), cursor });
if (disposed || token !== loadToken) return;
loadingMore = false;
grid?.append(res.cards);
cursor = res.nextCursor;
} catch {
if (disposed || token !== loadToken) return;
loadingMore = false;
moreError = MORE_ERROR;
}
renderMore();
}
// ---- Start ----
renderFilter(deps.currentUser());
const stopAuth = deps.onAuthChange((user) => {
if (disposed) return;
renderFilter(user);
// Wer keine Admin-Rechte (mehr) hat, kann keine Admin-Ansicht offen haben.
if (!isAdmin(user) && filter !== 'all') {
filter = 'all';
loadFirst(deps.listGallery(options()));
}
});
const first = deps.listGallery(options());
loadFirst(first);
// Die Hero-Karte zeigt immer die neueste sichtbare Karte, auch bei Suche/Tag: dann braucht sie eine eigene Abfrage.
const unfiltered = !query && !tag;
showHero((unfiltered ? first : deps.listGallery({})).then((res) => res.cards[0] ?? null));
// Der Anker (z. B. /#community) existiert erst jetzt; nach dem ersten Laden noch einmal, weil die Seite dann länger ist.
if (location.hash) {
deps.scrollToHash(location.hash);
const hash = location.hash;
const again = () => {
if (!disposed) deps.scrollToHash(hash);
};
first.then(again, again);
}
return () => {
disposed = true;
stopAuth();
grid?.dispose();
grid = null;
if (heroUrl) URL.revokeObjectURL(heroUrl);
heroUrl = null;
};
}
+2 -2
View File
@@ -49,8 +49,8 @@ export function titleFor(route: Route, detailTitle?: string): string {
let stopCurrent: (() => void) | null = null;
let renderCurrent: (() => void) | null = null;
/** Springt zum Anker `#<id>`; gibt es ihn nicht, an den Seitenanfang. */
function scrollToHash(hash: string): void {
/** Springt zum Anker `#<id>`; gibt es ihn nicht, an den Seitenanfang. Seiten, die den Anker erst nach dem Laden bekommen, rufen es erneut auf. */
export function scrollToHash(hash: string): void {
let id = hash.slice(1);
try {
id = decodeURIComponent(id);