// 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'; 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; loadImage(card: GalleryCard, size: 'full' | 'preview'): Promise; 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 = [ ['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('.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), ); 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): 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(); 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): 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 { 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; }; }