Files
AirCardIOS/web/src/pages/home-page.ts
T
ClaudeandClaude Sonnet 5.5 c4a66f674f 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>
2026-10-04 15:07:40 +00:00

342 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<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),
);
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;
};
}