feat(editor): Community-Logos unten rechts mit Herzen und Sortierung

Task 8: der Bereich „Logo unten rechts“ in der Optionsspalte des Editors (Spec § 3 und § 5).

- logos/logo-picker.ts (neu): createLogoPicker baut Kopf (Überschrift, Plakette „Community“, „+ Hochladen“, Satz zur Community), den
  Umschalter „Beliebt“/„Neu“ (Admins zusätzlich „Gemeldet“/„Ausgeblendet“, je eine Ansicht mit sort und filter) und das Raster:
  erste Kachel „Kein Logo“, je Logo eine Kachel mit Bild, Name, „von <Anzeigename>“ bzw. „von Anonym“ und einem eigenen Herz-Knopf
  neben dem Auswahlknopf. „Mehr laden“ hängt die nächste Seite an (Duplikate nur einmal), Leer-Text und Ladefehler mit „Nochmal
  versuchen“ (wiederholt genau den gescheiterten Schritt); nur die jüngste Antwort einer Ansicht zählt. Die Auswahl gehört dem
  Editor: ein Klick meldet onSelect, markiert wird über select(); ein Logo außerhalb der Liste wird nach dem Laden der ersten Seite
  per getLogo geholt und als zweite Kachel angeheftet (steht es in der Liste, gibt es keine Anfrage). selected() liefert die Zeile
  für die Infozeile in Task 9. Herzen: optimistisch mit Rücksprung und Fehlertext (role="alert"); solange eine Anfrage für ein
  Logo läuft, bleiben weitere Klicks auf sein Herz wirkungslos; Gäste öffnen erst den Login (abgebrochen: nichts passiert, kein
  Fehlertext); die Herzen einer Seite kommen mit genau deren IDs, beim Anmelden für alle gezeigten Logos, beim Abmelden aus; eine
  späte Antwort überschreibt kein inzwischen selbst geschaltetes Herz. „Hochladen“ meldet Gäste zuerst an, öffnet den Dialog (mit
  Signal, das beim Verlassen der Seite schließt), setzt das neue Logo als erste Kachel, ruft rememberLogo und onSelect(id).
- logos/logo-image.ts (neu): das PNG einer Kachel laden, auf die Pixelgrenze prüfen (MAX_LOGO_PIXELS, so wie checkGalleryImage die
  Galerie-Bilder prüft; darüber, nicht lesbar oder Ladefehler: neutraler Platzhalter statt Absturz) und zeigen: mehrfarbig als <img>,
  einfarbig als weiße Maske, immer auf dem dunklen Kartengrund. Die Object-URL gibt die zurückgegebene Funktion frei; eine Datei, die
  erst danach eintrifft, wird nicht mehr angezeigt.
- logos/logo-asset.ts: MAX_LOGO_PIXELS wird exportiert (eine Quelle für Renderer und Kacheln).
- logos/logos.css: Kopf, Umschalter, Raster (ab 1024 px zwei Spalten, sonst auto-fill ab 140 px), Kacheln mit Bildfläche auf #1B2738,
  Herz (gefüllt, wenn gedrückt), Leer- und Fehlerzeilen; Bild und Maske teilen sich die Regeln mit der Dialog-Vorschau.
- editor/editor-page.ts: der Picker sitzt in options.logoSlot, die Wahl setzt config.logo, sync ruft picker.select(config.logo), das
  Verlassen der Seite ruft dispose. Übernommen aus dem Review von Task 5: failedKey blockiert ein Logo nicht mehr für immer, eine
  andere Wahl (Logo, Details, Marke) hebt die Sperre auf; derselbe Schlüssel wird weiter nicht im Kreis neu versucht.
- editor/dom.ts: Symbole heart und plus. i18n/en.ts: englische Texte zu allen neuen Beschriftungen.
- Tests: logo-picker.test.ts (Aufbau, Sortierung, Mehr laden, Auswahl und angeheftetes Logo, Herzen mit Rennen und Login, Leer und
  Ladefehler, Hochladen, Kachelbilder, dispose, Englisch), logo-image.test.ts, editor-page.test.ts (Verdrahtung, Regressionstest zu
  failedKey). Vitest 51 Dateien, 1666 Tests grün, tsc und Build sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 22:40:37 +00:00
1 parent e22919f4dc
commit 4cf9fe192d
10 files changed
+2279 -10

No files matched your search

