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
Binary file not shown.
|
After Width: | Height: | Size: 403 KiB |
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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> = {}): 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<Page>>();
|
||||||
|
const loadImage = vi.fn<(c: GalleryCard, size: 'full' | 'preview') => Promise<Blob>>();
|
||||||
|
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<void> {
|
||||||
|
history.replaceState(null, '', url);
|
||||||
|
unmount = mountHomePage(root, deps());
|
||||||
|
await settle();
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = <T extends Element = HTMLElement>(sel: string) => root.querySelector<T>(sel);
|
||||||
|
const qa = <T extends Element = HTMLElement>(sel: string) => [...root.querySelectorAll<T>(sel)];
|
||||||
|
const buttonByText = (text: string) => qa<HTMLButtonElement>('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 = '<input class="header-search-input" value="alt"><main id="app-root"></main>';
|
||||||
|
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<HTMLAnchorElement>('.home-hero a[href="#community"]');
|
||||||
|
expect(discover?.textContent).toBe('Karten entdecken ↓');
|
||||||
|
expect(discover?.hasAttribute('data-link')).toBe(true);
|
||||||
|
const design = q<HTMLAnchorElement>('.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<HTMLImageElement>('.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<HTMLAnchorElement>('.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<HTMLImageElement>('.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<HTMLImageElement>('.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<HTMLAnchorElement>('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=<tag>#community', async () => {
|
||||||
|
listGallery.mockResolvedValue(page([card(1, { tags: ['visa', 'dunkel blau'] })]));
|
||||||
|
await mount();
|
||||||
|
qa<HTMLButtonElement>('button.gallery-tag')[0].click();
|
||||||
|
expect(navigate).toHaveBeenLastCalledWith('/?tag=visa#community');
|
||||||
|
qa<HTMLButtonElement>('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<HTMLButtonElement>('button');
|
||||||
|
expect(clear?.textContent).toBe('×');
|
||||||
|
expect(clear?.getAttribute('aria-label')).toBe('Suche zurücksetzen');
|
||||||
|
expect(document.querySelector<HTMLInputElement>('.header-search-input')?.value).toBe('visa gold');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('„ד entfernt die Suche (zurück zu /#community)', async () => {
|
||||||
|
await mount('/?q=visa#community');
|
||||||
|
q<HTMLButtonElement>('.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<HTMLInputElement>('.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<HTMLInputElement>('.header-search-input')!;
|
||||||
|
expect(input.value).toBe('<b>x</b>');
|
||||||
|
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<HTMLButtonElement>('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<HTMLButtonElement>('.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<HTMLButtonElement>('.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<Page>((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<Page>((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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+337
-6
@@ -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 { el } from '../editor/dom';
|
||||||
|
import { navigate, scrollToHash } from './router';
|
||||||
|
|
||||||
export function mountHomePage(root: HTMLElement): () => void {
|
type Page = { cards: GalleryCard[]; nextCursor: string | null };
|
||||||
root.replaceChildren(
|
|
||||||
el('h1', {}, 'Kartendesigns für Apple Wallet'),
|
/** Alles, was die Seite von außen braucht (für Tests austauschbar). */
|
||||||
el('section', { id: 'community', class: 'home-community' }, el('h2', {}, 'Community-Karten')),
|
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),
|
||||||
);
|
);
|
||||||
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<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;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -49,8 +49,8 @@ export function titleFor(route: Route, detailTitle?: string): string {
|
|||||||
let stopCurrent: (() => void) | null = null;
|
let stopCurrent: (() => void) | null = null;
|
||||||
let renderCurrent: (() => void) | null = null;
|
let renderCurrent: (() => void) | null = null;
|
||||||
|
|
||||||
/** Springt zum Anker `#<id>`; gibt es ihn nicht, an den Seitenanfang. */
|
/** Springt zum Anker `#<id>`; gibt es ihn nicht, an den Seitenanfang. Seiten, die den Anker erst nach dem Laden bekommen, rufen es erneut auf. */
|
||||||
function scrollToHash(hash: string): void {
|
export function scrollToHash(hash: string): void {
|
||||||
let id = hash.slice(1);
|
let id = hash.slice(1);
|
||||||
try {
|
try {
|
||||||
id = decodeURIComponent(id);
|
id = decodeURIComponent(id);
|
||||||
|
|||||||
Reference in new issue
Block a user