diff --git a/web/public/hero-beispiel.png b/web/public/hero-beispiel.png new file mode 100644 index 0000000..3cedf13 Binary files /dev/null and b/web/public/hero-beispiel.png differ diff --git a/web/src/community/community.css b/web/src/community/community.css new file mode 100644 index 0000000..34e1655 --- /dev/null +++ b/web/src/community/community.css @@ -0,0 +1,545 @@ +/* Startseite: kompakter Hero mit schwebender Karte und die Community-Galerie darunter. + Wichtig (Abnahmekriterium): bei 1440 x 900 und 390 x 844 stehen die Überschrift „Community-Karten“ und + die Oberkante der ersten Kartenreihe schon im ersten Bildschirm. Der Hero bleibt deshalb niedrig. */ + +/* Knöpfe der Startseite (unabhängig von editor.css / account.css) */ + +.home-btn { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 46px; + margin: 0; + padding: 0 22px; + border: 1px solid var(--line-strong); + border-radius: 12px; + background: transparent; + color: var(--text); + font: inherit; + font-weight: 700; + line-height: 1.2; + text-align: center; + text-decoration: none; + cursor: pointer; + transition: background-color 0.12s, border-color 0.12s; +} + +.home-btn:hover:not(:disabled) { + border-color: var(--sel-line); + background: var(--panel); +} + +.home-btn-primary { + border-color: var(--primary); + background: var(--primary); + color: var(--on-primary); +} + +.home-btn-primary:hover:not(:disabled) { + border-color: var(--primary-hover); + background: var(--primary-hover); +} + +.home-btn:disabled { + opacity: 0.55; + cursor: progress; +} + +/* Hero */ + +.home-hero { + --hero-card-max: 540px; + --card-radius: 3.711% / 5.882%; /* 57 px von 1536 x 969, wie in der Editor-Vorschau */ + display: grid; + grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr); + align-items: center; + gap: 24px 56px; + min-height: auto; + padding: 14px 0 10px; +} + +/* Nur auf breiten, hohen Fenstern: der Hero nimmt höchstens 60 % der Höhe. Auf schmaleren oder niedrigeren Fenstern + bräche der Text in mehr Zeilen um und würde über die Grenze hinausragen (und die Galerie-Überschrift überdecken). */ +@media (min-width: 1100px) and (min-height: 700px) { + .home-hero { + max-height: 60vh; + --hero-card-max: min(540px, calc((60vh - 48px) * 1536 / 969)); + } +} + +.home-hero-copy { + min-width: 0; +} + +.home-hero-eyebrow { + margin: 0 0 10px; + color: var(--muted); + font-size: 0.95rem; + font-weight: 600; + letter-spacing: 0.02em; +} + +#app-root .home-hero-title { + margin: 0 0 14px; + font-size: clamp(1.95rem, 4.4vw, 3.15rem); + font-weight: 800; + line-height: 1.08; + letter-spacing: -0.01em; + text-wrap: balance; +} + +.home-hero-lead { + max-width: 34em; + margin: 0 0 22px; + color: var(--muted); + font-size: 1.1rem; + line-height: 1.5; +} + +.home-hero-actions { + display: flex; + flex-wrap: wrap; + gap: 10px 12px; +} + +.home-hero-visual { + position: relative; + display: flex; + align-items: center; + justify-content: flex-end; + min-width: 0; + padding: 10px 0; +} + +/* weicher Lichtschein hinter der Karte */ +.home-hero-visual::before { + content: ''; + position: absolute; + inset: -10% 0; /* seitlich nicht über den Hero hinaus: sonst entsteht waagerechtes Scrollen */ + border-radius: 50%; + background: radial-gradient(closest-side, rgb(112 92 255 / 0.32), rgb(60 140 255 / 0.12) 60%, transparent); + filter: blur(14px); + pointer-events: none; +} + +:root[data-theme='light'] .home-hero-visual::before { + background: radial-gradient(closest-side, rgb(112 92 255 / 0.22), rgb(60 140 255 / 0.1) 60%, transparent); +} + +.hero-card { + position: relative; + width: min(100%, var(--hero-card-max)); + aspect-ratio: 1536 / 969; + overflow: hidden; + border-radius: var(--card-radius); + background: var(--panel-2); + box-shadow: var(--shadow-card), 0 0 0 1px rgb(255 255 255 / 0.06); + transform: rotate(-1.5deg); +} + +.hero-card img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.hero-card-link { + display: block; + width: 100%; + height: 100%; +} + +/* Schwebe-Animation: nur ohne „Bewegung reduzieren“ */ +@media (prefers-reduced-motion: no-preference) { + .hero-card { + animation: hero-float 7s ease-in-out infinite; + } +} + +@keyframes hero-float { + 0%, + 100% { + transform: translateY(0) rotate(-1.5deg); + } + + 50% { + transform: translateY(-10px) rotate(-0.7deg); + } +} + +/* Galerie-Abschnitt */ + +.home-community { + margin-top: 36px; + scroll-margin-top: 16px; +} + +.community-head { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: 12px 24px; +} + +.community-head-text { + min-width: 0; +} + +.community-title { + margin: 0; + font-size: clamp(1.5rem, 3vw, 1.95rem); + font-weight: 800; + line-height: 1.15; +} + +.community-sub { + margin: 4px 0 0; + color: var(--muted); +} + +.community-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0 16px; +} + +.community-toolbar > :empty { + display: none; +} + +.community-active, +.community-filter { + margin-top: 16px; +} + +.community-active { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.community-chip { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + padding: 3px 4px 3px 14px; + border: 1px solid var(--sel-line); + border-radius: 999px; + background: var(--sel-bg); + font-size: 0.95rem; +} + +.community-chip-label { + color: var(--muted); +} + +.community-chip-value { + min-width: 0; + overflow-wrap: anywhere; +} + +.community-chip-clear { + appearance: none; + display: grid; + flex: none; + place-items: center; + width: 32px; + height: 32px; + margin: 0; + padding: 0; + border: 0; + border-radius: 50%; + background: transparent; + color: inherit; + font: inherit; + font-size: 1.35rem; + line-height: 1; + cursor: pointer; +} + +.community-chip-clear:hover { + background: rgb(127 127 127 / 0.22); +} + +.community-filter { + display: inline-flex; + flex-wrap: wrap; + gap: 6px; +} + +.community-filter-btn { + appearance: none; + min-height: 36px; + margin: 0; + padding: 0 16px; + border: 1px solid var(--line-strong); + border-radius: 999px; + background: transparent; + color: var(--text); + font: inherit; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; +} + +.community-filter-btn:hover { + border-color: var(--sel-line); +} + +.community-filter-btn[aria-pressed='true'] { + border-color: var(--primary); + background: var(--primary); + color: var(--on-primary); +} + +.community-status { + margin: 24px 0 12px; + color: var(--muted); +} + +.community-status.is-error, +.community-more-msg.is-error { + color: var(--danger-text); +} + +.community-empty { + margin-top: 20px; + padding: 36px 20px; + border: 1px dashed var(--line-strong); + border-radius: 18px; + color: var(--muted); + font-size: 1.05rem; + text-align: center; +} + +.community-empty p { + margin: 0 0 16px; +} + +.community-empty p:last-child { + margin-bottom: 0; +} + +.community-more { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + margin-top: 28px; +} + +.community-more:empty { + margin-top: 0; +} + +.community-more-msg { + margin: 0; +} + +/* Kachelraster */ + +.gallery-grid { + --card-radius: 3.711% / 5.882%; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr)); + gap: 20px; + margin: 20px 0 0; + padding: 0; + list-style: none; +} + +.gallery-tile { + position: relative; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + padding: 10px 10px 14px; + border: 1px solid var(--line); + border-radius: 18px; + background: var(--panel); + transition: border-color 0.12s, box-shadow 0.12s; +} + +.gallery-tile:hover { + border-color: var(--line-strong); + box-shadow: var(--shadow-card); +} + +.gallery-tile-link { + display: flex; + flex-direction: column; + gap: 10px; + border-radius: 12px; + color: inherit; + text-decoration: none; +} + +.gallery-thumb { + display: flex; + align-items: center; + justify-content: center; + aspect-ratio: 1536 / 969; + overflow: hidden; + border-radius: var(--card-radius); + background: var(--panel-2); + color: var(--muted); + font-size: 0.9rem; + text-align: center; +} + +.gallery-thumb img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.gallery-title { + display: -webkit-box; + margin: 0; + padding: 0 4px; + overflow: hidden; + font-size: 1.05rem; + font-weight: 700; + line-height: 1.25; + overflow-wrap: anywhere; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; +} + +.gallery-issuer { + margin: 0; + padding: 0 4px; + overflow: hidden; + color: var(--muted); + font-size: 0.9rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.gallery-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: auto; + padding: 6px 4px 0; +} + +.gallery-tag { + appearance: none; + min-height: 28px; + margin: 0; + padding: 2px 11px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--panel-2); + color: var(--muted); + font: inherit; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; +} + +.gallery-tag:hover { + border-color: var(--sel-line); + color: var(--text); +} + +.gallery-badges { + position: absolute; + top: 18px; + left: 18px; + display: flex; + flex-wrap: wrap; + gap: 6px; + pointer-events: none; +} + +.gallery-badge { + padding: 2px 9px; + border-radius: 999px; + background: rgb(15 24 40 / 0.85); + color: #fff; + font-size: 0.78rem; + font-weight: 700; +} + +.gallery-badge-hidden { + background: #b42318; +} + +/* Tablet und Handy: eine Spalte, der Hero bleibt niedrig */ + +@media (max-width: 899px) { + .home-hero { + --hero-card-max: 400px; + grid-template-columns: minmax(0, 1fr); + gap: 16px; + padding: 4px 0 6px; + } + + .home-hero-visual { + justify-content: center; + } +} + +@media (max-width: 599px) { + .home-hero { + --hero-card-max: 250px; + gap: 8px; + margin-top: -4px; + padding: 0; + } + + .home-hero-eyebrow { + margin-bottom: 4px; + font-size: 0.9rem; + } + + #app-root .home-hero-title { + margin-bottom: 8px; + font-size: 1.85rem; + } + + .home-hero-lead { + margin-bottom: 14px; + font-size: 0.97rem; + line-height: 1.45; + } + + .home-btn { + min-height: 44px; + padding: 0 18px; + } + + .home-hero-visual { + padding: 10px 0 4px; /* Luft nach oben für die Schwebe-Animation (10 px nach oben) */ + } + + .home-community { + margin-top: 20px; + } + + .community-head { + gap: 10px; + } + + .community-publish { + width: 100%; + min-height: 42px; + } +} + +@media (prefers-reduced-motion: reduce) { + .home-btn, + .gallery-tile { + transition: none; + } +} diff --git a/web/src/community/gallery-grid.test.ts b/web/src/community/gallery-grid.test.ts new file mode 100644 index 0000000..44a2ccc --- /dev/null +++ b/web/src/community/gallery-grid.test.ts @@ -0,0 +1,178 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { GalleryCard } from './gallery'; +import { mountGalleryGrid, type GalleryGrid } from './gallery-grid'; + +const card = (n: number, patch: Partial = {}): 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, +}); + +const blob = () => new Blob(['x'], { type: 'image/webp' }); +const flush = async () => { + for (let i = 0; i < 5; i++) await Promise.resolve(); +}; + +let host: HTMLElement; +let grid: GalleryGrid | null; +let counter: number; +const revoked: string[] = []; + +beforeEach(() => { + host = document.createElement('div'); + document.body.append(host); + grid = null; + counter = 0; + revoked.length = 0; + URL.createObjectURL = vi.fn(() => `blob:t-${++counter}`); + URL.revokeObjectURL = vi.fn((url: string) => { + revoked.push(url); + }); +}); + +afterEach(() => { + grid?.dispose(); + host.remove(); +}); + +const tiles = () => [...host.querySelectorAll('.gallery-tile')]; + +describe('mountGalleryGrid: Kacheln', () => { + it('zeigt Titel, Herausgeber und Kachel-Link auf die Detailseite', () => { + grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag: () => {} }); + grid.append([card(1), card(2)]); + expect(tiles()).toHaveLength(2); + const first = tiles()[0]; + const link = first.querySelector('a[data-link]'); + expect(link?.getAttribute('href')).toBe('/community/g1'); + expect(first.querySelector('.gallery-title')?.textContent).toBe('Karte 1'); + expect(first.querySelector('.gallery-issuer')?.textContent).toBe('Beispielbank'); + }); + + it('kodiert die ID im Link und lässt den Herausgeber weg, wenn er fehlt', () => { + grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag: () => {} }); + grid.append([card(1, { id: 'a/b c', issuer: '' })]); + expect(host.querySelector('a[data-link]')?.getAttribute('href')).toBe('/community/a%2Fb%20c'); + expect(host.querySelector('.gallery-issuer')).toBeNull(); + }); + + it('zeigt höchstens drei Tags als Knöpfe; Klick meldet den Tag', () => { + const onTag = vi.fn(); + grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag }); + grid.append([card(1, { tags: ['visa', 'gold', 'dunkel', 'extra'] })]); + const buttons = [...host.querySelectorAll('button.gallery-tag')]; + expect(buttons.map((b) => b.textContent)).toEqual(['visa', 'gold', 'dunkel']); + expect(buttons.every((b) => b.type === 'button')).toBe(true); + buttons[1].click(); + expect(onTag).toHaveBeenCalledWith('gold'); + }); + + it('die Tags stehen außerhalb des Kachel-Links (kein Knopf im Link)', () => { + grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag: () => {} }); + grid.append([card(1)]); + expect(host.querySelector('a button')).toBeNull(); + }); + + it('setzt Titel und Herausgeber als Text, nie als HTML', () => { + grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag: () => {} }); + grid.append([card(1, { title: '', issuer: 'fett', tags: ['x'] })]); + expect(host.querySelector('.gallery-title')?.textContent).toBe(''); + expect(host.querySelector('.gallery-tile img[src="x"]')).toBeNull(); + expect(host.querySelector('.gallery-issuer b')).toBeNull(); + expect(host.querySelector('.gallery-tag i')).toBeNull(); + }); + + it('append hängt an, ohne die vorhandenen Kacheln zu ersetzen', () => { + grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag: () => {} }); + grid.append([card(1)]); + const firstTile = tiles()[0]; + grid.append([card(2), card(3)]); + expect(tiles()).toHaveLength(3); + expect(tiles()[0]).toBe(firstTile); + }); + + it('Status-Marken (Ausgeblendet, Meldungen) nur mit showStatus', () => { + grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag: () => {}, showStatus: true }); + grid.append([card(1, { hidden: true, reportCount: 3 }), card(2, { reportCount: 1 }), card(3)]); + const badges = tiles().map((t) => [...t.querySelectorAll('.gallery-badge')].map((b) => b.textContent)); + expect(badges).toEqual([['Ausgeblendet', '3 Meldungen'], ['1 Meldung'], []]); + + grid.dispose(); + host.replaceChildren(); + grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag: () => {} }); + grid.append([card(1, { hidden: true, reportCount: 3 })]); + expect(host.querySelector('.gallery-badge')).toBeNull(); + }); +}); + +describe('mountGalleryGrid: Vorschaubilder', () => { + it('lädt die Vorschau jeder Karte und zeigt sie per Object-URL', async () => { + const load = vi.fn(async () => blob()); + grid = mountGalleryGrid(host, { load, onTag: () => {} }); + const cards = [card(1), card(2)]; + grid.append(cards); + await flush(); + expect(load).toHaveBeenCalledTimes(2); + expect(load.mock.calls.map((c) => (c as unknown as [GalleryCard])[0].id)).toEqual(['g1', 'g2']); + const srcs = [...host.querySelectorAll('.gallery-thumb img')].map((i) => i.getAttribute('src')); + expect(srcs).toEqual(['blob:t-1', 'blob:t-2']); + }); + + it('gibt alle Object-URLs beim Aufräumen frei', async () => { + grid = mountGalleryGrid(host, { load: async () => blob(), onTag: () => {} }); + grid.append([card(1), card(2), card(3)]); + await flush(); + expect(counter).toBe(3); + expect(revoked).toEqual([]); + grid.dispose(); + expect(revoked.sort()).toEqual(['blob:t-1', 'blob:t-2', 'blob:t-3']); + }); + + it('legt nach dem Aufräumen keine URL mehr an (späte Antwort) und stößt keine neuen Ladungen an', async () => { + const resolvers: Array<(b: Blob) => void> = []; + const load = vi.fn(() => new Promise((r) => resolvers.push(r))); + grid = mountGalleryGrid(host, { load, onTag: () => {} }); + grid.append([card(1), card(2)]); + expect(load).toHaveBeenCalledTimes(2); + grid.dispose(); + for (const resolve of resolvers) resolve(blob()); + await flush(); + expect(counter).toBe(0); + grid.append([card(3)]); + expect(load).toHaveBeenCalledTimes(2); + expect(tiles()).toHaveLength(2); // die Kachel nach dem Aufräumen kommt nicht mehr dazu + }); + + it('lädt höchstens sechs Vorschauen gleichzeitig', async () => { + const pending: Array<(b: Blob) => void> = []; + const load = vi.fn(() => new Promise((r) => pending.push(r))); + grid = mountGalleryGrid(host, { load, onTag: () => {} }); + grid.append(Array.from({ length: 10 }, (_, i) => card(i + 1))); + expect(load).toHaveBeenCalledTimes(6); + pending[0](blob()); + await flush(); + expect(load).toHaveBeenCalledTimes(7); + }); + + it('eine fehlgeschlagene Vorschau zeigt „Keine Vorschau“ und hält die anderen nicht auf', async () => { + const load = vi.fn(async (c: GalleryCard) => { + if (c.id === 'g1') throw new Error('404'); + return blob(); + }); + grid = mountGalleryGrid(host, { load, onTag: () => {} }); + grid.append([card(1), card(2)]); + await flush(); + expect(tiles()[0].querySelector('.gallery-thumb')?.textContent).toBe('Keine Vorschau'); + expect(tiles()[1].querySelector('.gallery-thumb img')).not.toBeNull(); + }); +}); diff --git a/web/src/community/gallery-grid.ts b/web/src/community/gallery-grid.ts new file mode 100644 index 0000000..ab94707 --- /dev/null +++ b/web/src/community/gallery-grid.ts @@ -0,0 +1,115 @@ +// Kachelraster der Community-Galerie: Vorschau, Titel, Herausgeber, bis zu drei Tags. +// Die Vorschaubilder kommen über `load` (WebP 480 px aus dem Galerie-Bucket) und werden als Object-URL angezeigt; +// `dispose()` gibt alle URLs wieder frei. Alle Texte gehen als Text in den DOM, nie als HTML. +import { el } from '../editor/dom'; +import type { GalleryCard } from './gallery'; + +export interface GalleryGridOptions { + /** Lädt die Vorschau einer Karte. */ + load: (card: GalleryCard) => Promise; + /** Ein Tag-Knopf wurde angeklickt. */ + onTag: (tag: string) => void; + /** Admin-Ansicht: „Ausgeblendet“ und die Zahl der Meldungen an der Kachel zeigen (für alle anderen bleibt das verborgen). */ + showStatus?: boolean; +} + +export interface GalleryGrid { + /** Hängt Kacheln hinten an. */ + append(cards: readonly GalleryCard[]): void; + /** Gibt alle Object-URLs frei und beendet laufende Ladungen (späte Antworten werden verworfen). */ + dispose(): void; +} + +const PARALLEL = 6; +const MAX_TAGS = 3; +const THUMB_W = 480; +const THUMB_H = 303; // 480 x 1536:969 + +/** „1 Meldung“, „3 Meldungen“. */ +function reportsLabel(count: number): string { + return count === 1 ? '1 Meldung' : `${count} Meldungen`; +} + +export function mountGalleryGrid(container: HTMLElement, opts: GalleryGridOptions): GalleryGrid { + const list = el('ul', { class: 'gallery-grid' }); + container.replaceChildren(list); + + let disposed = false; + const urls = new Set(); + const queue: Array<{ card: GalleryCard; thumb: HTMLElement }> = []; + let active = 0; + + async function loadThumb(card: GalleryCard, thumb: HTMLElement): Promise { + try { + const blob = await opts.load(card); + if (disposed) return; // zu spät: keine URL anlegen, die niemand mehr freigibt + const url = URL.createObjectURL(blob); + urls.add(url); + thumb.replaceChildren(el('img', { src: url, alt: '', width: THUMB_W, height: THUMB_H, decoding: 'async' })); + } catch { + if (disposed) return; + thumb.replaceChildren(el('span', { class: 'gallery-thumb-empty' }, 'Keine Vorschau')); + } finally { + thumb.removeAttribute('aria-busy'); + } + } + + function pump(): void { + while (!disposed && active < PARALLEL && queue.length > 0) { + const job = queue.shift() as { card: GalleryCard; thumb: HTMLElement }; + active += 1; + void loadThumb(job.card, job.thumb).finally(() => { + active -= 1; + pump(); + }); + } + } + + function buildTile(card: GalleryCard): HTMLElement { + const thumb = el('span', { class: 'gallery-thumb', 'aria-busy': 'true' }); + const link = el( + 'a', + { class: 'gallery-tile-link', href: `/community/${encodeURIComponent(card.id)}`, 'data-link': true }, + thumb, + el('h3', { class: 'gallery-title' }, card.title), + ); + const tile = el('li', { class: 'gallery-tile' }, link); + if (card.issuer) tile.append(el('p', { class: 'gallery-issuer' }, card.issuer)); + + const tags = card.tags.slice(0, MAX_TAGS); + if (tags.length > 0) { + const row = el('div', { class: 'gallery-tags' }); + for (const tag of tags) { + const button = el('button', { type: 'button', class: 'gallery-tag', title: 'Karten mit diesem Tag zeigen' }, tag); + button.addEventListener('click', () => opts.onTag(tag)); + row.append(button); + } + tile.append(row); + } + + if (opts.showStatus && (card.hidden || card.reportCount > 0)) { + const badges = el('div', { class: 'gallery-badges' }); + if (card.hidden) badges.append(el('span', { class: 'gallery-badge gallery-badge-hidden' }, 'Ausgeblendet')); + if (card.reportCount > 0) badges.append(el('span', { class: 'gallery-badge' }, reportsLabel(card.reportCount))); + tile.append(badges); + } + + queue.push({ card, thumb }); + return tile; + } + + return { + append(cards) { + if (disposed) return; + list.append(...cards.map((card) => buildTile(card))); + pump(); + }, + dispose() { + if (disposed) return; + disposed = true; + queue.length = 0; + for (const url of urls) URL.revokeObjectURL(url); + urls.clear(); + }, + }; +} diff --git a/web/src/pages/home-page.test.ts b/web/src/pages/home-page.test.ts new file mode 100644 index 0000000..c9f7217 --- /dev/null +++ b/web/src/pages/home-page.test.ts @@ -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 => ({ + 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>(); +const loadImage = vi.fn<(c: GalleryCard, size: 'full' | 'preview') => Promise>(); +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 { + history.replaceState(null, '', url); + unmount = mountHomePage(root, deps()); + await settle(); +} + +const q = (sel: string) => root.querySelector(sel); +const qa = (sel: string) => [...root.querySelectorAll(sel)]; +const buttonByText = (text: string) => qa('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 = '
'; + 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('.home-hero a[href="#community"]'); + expect(discover?.textContent).toBe('Karten entdecken ↓'); + expect(discover?.hasAttribute('data-link')).toBe(true); + const design = q('.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('.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('.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('.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('.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('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=#community', async () => { + listGallery.mockResolvedValue(page([card(1, { tags: ['visa', 'dunkel blau'] })])); + await mount(); + qa('button.gallery-tag')[0].click(); + expect(navigate).toHaveBeenLastCalledWith('/?tag=visa#community'); + qa('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('button'); + expect(clear?.textContent).toBe('×'); + expect(clear?.getAttribute('aria-label')).toBe('Suche zurücksetzen'); + expect(document.querySelector('.header-search-input')?.value).toBe('visa gold'); + }); + + it('„ד entfernt die Suche (zurück zu /#community)', async () => { + await mount('/?q=visa#community'); + q('.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('.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('.header-search-input')!; + expect(input.value).toBe('x'); + 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('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('.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('.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((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((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(); + }); +}); diff --git a/web/src/pages/home-page.ts b/web/src/pages/home-page.ts index 2818080..399ff58 100644 --- a/web/src/pages/home-page.ts +++ b/web/src/pages/home-page.ts @@ -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; + 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), ); - 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): 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; + }; } diff --git a/web/src/pages/router.ts b/web/src/pages/router.ts index 2e116b1..e60d511 100644 --- a/web/src/pages/router.ts +++ b/web/src/pages/router.ts @@ -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 `#`; gibt es ihn nicht, an den Seitenanfang. */ -function scrollToHash(hash: string): void { +/** Springt zum Anker `#`; 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);