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>
342 lines
12 KiB
TypeScript
342 lines
12 KiB
TypeScript
// 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;
|
||
};
|
||
}
|