+251 -6
View File
@@ -1,5 +1,6 @@
/* Community-Logos. Dialog „Logo hochladen“: Hülle, Felder, Pflicht-Häkchen, Meldungen und Knöpfe kommen aus community.css
(Melden-/Veröffentlichen-Dialog) und account.css; hier steht nur, was neu ist: das Datei-Feld und die Vorschau. */
(Melden-/Veröffentlichen-Dialog) und account.css; hier steht nur, was neu ist: das Datei-Feld und die Vorschau.
Weiter unten der Bereich „Logo unten rechts“ im Editor (Raster, Sortierung, Herzen); seine Kacheln beruhen auf `.tile` aus editor.css. */
/* Etwas breiter als der Veröffentlichen-Dialog (480 px): zwei Vorschauen nebeneinander. Mit zwei Klassen, damit die Breite
nicht von der Reihenfolge der Stylesheets abhängt. */
@@ -77,19 +78,23 @@
}
.logo-preview-img,
.logo-preview-mask {
.logo-preview-mask,
.logo-tile-img,
.logo-tile-mask {
display: block;
width: 100%;
height: 100%;
}
.logo-preview-img {
.logo-preview-img,
.logo-tile-img {
object-fit: contain;
}
/* Einfarbig: nur die Deckkraft des PNGs zählt (Maske auf dessen Object-URL, `--logo-url` setzt der Dialog), die Farbe kommt
als Hintergrund (Weiß auf dunklem, #111111 auf hellem Grund, ebenfalls vom Dialog gesetzt). */
.logo-preview-mask {
/* Einfarbig: nur die Deckkraft des PNGs zählt (Maske auf dessen Object-URL, `--logo-url` setzt der Dialog bzw. die Kachel), die
Farbe kommt als Hintergrund (Weiß auf dunklem, #111111 auf hellem Grund im Dialog; Weiß in der Kachel, ebenfalls gesetzt). */
.logo-preview-mask,
.logo-tile-mask {
-webkit-mask-image: var(--logo-url);
mask-image: var(--logo-url);
-webkit-mask-repeat: no-repeat;
@@ -116,3 +121,243 @@
opacity: 0.55;
cursor: not-allowed;
}
/* Bereich „Logo unten rechts“ im Editor */
.logo-picker {
display: flex;
flex-direction: column;
gap: 12px;
}
.logo-picker-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
}
/* wie `.panel-subtitle` */
.logo-picker-title {
margin: 0;
padding-left: 4px;
font-size: 1.05rem;
font-weight: 600;
line-height: 1.2;
}
.logo-badge {
padding: 2px 9px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel-2);
color: var(--muted);
font-size: 0.78rem;
font-weight: 700;
}
/* Mit zwei Klassen, damit `margin: 0` aus `.auth-btn` nicht von der Reihenfolge der Stylesheets abhängt (wie beim Dialog). */
.auth-btn.logo-upload {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: auto;
}
.logo-picker-note {
margin: -4px 0 0;
padding-left: 4px;
color: var(--muted);
font-size: 0.85rem;
}
/* Sortierung: Umschalter wie die gewählte Kachel (Rahmen und Grund der Auswahl) */
.logo-sort {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.logo-sort-btn {
appearance: none;
min-height: 34px;
margin: 0;
padding: 0 14px;
border: 1px solid var(--line);
border-radius: 999px;
background: transparent;
color: var(--text);
font: inherit;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.12s, border-color 0.12s;
}
.logo-sort-btn:hover {
border-color: var(--line-strong);
background: var(--panel);
}
.logo-sort-btn[aria-pressed='true'] {
border-color: var(--sel-line);
background: var(--sel-bg);
}
/* Raster: ab 1024 px ist die Optionsspalte schmal (zwei Spalten). Darunter steht sie in voller Breite, auch auf dem Handy unter
900 px: so viele Spalten, wie mit mindestens 140 px je Kachel passen. */
.logo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 10px;
margin: 0;
padding: 0;
list-style: none;
}
@media (min-width: 1024px) {
.logo-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.logo-cell {
position: relative;
min-width: 0;
}
.logo-tile {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
min-width: 0;
padding: 8px;
}
/* Die Bildfläche steht immer auf dem dunklen Kartengrund (#1B2738), in beiden Themes: so sieht das Logo aus wie auf der Karte. */
.logo-tile-art {
display: flex;
align-items: center;
justify-content: center;
height: 72px;
margin-bottom: 6px;
padding: 10px;
border-radius: 8px;
background: #1b2738;
color: #9db0c8;
}
.logo-tile-none .logo-tile-art {
background: var(--panel-2);
color: var(--muted);
}
.logo-tile-art .logo-none-icon {
width: 36px;
height: 36px;
}
/* Bild nicht verfügbar (Ladefehler, nicht lesbar, zu groß): ein neutraler gestrichelter Platzhalter */
.logo-tile-art[data-state='unusable']::after {
content: '';
width: 44%;
height: 56%;
border: 2px dashed currentColor;
border-radius: 6px;
opacity: 0.45;
}
.logo-tile-name,
.logo-tile-by {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.logo-tile-name {
padding: 0 2px;
font-size: 0.95rem;
font-weight: 700;
line-height: 1.3;
}
.logo-tile-by {
padding: 0 2px;
color: var(--muted);
font-size: 0.8rem;
line-height: 1.3;
}
/* Herz: ein eigener Knopf neben dem Auswahlknopf, über der Bildfläche (also immer auf dunklem Grund). Gedrückt: gefülltes Herz. */
.logo-heart {
appearance: none;
position: absolute;
top: 14px;
right: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 44px;
min-height: 32px;
margin: 0;
padding: 0 9px;
border: 0;
border-radius: 999px;
background: rgb(15 24 40 / 0.78);
color: #e8eef7;
font: inherit;
font-size: 0.8rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
cursor: pointer;
transition: background-color 0.12s;
}
.logo-heart:hover {
background: rgb(15 24 40 / 0.95);
}
.logo-heart[aria-pressed='true'] {
color: #ff7a8a;
}
.logo-heart[aria-pressed='true'] .icon {
fill: currentColor;
}
.logo-heart .icon {
flex: none;
font-size: 1rem;
}
/* Leer, Fehler, „Nochmal versuchen“ und „Mehr laden“ */
.logo-empty {
margin: 0;
padding-left: 4px;
color: var(--muted);
font-size: 0.9rem;
}
.logo-empty:empty {
display: none;
}
.logo-retry,
.logo-more {
align-self: center;
}
@media (prefers-reduced-motion: reduce) {
.logo-sort-btn,
.logo-heart {
transition: none;
}
}