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:
1 parent
ae73dc4a0b
commit
c4a66f674f
7 files changed
+1609
-8
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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> = {}): 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<HTMLElement>('.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<HTMLAnchorElement>('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<HTMLButtonElement>('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: '<img src=x onerror=alert(1)>', issuer: '<b>fett</b>', tags: ['<i>x</i>'] })]);
|
||||
expect(host.querySelector('.gallery-title')?.textContent).toBe('<img src=x onerror=alert(1)>');
|
||||
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<HTMLImageElement>('.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<Blob>((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<Blob>((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();
|
||||
});
|
||||
});
|
||||
@@ -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<Blob>;
|
||||
/** 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<string>();
|
||||
const queue: Array<{ card: GalleryCard; thumb: HTMLElement }> = [];
|
||||
let active = 0;
|
||||
|
||||
async function loadThumb(card: GalleryCard, thumb: HTMLElement): Promise<void> {
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user