Feed wird Raster statt Kartenstapel, Swipen entfaellt

Rueckmeldung vom Kunden: der Screenshot war als Funktionsaenderung
gemeint, nicht nur als Optik. Statt eine Karte nach der anderen
durchzuschieben, liegen jetzt alle nebeneinander und werden einzeln
gemerkt, ausgeblendet oder geoeffnet.

Raus:
- Kartenstapel, Maus-Drag, Pfeiltasten, Exit-Animation
- Undo samt History - "gemerkt" ist ein Zustand, den ein zweiter Klick
  zuruecknimmt, keine Entscheidung in einer History
- die drei Aktions-Buttons, das Kuerzel-Panel, die feste Detailspalte

Rein:
- Masonry-Raster (columns 3-6, Standard 5) mit gestaffelten Hoehen
- Merken-Pille und ⋯ beim Hover auf der Kachel; ⋯ bietet "Nicht mehr
  zeigen" und "Details oeffnen"
- Detailansicht als Overlay mit Medium, Beschreibung, Tags, Kennzahlen
- Auswahlmodus mit Auswahlkreisen und Sammelaktion "Zur Sammlung
  hinzufuegen"
- Escape schliesst der Reihe nach Detailansicht, Menue, Auswahlmodus

Karten haben jetzt eine id; Merken, Ausblenden, Auswahl und Detail
referenzieren darueber statt ueber einen Index. Die Profil-Galerie zeigt
dadurch genau die gemerkten Karten, die Kennzahl "Gemerkt" zaehlt sie.
Beispieldaten auf 10/8/6 Karten erweitert, damit das Raster traegt.

Offen und im README vermerkt: Suche ist weiterhin eine Attrappe,
Ausblenden ist fluechtig, Nachladen fuer echte Datenmengen fehlt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 23:38:14 +02:00
parent 347fe7008a
commit 4c47090f42
3 changed files with 433 additions and 394 deletions

View File

@@ -7,14 +7,23 @@ Setze das Design aus diesem Ordner in unserem Stack um.
Zum Ansehen im Browser öffnen — `support.js` muss im selben Ordner liegen. Zum Ansehen im Browser öffnen — `support.js` muss im selben Ordner liegen.
Diese Dateien sind Design-Referenz, kein Produktionscode: nicht kopieren, sondern Diese Dateien sind Design-Referenz, kein Produktionscode: nicht kopieren, sondern
in unserer bestehenden Umgebung mit unseren Patterns und Komponenten nachbauen. in unserer bestehenden Umgebung mit unseren Patterns und Komponenten nachbauen.
3. Fidelity ist high — pixelgenau umsetzen: Farben, Radien, Übergänge, Hover-/Focus-States, 3. Fidelity ist high — pixelgenau umsetzen: Farben, Radien, Übergänge, Hover-/Focus-States
Drag-Schwellen und Animationsdauern wie im README angegeben. und Animationsdauern wie im README angegeben.
Die visuelle Sprache ist Pinterest Desktop: weißer Hintergrund, 80px-Icon-Rail ohne Die visuelle Sprache ist Pinterest Desktop: weißer Hintergrund, 80px-Icon-Rail ohne
Beschriftung, Suchfeld als Pille, rote Primäraktion `#e60023`, randlose 16px-Karten, Beschriftung, Suchfeld als Pille, rote Primäraktion `#e60023`, randlose 16px-Kacheln,
Masonry im Profil. Hover-Elemente (Merken-Pille, Scrim) hängen am Hover der Karte. Masonry-Raster.
4. Umfang: App-Shell (Sidebar + Kopfzeile mit Tabs) und die vier Screens 4. Umfang: App-Shell (Icon-Rail + Kopfzeile + Tab-Leiste) und die vier Screens
Feed, Profil, Erstellen, Einstellungen. Feed, Profil, Erstellen, Einstellungen — inklusive Detailansicht als Overlay
5. Icons als Lucide-Icons umsetzen (im Prototyp stehen Unicode-Glyphen als Platzhalter). und Auswahlmodus mit Sammelaktion.
5. **Kein Swipen.** Der Feed ist ein Raster, kein Kartenstapel: keine Drag-Geste, keine
Pfeiltasten, kein Undo. „Gemerkt" ist ein Zustand, den ein zweiter Klick zurücknimmt.
6. Karten werden über ihre `id` referenziert (Merken, Ausblenden, Auswahl, Detailansicht),
nicht über einen Index.
7. Hover-Elemente (Merken-Pille, ⋯, Scrim) hängen am Hover der Kachel — `:focus-within`
mitnehmen, sonst sind sie per Tastatur nicht erreichbar.
8. Icons als Lucide-Icons umsetzen (im Prototyp stehen Unicode-Glyphen als Platzhalter).
Medien-Platzhalter (CSS-Verläufe mit Streifen) durch echte Bilder/Videos ersetzen. Medien-Platzhalter (CSS-Verläufe mit Streifen) durch echte Bilder/Videos ersetzen.
Frag nach, bevor du Struktur oder Verhalten abweichend vom README änderst. Frag nach, bevor du Struktur oder Verhalten abweichend vom README änderst.
Die offenen Punkte am Ende des README (Suche, dauerhaftes Ausblenden, Nachladen)
vor der Umsetzung klären.

View File

@@ -1,25 +1,26 @@
# Handoff: Videogen — Web-App (Desktop-Adaption der Mobile-App) # Handoff: Videogen — Web-App (Desktop-Adaption der Mobile-App)
## Überblick ## Überblick
Die bestehende Mobile-App („videogen", Swipe-Feed für Creator-/Produkt-Posts) als Desktop-Web-App. Die bestehende Mobile-App („videogen") als Desktop-Web-App. Statt einer schmalen Handy-Spalte im
Statt einer schmalen Handy-Spalte im Browser: Icon-Rail links, breite Kopfzeile mit Suche, Browser: Icon-Rail links, breite Kopfzeile mit Suche und ein **Masonry-Raster** als Feed.
zentraler Kartenstapel und Detailspalte. Swipen wird durch Maus-Drag, Buttons und Tastatur ersetzt.
**Das Swipen entfällt.** In der Mobile-App wird eine Karte nach der anderen durchgeschoben; auf dem
Desktop liegen stattdessen alle Karten nebeneinander und werden einzeln gemerkt, ausgeblendet oder
geöffnet. Damit fallen Kartenstapel, Maus-Drag, Pfeiltasten, die Exit-Animation und das Undo weg —
„gemerkt" ist jetzt ein Zustand, den ein zweiter Klick zurücknimmt, keine Entscheidung in einer
History.
**Visuelle Sprache: Pinterest Desktop.** Heller Hintergrund, schmale Icon-Leiste ohne Beschriftung, **Visuelle Sprache: Pinterest Desktop.** Heller Hintergrund, schmale Icon-Leiste ohne Beschriftung,
Suchfeld als breite Pille, rote Primäraktion („Merken"), randlose Karten mit 16px-Radius, Suchfeld als breite Pille, rote Primäraktion („Merken"), randlose Karten mit 16px-Radius,
Masonry-Raster im Profil. Referenz ist die Pinterest-Web-App (`de.pinterest.com`); der Screenshot gestaffeltes Raster. Referenz ist die Pinterest-Web-App (`de.pinterest.com`); der Screenshot dafür
dafür liegt bisher nicht im Repo, siehe [Offene Punkte](#offene-punkte). liegt bisher nicht im Repo, siehe [Offene Punkte](#offene-punkte).
**Die Funktionen sind gegenüber dem ersten Entwurf unverändert** — Kartenstapel, Merken/Ablehnen,
Undo, Tabs, die vier Screens und das State-Modell bleiben, wie sie waren. Geändert hat sich
ausschließlich die Gestaltung.
## Zu den Design-Dateien ## Zu den Design-Dateien
Die Dateien in diesem Bundle sind **Design-Referenzen in HTML** — Prototypen, die Aussehen und Die Dateien in diesem Bundle sind **Design-Referenzen in HTML** — Prototypen, die Aussehen und
Verhalten zeigen, **kein Produktionscode zum Kopieren**. Aufgabe ist, diese Designs in der bestehenden Verhalten zeigen, **kein Produktionscode zum Kopieren**. Aufgabe ist, diese Designs in der bestehenden
Umgebung des Ziel-Codebases nachzubauen (React, Vue, Svelte, Next.js …) mit dessen etablierten Patterns Umgebung des Ziel-Codebases nachzubauen (React, Vue, Svelte, Next.js …) mit dessen etablierten Patterns
und Bibliotheken. Existiert noch keine Umgebung, das passendste Framework wählen (Empfehlung: React + und Bibliotheken. Existiert noch keine Umgebung, das passendste Framework wählen (Empfehlung: React +
TypeScript, Motion/Framer Motion für die Kartenanimation) und dort umsetzen. TypeScript) und dort umsetzen.
Die HTML-Datei ist ein „Design Component"-Format: `Videogen Web.dc.html` enthält Template + Logikklasse, Die HTML-Datei ist ein „Design Component"-Format: `Videogen Web.dc.html` enthält Template + Logikklasse,
`support.js` ist die Laufzeit dazu. Zum Ansehen: beide Dateien im selben Ordner belassen und die `support.js` ist die Laufzeit dazu. Zum Ansehen: beide Dateien im selben Ordner belassen und die
@@ -40,7 +41,7 @@ und sollen pixelgenau nachgebaut werden — mit den Komponenten/Utilities des Zi
### 1. Icon-Rail (links) ### 1. Icon-Rail (links)
- Breite `80px`, `flex:none`, `position:sticky; top:0; height:100vh`, `background:#ffffff`, - Breite `80px`, `flex:none`, `position:sticky; top:0; height:100vh`, `background:#ffffff`,
rechte Kante `1px solid #efefef`. Padding `12px 0 16px`, Inhalt zentrierte Flex-Spalte, `gap:8px`. rechte Kante `1px solid #efefef`. Padding `12px 0 16px`, zentrierte Flex-Spalte, `gap:8px`.
- **Brand**: 32×32 Kreis `#e60023` in einem 48px-Slot, weißes „V" 17px/700. - **Brand**: 32×32 Kreis `#e60023` in einem 48px-Slot, weißes „V" 17px/700.
- **Nav-Buttons**: 48×48, `border-radius:50%`, Icon 20px, **ohne Textlabel** — der Name steht im - **Nav-Buttons**: 48×48, `border-radius:50%`, Icon 20px, **ohne Textlabel** — der Name steht im
`title`-Attribut und wird als Tooltip gezeigt. `title`-Attribut und wird als Tooltip gezeigt.
@@ -63,96 +64,96 @@ und sollen pixelgenau nachgebaut werden — mit den Komponenten/Utilities des Zi
### 3. Tab-Leiste ### 3. Tab-Leiste
- `padding:0 24px`, Unterkante `1px solid #efefef`, Tabs mit `gap:24px`, an der Grundlinie ausgerichtet. - `padding:0 24px`, Unterkante `1px solid #efefef`, Tabs mit `gap:24px`, an der Grundlinie ausgerichtet.
- **Tabs**: „Videos", „Folge ich", „Posts". Jeder Tab wechselt den Kartenstapel und springt zurück - **Tabs**: „Videos", „Folge ich", „Posts" — drei Raster. Ein Wechsel springt auf den Feed-Screen
auf den Feed-Screen (Index/History zurücksetzen). und verwirft eine laufende Auswahl.
- `padding:0 2px 12px`, Unterkante `3px solid` — aktiv `#111111`, inaktiv `transparent`. - `padding:0 2px 12px`, Unterkante `3px solid` — aktiv `#111111`, inaktiv `transparent`.
- Aktiv: `#111111`, 700. Inaktiv: `#767676`, 500; Hover → `#111111`. Schriftgröße 16px. - Aktiv: `#111111`, 700. Inaktiv: `#767676`, 500; Hover → `#111111`. Schriftgröße 16px.
- **Rechts**: Tageszähler „**NN** heute gesichtet" (Zahl 700 `#111111` mit `tabular-nums`, - **Rechts**: Zähler „**9 Karten · 4 gemerkt**" (13px `#767676`) und der
Rest 13px `#767676`), Stapelzähler „3 / 5" (13px `#767676`, `tabular-nums`) und runder **Auswählen-Umschalter**: Pille `padding:10px 18px`, `radius:22px`, `font:700 15px`;
Info-Button 36px (transparent, Hover `#e9e9e9`), der das Kürzel-Panel toggelt. aus → `#efefef`/`#111111` mit Beschriftung „Auswählen", an → `#111111`/`#ffffff` mit „Fertig".
--- ---
## Screens ## Screens
### A. Feed (Startansicht) ### A. Feed (Startansicht)
**Zweck:** Karten sichten, merken oder überspringen. **Zweck:** Material sichten, Gutes merken, Uninteressantes ausblenden.
**Layout:** `display:grid; grid-template-columns: minmax(0,1fr) 372px; flex:1; min-height:0; overflow:auto`. - Bereich: `flex:1; min-height:0; overflow:auto; padding:24px 24px 96px`
(unten Platz für die Auswahlleiste).
- **Masonry-Raster**: `columns:5; column-gap:16px` — Spaltenzahl ist als Prop einstellbar (36).
Kachel-Wrapper: `break-inside:avoid; margin-bottom:16px; position:relative`.
#### A1 Kartenbühne (linke Spalte) #### A1 Kachel
- Zentriert, `padding:26px 20px 30px`, `gap:22px`, `position:relative`. - Medienfläche: `position:relative`, **variable Höhe** (Zyklus 320 / 400 / 260 / 340 / 280 / 440 /
- Bühne: `position:relative; width:min(404px,100%); height:560px; flex:none`. 300 / 360 / 240 / 380 px), `border-radius:16px`, `overflow:hidden`, **kein Rahmen**,
- Es werden **bis zu 3 Karten** gestapelt gerendert (konfigurierbar 15). `cursor:zoom-in` (im Auswahlmodus `pointer`).
- Karte k (0 = oben): `transform: translate3d(0, -12px·k, 0) scale(1 - 0.045·k)`, `z-index: 10-k`, - Inhalt von unten nach oben:
`pointer-events` nur auf der obersten. 1. Grundfläche `linear-gradient(155deg, <hue0> 0%, <hue1> 100%)`.
- Karte: `position:absolute; inset:0; border-radius:26px`**`16px`**, `overflow:hidden`, 2. Streifen-Overlay `repeating-linear-gradient(<40+9·i>deg, rgba(255,255,255,.08) 0 2px, transparent 2px 16px)`,
**kein Rahmen**, `box-shadow: 0 1px 20px rgba(0,0,0,.18)`, `opacity:.45`. (Platzhalter für das Medium — im Produkt liegt hier Bild oder Video.)
`touch-action:none; user-select:none; cursor:grab` (beim Ziehen `grabbing`). 3. **Hover-Scrim** `rgba(0,0,0,.24)`, `opacity` 0 → 1 in `.12s`.
- Transition: `transform .34s cubic-bezier(.22,.9,.3,1), opacity .3s ease` — während des aktiven Drags `none`. 4. Fußzeile auf `linear-gradient(0deg, rgba(0,0,0,.62), transparent)`, `padding:14px`:
- **Karteninhalt (von unten nach oben gestapelt):** Caption 14px/700 weiß, darunter „Name · Art" 12px `rgba(255,255,255,.82)`.
1. Grundfläche: `linear-gradient(155deg, <hue0> 0%, <hue1> 100%)` (pro Karte, siehe Daten unten). - **Hover-Steuerung** (beide `opacity` 0 → 1 in `.12s`, gebunden an den Hover der *Kachel*):
2. Streifen-Overlay: `repeating-linear-gradient(<40+9·i>deg, rgba(255,255,255,.08) 0 2px, transparent 2px 16px)`, `opacity:.5`. - **Merken-Pille** oben rechts (`right:12px; top:12px`): `padding:11px 16px; radius:22px;
(Im Prototyp Platzhalter für das Video-/Bild-Asset — im Produkt liegt hier das Medium.) font:700 15px`, ungemerkt `#e60023` mit weißem Text, **gemerkt `#111111` mit „Gemerkt"**.
3. **Hover-Scrim**: `rgba(0,0,0,.24)`, `opacity` 0 → 1 in `.12s`, nur wenn die Karte gehovert wird. Klick schaltet um — ein zweiter Klick nimmt zurück.
4. Lesbarkeits-Verlauf: `linear-gradient(180deg, transparent 46%, rgba(0,0,0,.62) 100%)`. - **⋯-Button** unten rechts (`right:12px; bottom:12px`): 40px Kreis `#ffffff`,
5. **Pills** oben links (`padding:16px`, `gap:8px`): Art („Produkt"/„Video") und Assets („3 Bilder"). `box-shadow: 0 1px 8px rgba(0,0,0,.2)`, Hover `#e9e9e9`.
`padding:6px 12px; border-radius:16px; background: rgba(255,255,255,.94); color:#111111; font:600 13px`. - **⋯-Menü**: öffnet über dem Button (`right:12px; bottom:56px`), 210px, `radius:16px`,
6. **Merken-Pille** oben rechts, **nur auf der obersten Karte und erst beim Hover** `background:#ffffff`, `box-shadow: 0 1px 20px rgba(0,0,0,.22)`, `padding:8px`,
(`opacity` 0 → 1 in `.12s`): `padding:11px 16px; border-radius:22px; background:#e60023; Einblendung `riseIn .14s`. Einträge `padding:12px 14px`, `radius:12px`, 15px/600,
color:#ffffff; font:700 15px`, Hover `#ad081b`. Klick = dieselbe Aktion wie „Merken"/→. Hover `#efefef`: **„Nicht mehr zeigen"** (blendet die Kachel aus) und **„Details öffnen"**.
7. **Caption** mittig, 104px über der Unterkante: `font:600 15px`, `padding:8px 14px`, Ein zweiter Klick auf ⋯ schließt das Menü, `Escape` ebenfalls.
`border-radius:16px`, `background: rgba(255,255,255,.94)`, `color:#111111`. - **Leerzustand** (alles ausgeblendet): zentriert, Titel „Nichts mehr in dieser Ansicht" 20px/700,
8. **Creator-Zeile** unten (`padding:18px`, `gap:12px`, Text weiß): 44px Avatar-Kreis (Farbverlauf, Text 15px `#5f5f5f`, roter Pill-Button „Ausgeblendete wieder anzeigen".
ohne Rand), Name 18px/700 `letter-spacing:-.01em`, Untertitel 14px `rgba(255,255,255,.82)`.
9. **Entscheidungs-Badge** (nur oberste Karte, erscheint ab 40px Drag oder beim Exit):
`top:86px`, links bei „GEMERKT" / rechts bei „SKIP", `padding:10px 18px; border-radius:22px;
font:700 15px; letter-spacing:.08em`, Text immer `#ffffff`, Fläche
`#e60023` (GEMERKT) bzw. `#111111` (SKIP), `rotate(-10deg)` bzw. `rotate(10deg)`,
Opacity `min(1, |dx|/110)`, Transition `.16s`.
- **Leerzustand** (Stapel durch): Fläche `#f5f5f5`, `radius:16px`, Titel „Alles gesichtet" 20px/700,
Text „Für heute sind keine neuen Karten mehr in dieser Ansicht." 15px `#5f5f5f`,
roter Pill-Button „Stapel neu laden" (`#e60023`, Hover `#ad081b`, weiß 15px/700,
`padding:12px 20px; radius:24px`), Einblendung `fadeIn .3s`.
- **Aktions-Buttons** unter der Bühne (`gap:16px`), Hover `scale(1.04)`, Active `scale(.96)`,
Transition `.12s`:
- ✕ Ablehnen — 56px Kreis, `background:#efefef`, Icon `#111111`, Hover `#e2e2e2`.
- ↺ Zurück — 48px Kreis, gleiche Flächen.
- **Merken** — roter Pill statt Kreis: Höhe 56px, `padding:0 30px`, `border-radius:28px`,
`background:#e60023`, `color:#ffffff`, `font:700 17px`, Hover `#ad081b`.
(Icons in der Implementierung: Lucide `x`, `rotate-ccw`; „Merken" trägt Text, kein Icon.)
- **Kürzel-Panel** (Info-Button): absolut `right:26px; top:26px`, 250px, `radius:16px`,
`background:#ffffff`, `box-shadow: 0 1px 20px rgba(0,0,0,.16)`, `padding:16px`,
Einblendung `riseIn .22s`. Inhalt: ← Ablehnen, → Merken, ↑ Zurücknehmen, Maus Karte ziehen.
#### A2 Detailspalte (rechts, 372px) #### A2 Detailansicht (Overlay)
- `border-left:1px solid #efefef`, `background:#ffffff`, `padding:28px`, Ein Klick auf die Kachel öffnet sie — Pinterests Pin-Seite als Overlay.
Flex-Spalte `gap:22px`, `overflow:auto`. - Backdrop: `position:fixed; inset:0; background:rgba(0,0,0,.6)`, Inhalt zentriert,
- Kicker „Aktuelle Karte" 12px/600 `#767676`, Titel = Caption der obersten Karte 28px/700 `padding:32px`, `z-index:60`, Einblendung `fadeIn .16s`. Klick auf den Backdrop schließt.
`letter-spacing:-.02em`, darunter „Name · Untertitel" 15px `#5f5f5f`. - Karte: `width:min(1016px,100%)`, `max-height:calc(100vh - 64px)`, `background:#ffffff`,
- Hairline `1px #efefef`. `border-radius:32px`, `overflow:hidden`, `grid-template-columns:1fr 1fr`, `riseIn .2s`.
- **Statistik-Raster** 3 Spalten, `gap:10px`: Kachel `background:#f5f5f5`, `radius:16px`, - **Schließen**: 40px Kreis `#ffffff` mit `box-shadow: 0 1px 8px rgba(0,0,0,.2)`,
`padding:14px`; Wert 18px/700 `tabular-nums`, Label 12px `#767676`. absolut `left:16px; top:16px` über dem Medium. Zusätzlich `Escape`.
- **Beschreibung**: Kicker + Fließtext 15px, `line-height:1.7`, `#333333`, `text-wrap:pretty`. - **Links**: das Medium, `min-height:520px`, Verlauf + Streifen wie auf der Kachel.
- **Tags**: Pills `padding:8px 14px`, `radius:18px`, `background:#efefef`, 13px/600 `#111111`. - **Rechts**: `padding:32px`, `overflow:auto`, Flex-Spalte `gap:20px`:
- **Aktionen unten** (`margin-top:auto`, `gap:8px`), beide `padding:14px; radius:24px; font:700 15px`: - Aktionszeile rechtsbündig: „Nicht mehr zeigen" (`#efefef`, 48px hoch, `radius:24px`, 15px/700)
„Zur Sammlung hinzufügen" (rot `#e60023`, weiß, Hover `#ad081b`) = gleiche Aktion wie „Merken"; und die Merken-Pille (48px hoch, `padding:0 24px`, `radius:24px`, rot bzw. schwarz wie auf der Kachel).
„Überspringen" (`#efefef`, `#111111`, Hover `#e2e2e2`) = gleiche Aktion wie ✕. - Titel = Caption, 32px/700, `letter-spacing:-.02em`.
- Creator-Zeile: 48px Avatar-Kreis, Name 16px/700, Untertitel 14px `#767676`.
- Beschreibung 16px, `line-height:1.6`, `#333333`.
- Statistik-Raster 3 Spalten, `gap:10px`: Kachel `background:#f5f5f5`, `radius:16px`, `padding:14px`;
Wert 18px/700 `tabular-nums`, Label 12px `#767676`.
- Tags: Pills `padding:8px 14px`, `radius:18px`, `background:#efefef`, 13px/600.
#### A3 Auswahlmodus
Für das Abarbeiten ganzer Strecken. Umschalter sitzt in der Tab-Leiste.
- Im Auswahlmodus **verschwinden Merken-Pille und ⋯** — auf der Kachel zählt nur noch die Auswahl.
- Jede Kachel bekommt oben links (`left:12px; top:12px`) einen **Auswahlkreis** 28px,
`border:2px solid #ffffff`: nicht gewählt `background:rgba(0,0,0,.35)`, Haken unsichtbar;
gewählt `background:#e60023` mit weißem ✓ 15px/700. `pointer-events:none` — geklickt wird die Kachel.
- Gewählte Kachel zusätzlich `outline:4px solid #e60023; outline-offset:-4px`.
- Ein Klick auf die Kachel wählt aus bzw. ab (öffnet **nicht** die Detailansicht).
- **Auswahlleiste**, sobald mindestens eine Kachel gewählt ist: fixiert `bottom:28px`, zentriert,
`padding:12px 12px 12px 22px`, `radius:32px`, `background:#ffffff`,
`box-shadow: 0 1px 20px rgba(0,0,0,.22)`, `riseIn .2s`, `z-index:45`. Inhalt:
„**3 ausgewählt**" 15px/700, roter Button „Zur Sammlung hinzufügen", grauer „Auswahl aufheben"
(beide `padding:12px 20px`, `radius:24px`, 15px/700).
- „Zur Sammlung hinzufügen" merkt alle gewählten Karten, leert die Auswahl und **verlässt den
Auswahlmodus**; Toast „N Karten zur Sammlung hinzugefügt".
- `Escape` verlässt den Auswahlmodus und verwirft die Auswahl.
### B. Profil ### B. Profil
- `padding:32px 24px 40px`, `overflow:auto`, Einblendung `riseIn .26s`. - `padding:32px 24px 40px`, `overflow:auto`, Einblendung `riseIn .26s`.
- Kopf **zentriert** als Spalte, `gap:10px`, `padding-bottom:28px`: 120px Avatar-Kreis - Kopf **zentriert** als Spalte, `gap:10px`, `padding-bottom:28px`: 120px Avatar-Kreis
(`linear-gradient(140deg,#b2323c,#5d1620)`, ohne Rand), Name 36px/700 `letter-spacing:-.02em`, (`linear-gradient(140deg,#b2323c,#5d1620)`, ohne Rand), Name 36px/700 `letter-spacing:-.02em`,
Meta „Studio-Konto · seit März 2026" 15px `#5f5f5f`; darunter drei Kennzahlen nebeneinander Meta „Studio-Konto · seit März 2026" 15px `#5f5f5f`; darunter drei Kennzahlen (`gap:28px`,
(`gap:28px`, zentriert): Posts 38, Gemerkt (live aus dem Feed), Follower 1.204 — zentriert): Posts 38, **Gemerkt (live: Anzahl der gemerkten Karten)**, Follower 1.204 —
Werte 18px/700 `tabular-nums`, Labels 13px `#767676`. Werte 18px/700 `tabular-nums`, Labels 13px `#767676`.
- **Masonry-Raster** statt Gitter: `columns:5; column-gap:16px`. - **Die Galerie zeigt genau die gemerkten Karten** — dasselbe Masonry-Raster wie im Feed,
Kachel: `break-inside:avoid; margin-bottom:16px`, **unterschiedliche Höhen** aber ohne Hover-Steuerung. Ein Klick öffnet die Detailansicht.
(Zyklus 320 / 240 / 400 / 280 / 360 / 220 / 300 / 260 px), `radius:16px`, **kein Rahmen**,
Kartenverlauf + Streifen (`opacity:.45`), `cursor:zoom-in`.
Titel unten auf `linear-gradient(0deg, rgba(0,0,0,.62), transparent)`, 14px/600 weiß, `padding:14px`.
**Hover:** Scrim `rgba(0,0,0,.24)` und rote Merken-Pille oben rechts (`right:12px; top:12px`,
`padding:11px 16px; radius:22px`) blenden mit `.12s` ein.
### C. Erstellen ### C. Erstellen
- `padding:32px 24px 40px`; Inhalt in einer weißen Karte: `max-width:920px`, zentriert, - `padding:32px 24px 40px`; Inhalt in einer weißen Karte: `max-width:920px`, zentriert,
@@ -162,65 +163,62 @@ und sollen pixelgenau nachgebaut werden — mit den Komponenten/Utilities des Zi
- **Dropzone**: Höhe 220px, `border:2px dashed #cdcdcd`, `radius:16px`, `background:#f5f5f5`; - **Dropzone**: Höhe 220px, `border:2px dashed #cdcdcd`, `radius:16px`, `background:#f5f5f5`;
Hover Border `#e60023`, BG `#fafafa`. Hover Border `#e60023`, BG `#fafafa`.
Text „Dateien hierher ziehen" 16px/700 + „MP4, MOV oder bis zu 10 Bilder" 14px `#767676`. Text „Dateien hierher ziehen" 16px/700 + „MP4, MOV oder bis zu 10 Bilder" 14px `#767676`.
- **Felder** Titel und Untertitel: Label 13px/700 `#111111` (keine Versalien, kein `letter-spacing`); - **Felder** Titel und Untertitel: Label 13px/700 `#111111`; Input `padding:14px 16px`,
Input `padding:14px 16px`, `radius:16px`, `border:2px solid #cdcdcd`, `background:#ffffff`, `radius:16px`, `border:2px solid #cdcdcd`, `background:#ffffff`, 15px.
Text `#111111`, 15px. Beide sind an die Live-Vorschau gebunden. Beide sind an die Live-Vorschau gebunden.
- **Art**: Pills „Produkt / Video / Serie", `padding:11px 18px`, `radius:22px`, `font:700 15px`; - **Art**: Pills „Produkt / Video / Serie", `padding:11px 18px`, `radius:22px`, `font:700 15px`;
aktiv `background:#111111`, Text `#ffffff`; inaktiv `background:#efefef`, Text `#111111`. aktiv `background:#111111`, Text `#ffffff`; inaktiv `background:#efefef`, Text `#111111`.
- **Aktionen**: „Veröffentlichen" (rot `#e60023`, weiß, löst Toast „Post veröffentlicht" aus), - **Aktionen**: „Veröffentlichen" (rot `#e60023`, löst Toast „Post veröffentlicht" aus),
„Als Entwurf sichern" (`#efefef`), beide `padding:14px 24px`, `radius:24px`, `font:700 15px`. „Als Entwurf sichern" (`#efefef`), beide `padding:14px 24px`, `radius:24px`, `font:700 15px`.
- **Vorschau** (rechte Spalte): Kicker 12px/600 `#767676` + Karte 420px hoch, `radius:16px`, - **Vorschau** (rechte Spalte): Kicker 12px/600 `#767676` + Karte 420px hoch, `radius:16px`,
`linear-gradient(155deg,#1f8163,#0d4034)` + `repeating-linear-gradient(48deg, rgba(255,255,255,.07) 0 2px, transparent 2px 15px)`, Verlauf + Streifen, unten Titel 18px/700 und Untertitel 14px auf schwarzem Verlauf —
unten Titel 18px/700 und Untertitel 14px auf schwarzem Verlauf — spiegelt die Eingaben live. spiegelt die Eingaben live.
### D. Einstellungen ### D. Einstellungen
- `padding:32px 24px 40px`, Inhalt `max-width:660px`, zentriert. Titel „Einstellungen" 30px/700. - `padding:32px 24px 40px`, Inhalt `max-width:660px`, zentriert. Titel „Einstellungen" 30px/700.
- Liste: Container `radius:16px`, `overflow:hidden`, `box-shadow: 0 1px 20px rgba(0,0,0,.12)`, - Liste: Container `radius:16px`, `overflow:hidden`, `box-shadow: 0 1px 20px rgba(0,0,0,.12)`,
**kein Rahmen**. Zeile `padding:20px`, `background:#ffffff`, Trenner `1px solid #efefef`; **kein Rahmen**. Zeile `padding:20px`, `background:#ffffff`, Trenner `1px solid #efefef`;
Label 16px/700, Hinweis 14px `#767676`. Label 16px/700, Hinweis 14px `#767676`.
- Toggle: Track 52×30, `radius:999px`, kein Rahmen; aus `background:#cdcdcd`, - Toggle: Track 52×30, `radius:999px`, kein Rahmen; aus `#cdcdcd`, an `#e60023`.
an → `background:#e60023`. Knopf 24px, immer `#ffffff` mit `box-shadow: 0 1px 3px rgba(0,0,0,.25)`, Knopf 24px, immer `#ffffff` mit `box-shadow: 0 1px 3px rgba(0,0,0,.25)`, links 3px → 25px,
links 3px → 25px, Transition `left .2s cubic-bezier(.22,.9,.3,1)`. Transition `left .2s cubic-bezier(.22,.9,.3,1)`.
- Einträge: Videos automatisch abspielen (an), Ton beim Öffnen (aus), Wochenrückblick (an), Bewegte Übergänge (an). - Einträge: Videos automatisch abspielen (an), Ton beim Öffnen (aus), Wochenrückblick (an),
Bewegte Übergänge (an).
--- ---
## Interaktion & Verhalten ## Interaktion & Verhalten
**Karte entscheiden — vier gleichwertige Wege:** **Merken** — zwei Wege, beide schalten um (zweiter Klick nimmt zurück):
1. **Maus-Drag**: Pointer Events auf der obersten Karte (`pointerdown``setPointerCapture`, 1. Merken-Pille auf der Kachel (erscheint beim Hover).
`pointermove` Delta, `pointerup`/`pointercancel` → Auswertung). 2. Merken-Button in der Detailansicht.
Live-Transform: `translate3d(dx, dy, 0) rotate(dx/22 deg)`, Transition währenddessen aus. Toast: „Zu <Name>' gemerkt" bzw. „Aus der Sammlung entfernt".
Schwelle **±120px**: `dx > 120` = merken, `dx < -120` = ablehnen, sonst Rücksprung (Transition wieder an).
2. **Buttons** ✕ / ↺ / „Merken" unter der Bühne (und die beiden Buttons in der Detailspalte).
3. **Merken-Pille auf der Karte** (erscheint beim Hover, oben rechts) — identisch mit „merken".
4. **Tastatur** (global, nur auf dem Feed-Screen, mit `preventDefault`):
`ArrowLeft` = ablehnen, `ArrowRight` = merken, `ArrowUp` = zurücknehmen.
**Hover-Zustände:** Scrim und Merken-Pille hängen am Hover der *Karte*, nicht am eigenen Element **Ausblenden** — „Nicht mehr zeigen" im ⋯-Menü oder in der Detailansicht. Die Kachel verschwindet
(im Prototyp `.pin:hover .pin-save` / `.pin:hover .pin-scrim`, zusätzlich `:focus-within` für aus dem Raster, eine offene Detailansicht schließt sich, Toast „Wird nicht mehr gezeigt".
Tastaturbedienung). Im Ziel-Framework als Hover-State der Kartenkomponente umsetzen —
und den Fokusfall nicht vergessen, sonst ist die Pille per Tastatur nicht erreichbar.
**Exit-Animation:** `transform: translate3d(±620px, -40px, 0) rotate(±22deg)`, `opacity: 0`, **Öffnen** — Klick auf die Kachel oder „Details öffnen" im ⋯-Menü.
Dauer **320ms**, danach Index +1 und `exiting` zurücksetzen. Während `exiting` sind weitere
Entscheidungen gesperrt.
**Undo:** letzte Entscheidung aus der History, Index zurück, „Gemerkt"-Zähler ggf. 1. **Mehrfachauswahl** — siehe [A3](#a3-auswahlmodus).
Leere History → Toast „Nichts zurückzunehmen".
**Toast:** fixiert `bottom:28px`, horizontal zentriert, `padding:16px 22px`, `radius:16px`, **Tastatur:** `Escape` schließt der Reihe nach: offene Detailansicht → offenes ⋯-Menü →
`background:#ffffff`, `box-shadow: 0 1px 20px rgba(0,0,0,.22)`, `color:#111111`, 15px/600, Auswahlmodus. Weitere Kürzel gibt es nicht mehr; die Pfeiltasten sind mit dem Swipen entfallen.
Einblendung `riseIn .2s`, verschwindet nach **1700ms**. Texte: „Zu <Name>' gemerkt",
„Übersprungen", „Nichts zurückzunehmen", „Post veröffentlicht". **Hover-Zustände:** Scrim, Merken-Pille und ⋯ hängen am Hover der *Kachel*, nicht am eigenen Element
(im Prototyp `.pin:hover .pin-hover`, zusätzlich `:focus-within` für Tastaturbedienung).
Im Ziel-Framework als Hover-State der Kartenkomponente umsetzen — und den Fokusfall nicht vergessen,
sonst sind Merken und ⋯ per Tastatur nicht erreichbar.
**Toast:** fixiert, horizontal zentriert, `bottom:28px` — bei sichtbarer Auswahlleiste `96px`,
damit beide nicht übereinanderliegen. `padding:16px 22px`, `radius:16px`, `background:#ffffff`,
`box-shadow: 0 1px 20px rgba(0,0,0,.22)`, 15px/600, `riseIn .2s`, verschwindet nach **1700ms**.
**Keyframes:** **Keyframes:**
- `riseIn`: `opacity:0; translateY(14px)``opacity:1; none` (.2.26s ease) - `riseIn`: `opacity:0; translateY(14px)` → `opacity:1; none` (.14.26s ease)
- `fadeIn`: `opacity:0` → `1` - `fadeIn`: `opacity:0` → `1`
- Fokus: `:focus-visible { outline: 2px solid #0074e8; outline-offset: 2px; }` — nie der Browser-Default. - Fokus: `:focus-visible { outline: 2px solid #0074e8; outline-offset: 2px; }` — nie der Browser-Default.
**Responsive:** Desktop-only, `min-width:1180px`; darunter horizontal scrollen. **Responsive:** Desktop-only, `min-width:1180px`; darunter horizontal scrollen.
Der Feed-Bereich scrollt bei geringer Fensterhöhe (`min-height:0; overflow:auto`).
--- ---
@@ -229,21 +227,22 @@ Der Feed-Bereich scrollt bei geringer Fensterhöhe (`min-height:0; overflow:auto
| State | Typ | Zweck | | State | Typ | Zweck |
|---|---|---| |---|---|---|
| `screen` | `'feed' \| 'profile' \| 'create' \| 'settings'` | aktive Ansicht (Icon-Rail) | | `screen` | `'feed' \| 'profile' \| 'create' \| 'settings'` | aktive Ansicht (Icon-Rail) |
| `tab` | `'videos' \| 'following' \| 'posts'` | aktiver Stapel; Wechsel setzt `idx`/`history` zurück und springt auf `feed` | | `tab` | `'videos' \| 'following' \| 'posts'` | aktives Raster; Wechsel verwirft Auswahl und Menü |
| `idx` | number | Index der obersten Karte im aktuellen Stapel | | `saved` | `string[]` (Karten-IDs) | gemerkte Karten — speist Profil-Galerie und Kennzahl |
| `drag` | `{x, y, active} \| null` | laufende Drag-Geste | | `hidden` | `string[]` (Karten-IDs) | ausgeblendete Karten, aus dem Raster gefiltert |
| `exiting` | `'left' \| 'right' \| null` | laufende Exit-Animation (sperrt Eingaben) | | `selectMode` | boolean | Auswahlmodus aktiv |
| `history` | `{idx, dir}[]` | für Undo | | `selection` | `string[]` (Karten-IDs) | aktuell gewählte Kacheln |
| `seen`, `saved` | number | Zähler Tab-Leiste / Profil | | `detail` | `string \| null` | ID der offenen Detailansicht |
| `showInfo` | boolean | Kürzel-Panel | | `menuFor` | `string \| null` | ID der Kachel mit offenem ⋯-Menü |
| `toast` | string | Toast-Text (leer = versteckt) | | `toast` | string | Toast-Text (leer = versteckt) |
| `draftTitle`, `draftSub`, `draftKind` | string | Formular „Erstellen", live in der Vorschau | | `draftTitle`, `draftSub`, `draftKind` | string | Formular „Erstellen", live in der Vorschau |
| `settings` | `{autoplay, sound, digest, motion}` booleans | Einstellungen | | `settings` | `{autoplay, sound, digest, motion}` booleans | Einstellungen |
**Datenbedarf pro Karte:** `name`, `sub`, `kind`, `assets`, `caption`, `hue: [from, to]`, **Datenbedarf pro Karte:** `id`, `name`, `sub`, `kind`, `assets`, `caption`, `hue: [from, to]`,
`avatar` (Verlauf oder Bild-URL), `body`, `tags: string[]`, `stats: [wert, label][]`. `avatar` (Verlauf oder Bild-URL), `body`, `tags: string[]`, `stats: [wert, label][]`.
Im Produkt ersetzt das Medium (Bild/Video) die Verlaufs-/Streifenfläche; alles andere bleibt. Die `id` ist neu und trägt jetzt alles — Merken, Ausblenden, Auswahl und Detailansicht referenzieren
Die drei Tabs liefern drei Stapel (Videos, Folge ich, Posts). Karten darüber, nicht mehr über einen Index. Im Produkt ersetzt das Medium (Bild/Video) die
Verlaufs-/Streifenfläche. Die drei Tabs liefern drei Raster.
--- ---
@@ -254,29 +253,28 @@ Die drei Tabs liefern drei Stapel (Videos, Folge ich, Posts).
|---|---| |---|---|
| Seite | `#ffffff` | | Seite | `#ffffff` |
| Text | `#111111` | | Text | `#111111` |
| Text gedämpft | `#5f5f5f` (Fließtext sekundär), `#767676` (Labels, inaktive Tabs), `#333333` (Beschreibung) | | Text gedämpft | `#5f5f5f` (sekundär), `#767676` (Labels, inaktive Tabs), `#333333` (Beschreibung) |
| Akzent (Rot) | `#e60023`, Hover `#ad081b` | | Akzent (Rot) | `#e60023`, Hover `#ad081b`; auch Auswahl-Outline und Auswahlkreis |
| Dunkel | `#111111` (aktiver Rail-Button, aktive Art-Pille, SKIP-Badge), Hover `#000000` | | Dunkel | `#111111` (aktiver Rail-Button, aktive Art-Pille, **gemerkt**-Zustand, „Fertig"), Hover `#000000` |
| Neutrale Flächen | `#efefef` (Suchfeld, Buttons, Tags), Hover `#e2e2e2`; `#e9e9e9` (Rail-Hover); `#f5f5f5` (Kacheln, Dropzone, Leerzustand) | | Neutrale Flächen | `#efefef` (Suchfeld, Buttons, Tags), Hover `#e2e2e2`; `#e9e9e9` (Rail-/⋯-Hover); `#f5f5f5` (Statistik-Kacheln, Dropzone) |
| Linien | `#efefef` (Trenner, Kanten), `#cdcdcd` (Eingabe-Rahmen, Toggle aus) | | Linien | `#efefef` (Trenner, Kanten), `#cdcdcd` (Eingabe-Rahmen, Toggle aus) |
| Auf Medien | `rgba(255,255,255,.94)` Pills, `rgba(255,255,255,.82)` Untertitel, `rgba(0,0,0,.24)` Hover-Scrim, `rgba(0,0,0,.62)` Lesbarkeitsverlauf | | Auf Medien | `rgba(255,255,255,.82)` Untertitel, `rgba(0,0,0,.24)` Hover-Scrim, `rgba(0,0,0,.62)` Fußzeilen-Verlauf, `rgba(0,0,0,.35)` Auswahlkreis leer |
| Overlay | `rgba(0,0,0,.6)` Backdrop der Detailansicht |
| Fokus | `#0074e8` | | Fokus | `#0074e8` |
| Karten-Verläufe | grün `#1f8163#0d4034`, blau `#256f8b#0c2f3f`, ocker `#8a6a2f#3a2a11`, rot `#7a2f38#2c1014`, tannen `#2f6f5a#0e332b`, türkis `#1c6f7f#0a2c33`, violett `#6a2f7a#26102c`, gold `#7a5a2a#2f2110` | | Karten-Verläufe | grün `#1f8163#0d4034`, blau `#256f8b#0c2f3f`, ocker `#8a6a2f#3a2a11`, rot `#7a2f38#2c1014`, tannen `#2f6f5a#0e332b`, türkis `#1c6f7f#0a2c33`, violett `#6a2f7a#26102c`, gold `#7a5a2a#2f2110` |
**Typografie****nur Manrope** 400/500/600/700. JetBrains Mono entfällt; Zahlen laufen in Manrope **Typografie** — **nur Manrope** 400/500/600/700. Zahlen mit `font-variant-numeric: tabular-nums`.
mit `font-variant-numeric: tabular-nums`. Skala: 36 / 32 / 30 / 20 / 18 / 17 / 16 / 15 / 14 / 13 / 12 px.
Skala: 36 / 30 / 28 / 20 / 18 / 17 / 16 / 15 / 14 / 13 / 12 px. Große Überschriften `letter-spacing:-.02em`. Kicker sind 12px/600 in `#767676`, normal geschrieben.
Große Überschriften `letter-spacing:-.02em`. Keine Versalien-Kicker mehr — Kicker sind
12px/600 in `#767676`, normal geschrieben.
**Abstände** 2 / 4 / 6 / 8 / 10 / 12 / 14 / 16 / 18 / 20 / 22 / 24 / 26 / 28 / 30 / 32 / 40 px. **Abstände** 2 / 4 / 6 / 8 / 10 / 12 / 14 / 16 / 18 / 20 / 22 / 24 / 28 / 32 / 40 / 56 / 96 px.
**Radien** 16px (Karten, Kacheln, Eingaben, Pills auf Medien, Toast, Panel), **Radien** 12px (Menüeinträge), 16px (Kacheln, Eingaben, Toast, Menü), 18 / 22 / 24px (Pillen),
18 / 22 / 24 / 28px (Tag-, Art-, Button-Pillen), `50%` Kreise, `999px` Toggle-Track. 32px (Detailansicht, Auswahlleiste), `50%` Kreise, `999px` Toggle-Track.
**Schatten** `0 1px 20px rgba(0,0,0,.12)` Flächen/Listen · `.16` Kürzel-Panel · `.18` Karte · **Schatten** `0 1px 8px rgba(0,0,0,.2)` schwebende Rundbuttons · `0 1px 20px rgba(0,0,0,.12)`
`.22` Toast · `0 1px 3px rgba(0,0,0,.25)` Toggle-Knopf. Flächen/Listen · `.22` Menü, Auswahlleiste, Toast · `0 1px 3px rgba(0,0,0,.25)` Toggle-Knopf.
**Kein `backdrop-filter`** — die Pinterest-Oberfläche ist deckend. **Kein `backdrop-filter`** — die Oberfläche ist deckend.
**Transitions** `.12s ease` (Hover, Farbwechsel, Ein-/Ausblenden), `.16s` (Badge), **Transitions** `.12s ease` (Hover, Farbwechsel, Ein-/Ausblenden), `.2s` (Toggle).
`.2s` (Toggle), Karten-Bewegung `.34s cubic-bezier(.22,.9,.3,1)`, Exit `320ms`. Karten- und Exit-Animationen des alten Stapels entfallen ersatzlos.
--- ---
@@ -289,6 +287,11 @@ Platzhalter). Fonts: Google Fonts — **nur Manrope**.
- **Suchfeld ohne Funktion.** Die Pinterest-Kopfzeile lebt von der Suche, im Prototyp ist sie eine - **Suchfeld ohne Funktion.** Die Pinterest-Kopfzeile lebt von der Suche, im Prototyp ist sie eine
reine Attrappe — es gibt bisher keine Suchfunktion in der App. Vor der Umsetzung klären, ob Suche reine Attrappe — es gibt bisher keine Suchfunktion in der App. Vor der Umsetzung klären, ob Suche
gebaut wird; wenn nicht, gehört das Feld raus, statt eine Funktion vorzutäuschen. gebaut wird; wenn nicht, gehört das Feld raus, statt eine Funktion vorzutäuschen.
- **Ausblenden ist im Prototyp flüchtig.** `hidden` lebt nur im Zustand der Seite. Im Produkt muss
entschieden werden, ob „Nicht mehr zeigen" dauerhaft pro Konto gilt und ob es einen Weg zurück
gibt (im Prototyp nur über den Leerzustand).
- **Nachladen fehlt.** Das Raster zeigt einen endlichen Satz. Für echte Datenmengen braucht es
Infinite Scroll oder Paginierung samt Ladeplatzhaltern — beides ist hier nicht gestaltet.
- **Pinterest-Referenz liegt nicht im Repo.** Die Vorlage kam als Screenshot im Chat. Wer sie - **Pinterest-Referenz liegt nicht im Repo.** Die Vorlage kam als Screenshot im Chat. Wer sie
dauerhaft braucht, legt sie als `referenz/pinterest-desktop.png` daneben. dauerhaft braucht, legt sie als `referenz/pinterest-desktop.png` daneben.

View File

@@ -19,10 +19,13 @@
button:focus-visible, [tabindex]:focus-visible, input:focus-visible { outline:2px solid #0074e8; outline-offset:2px; } button:focus-visible, [tabindex]:focus-visible, input:focus-visible { outline:2px solid #0074e8; outline-offset:2px; }
@keyframes riseIn { from { opacity:0; transform:translateY(14px);} to { opacity:1; transform:none;} } @keyframes riseIn { from { opacity:0; transform:translateY(14px);} to { opacity:1; transform:none;} }
@keyframes fadeIn { from { opacity:0 } to { opacity:1 } } @keyframes fadeIn { from { opacity:0 } to { opacity:1 } }
.pin-save { opacity:0; transition:opacity .12s ease; } /* Hover-Steuerung haengt an der Kachel, nicht am Element selbst. */
.pin:hover .pin-save, .pin:focus-within .pin-save { opacity:1; } .pin-hover { opacity:0; transition:opacity .12s ease; }
.pin:hover .pin-hover, .pin:focus-within .pin-hover { opacity:1; }
.pin-scrim { opacity:0; transition:opacity .12s ease; } .pin-scrim { opacity:0; transition:opacity .12s ease; }
.pin:hover .pin-scrim { opacity:1; } .pin:hover .pin-scrim { opacity:1; }
/* Rail-Zustaende als Klassen: das Runtime setzt style-hover mit !important
und wuerde ein inline gesetztes Aktiv-Schwarz beim Hover ueberschreiben. */
.rail { background:transparent; color:#111111; } .rail { background:transparent; color:#111111; }
.rail:hover { background:#e9e9e9; } .rail:hover { background:#e9e9e9; }
.rail.active { background:#111111; color:#ffffff; } .rail.active { background:#111111; color:#ffffff; }
@@ -68,115 +71,51 @@
<button onClick="{{ t.go }}" style="{{ t.style }}" style-hover="color:#111111;">{{ t.label }}</button> <button onClick="{{ t.go }}" style="{{ t.style }}" style-hover="color:#111111;">{{ t.label }}</button>
</sc-for> </sc-for>
</div> </div>
<div style="display:flex; align-items:center; gap:14px; padding-bottom:10px;"> <div style="display:flex; align-items:center; gap:12px; padding-bottom:8px;">
<div style="font-size:13px; color:#767676;"><span style="font-weight:700; color:#111111; font-variant-numeric:tabular-nums;">{{ swipesToday }}</span> heute gesichtet</div> <div style="font-size:13px; color:#767676;">{{ counter }}</div>
<div style="font-size:13px; color:#767676; font-variant-numeric:tabular-nums;">{{ counter }}</div> <button onClick="{{ toggleSelectMode }}" style="{{ selectToggleStyle }}">{{ selectToggleLabel }}</button>
<button onClick="{{ toggleInfo }}" title="Kürzel anzeigen" style="width:36px; height:36px; border:none; border-radius:50%; background:transparent; color:#111111; font-size:14px; font-weight:700; cursor:pointer; transition:background .12s ease;" style-hover="background:#e9e9e9;">i</button>
</div> </div>
</div> </div>
<sc-if value="{{ isFeed }}" hint-placeholder-val="{{ true }}"> <sc-if value="{{ isFeed }}" hint-placeholder-val="{{ true }}">
<section style="flex:1; min-height:0; overflow:auto; display:grid; grid-template-columns:minmax(0,1fr) 372px; gap:0;"> <section style="flex:1; min-height:0; overflow:auto; padding:24px 24px 96px;">
<div style="{{ gridStyle }}">
<div style="position:relative; display:flex; flex-direction:column; align-items:center; justify-content:center; padding:26px 20px 30px; gap:22px; min-width:0; min-height:min-content;"> <sc-for list="{{ grid }}" as="c" hint-placeholder-count="10">
<div style="position:relative; width:min(404px,100%); height:560px; flex:none;"> <div class="pin" style="position:relative; break-inside:avoid; margin-bottom:16px;">
<sc-for list="{{ stack }}" as="c" hint-placeholder-count="3"> <div onClick="{{ c.activate }}" style="{{ c.media }}">
<div class="pin" style="{{ c.shell }}" onPointerDown="{{ c.down }}" onPointerMove="{{ c.move }}" onPointerUp="{{ c.up }}" onPointerCancel="{{ c.up }}"> <div style="position:absolute; inset:0; background:{{ c.stripes }}; opacity:.45;"></div>
<div style="{{ c.media }}">
<div style="position:absolute; inset:0; background:{{ c.stripes }}; opacity:.5;"></div>
<div class="pin-scrim" style="position:absolute; inset:0; background:rgba(0,0,0,.24);"></div> <div class="pin-scrim" style="position:absolute; inset:0; background:rgba(0,0,0,.24);"></div>
<div style="position:absolute; inset:0; background:linear-gradient(180deg, rgba(0,0,0,0) 46%, rgba(0,0,0,.62) 100%);"></div> <div style="position:absolute; inset:auto 0 0 0; padding:14px; background:linear-gradient(0deg, rgba(0,0,0,.62), transparent); color:#ffffff;">
<div style="font-size:14px; font-weight:700; text-wrap:pretty;">{{ c.caption }}</div>
<div style="position:relative; display:flex; align-items:flex-start; justify-content:space-between; gap:9px; padding:16px;"> <div style="font-size:12px; color:rgba(255,255,255,.82); margin-top:2px;">{{ c.name }} · {{ c.kind }}</div>
<div style="display:flex; gap:8px;">
<span style="padding:6px 12px; border-radius:16px; background:rgba(255,255,255,.94); color:#111111; font-size:13px; font-weight:600;">{{ c.kind }}</span>
<span style="padding:6px 12px; border-radius:16px; background:rgba(255,255,255,.94); color:#111111; font-size:13px; font-weight:600;">{{ c.assets }}</span>
</div> </div>
<button class="pin-save" onClick="{{ onLike }}" style="{{ c.save }}" style-hover="background:#ad081b;">Merken</button> <div style="{{ c.checkbox }}"></div>
</div> </div>
<div style="position:absolute; inset:auto 0 104px 0; display:flex; justify-content:center; padding:0 22px;"> <div class="pin-hover" style="{{ c.saveSlot }}">
<div style="font-size:15px; font-weight:600; padding:8px 14px; border-radius:16px; background:rgba(255,255,255,.94); color:#111111; text-align:center; text-wrap:pretty;">{{ c.caption }}</div> <button onClick="{{ c.toggleSave }}" style="{{ c.saveStyle }}" style-hover="filter:brightness(.88);">{{ c.saveLabel }}</button>
</div>
<div class="pin-hover" style="{{ c.menuSlot }}">
<button onClick="{{ c.openMenu }}" title="Weitere Optionen" style="{{ c.menuBtn }}" style-hover="background:#e9e9e9;"></button>
</div> </div>
<div style="position:absolute; inset:auto 0 0 0; display:flex; align-items:center; gap:12px; padding:18px; color:#ffffff;"> <sc-if value="{{ c.menuOpen }}" hint-placeholder-val="{{ false }}">
<div style="width:44px; height:44px; border-radius:50%; flex:none; background:{{ c.avatar }};"></div> <div style="position:absolute; right:12px; bottom:56px; width:210px; border-radius:16px; background:#ffffff; box-shadow:0 1px 20px rgba(0,0,0,.22); padding:8px; z-index:20; animation:riseIn .14s ease;">
<div style="min-width:0;"> <button onClick="{{ c.hide }}" style="width:100%; text-align:left; padding:12px 14px; border:none; border-radius:12px; background:transparent; color:#111111; font-family:inherit; font-size:15px; font-weight:600; cursor:pointer;" style-hover="background:#efefef;">Nicht mehr zeigen</button>
<div style="font-size:18px; font-weight:700; letter-spacing:-.01em;">{{ c.name }}</div> <button onClick="{{ c.open }}" style="width:100%; text-align:left; padding:12px 14px; border:none; border-radius:12px; background:transparent; color:#111111; font-family:inherit; font-size:15px; font-weight:600; cursor:pointer;" style-hover="background:#efefef;">Details öffnen</button>
<div style="font-size:14px; color:rgba(255,255,255,.82);">{{ c.sub }}</div>
</div>
</div>
<div style="{{ c.badge }}">{{ c.badgeText }}</div>
</div>
</div>
</sc-for>
<sc-if value="{{ stackEmpty }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute; inset:0; border-radius:16px; background:#f5f5f5; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:14px; text-align:center; padding:40px; animation:fadeIn .3s ease;">
<div style="font-size:20px; font-weight:700;">Alles gesichtet</div>
<div style="font-size:15px; color:#5f5f5f; max-width:240px; text-wrap:pretty;">Für heute sind keine neuen Karten mehr in dieser Ansicht.</div>
<button onClick="{{ reset }}" style="padding:12px 20px; border:none; border-radius:24px; background:#e60023; color:#ffffff; font-family:inherit; font-size:15px; font-weight:700; cursor:pointer; transition:background .12s ease;" style-hover="background:#ad081b;">Stapel neu laden</button>
</div> </div>
</sc-if> </sc-if>
</div> </div>
</sc-for>
<div style="display:flex; align-items:center; gap:16px;">
<button onClick="{{ onNope }}" title="Ablehnen — Pfeil links" style="width:56px; height:56px; border:none; border-radius:50%; background:#efefef; color:#111111; font-size:20px; cursor:pointer; display:grid; place-items:center; transition:background .12s ease, transform .12s ease;" style-hover="background:#e2e2e2; transform:scale(1.04);" style-active="transform:scale(.96);"></button>
<button onClick="{{ onUndo }}" title="Zurück — Pfeil hoch" style="width:48px; height:48px; border:none; border-radius:50%; background:#efefef; color:#111111; font-size:17px; cursor:pointer; display:grid; place-items:center; transition:background .12s ease, transform .12s ease;" style-hover="background:#e2e2e2; transform:scale(1.04);" style-active="transform:scale(.96);"></button>
<button onClick="{{ onLike }}" title="Merken — Pfeil rechts" style="height:56px; padding:0 30px; border:none; border-radius:28px; background:#e60023; color:#ffffff; font-family:inherit; font-size:17px; font-weight:700; cursor:pointer; display:grid; place-items:center; transition:background .12s ease, transform .12s ease;" style-hover="background:#ad081b; transform:scale(1.04);" style-active="transform:scale(.96);">Merken</button>
</div> </div>
<sc-if value="{{ showInfo }}" hint-placeholder-val="{{ false }}"> <sc-if value="{{ gridEmpty }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute; right:26px; top:26px; width:250px; border-radius:16px; background:#ffffff; box-shadow:0 1px 20px rgba(0,0,0,.16); padding:16px; font-size:14px; line-height:1.7; animation:riseIn .22s ease;"> <div style="max-width:420px; margin:80px auto; text-align:center; display:flex; flex-direction:column; align-items:center; gap:14px; animation:fadeIn .3s ease;">
<div style="font-weight:700; margin-bottom:8px;">Bedienung</div> <div style="font-size:20px; font-weight:700;">Nichts mehr in dieser Ansicht</div>
<div style="display:grid; grid-template-columns:auto 1fr; gap:6px 12px; color:#5f5f5f;"> <div style="font-size:15px; color:#5f5f5f; text-wrap:pretty;">Alle Karten dieses Tabs sind ausgeblendet.</div>
<span style="font-weight:600; color:#111111;"></span><span>Ablehnen</span> <button onClick="{{ unhideAll }}" style="padding:12px 20px; border:none; border-radius:24px; background:#e60023; color:#ffffff; font-family:inherit; font-size:15px; font-weight:700; cursor:pointer;" style-hover="background:#ad081b;">Ausgeblendete wieder anzeigen</button>
<span style="font-weight:600; color:#111111;"></span><span>Merken</span>
<span style="font-weight:600; color:#111111;"></span><span>Zurücknehmen</span>
<span style="font-weight:600; color:#111111;">Maus</span><span>Karte ziehen</span>
</div>
</div> </div>
</sc-if> </sc-if>
</div>
<aside style="border-left:1px solid #efefef; background:#ffffff; padding:28px; display:flex; flex-direction:column; gap:22px; overflow:auto;">
<div>
<div style="font-size:12px; font-weight:600; color:#767676;">Aktuelle Karte</div>
<div style="font-size:28px; font-weight:700; letter-spacing:-.02em; margin-top:8px; text-wrap:pretty;">{{ detailTitle }}</div>
<div style="font-size:15px; color:#5f5f5f; margin-top:6px;">{{ detailSub }}</div>
</div>
<div style="height:1px; background:#efefef;"></div>
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:10px;">
<sc-for list="{{ detailStats }}" as="s" hint-placeholder-count="3">
<div style="border-radius:16px; background:#f5f5f5; padding:14px;">
<div style="font-size:18px; font-weight:700; font-variant-numeric:tabular-nums;">{{ s.value }}</div>
<div style="font-size:12px; color:#767676; margin-top:3px;">{{ s.label }}</div>
</div>
</sc-for>
</div>
<div>
<div style="font-size:12px; font-weight:600; color:#767676; margin-bottom:8px;">Beschreibung</div>
<p style="margin:0; font-size:15px; line-height:1.7; color:#333333; text-wrap:pretty;">{{ detailBody }}</p>
</div>
<div>
<div style="font-size:12px; font-weight:600; color:#767676; margin-bottom:10px;">Tags</div>
<div style="display:flex; flex-wrap:wrap; gap:8px;">
<sc-for list="{{ detailTags }}" as="tag" hint-placeholder-count="4">
<span style="padding:8px 14px; border-radius:18px; background:#efefef; font-size:13px; font-weight:600; color:#111111;">{{ tag }}</span>
</sc-for>
</div>
</div>
<div style="margin-top:auto; display:flex; flex-direction:column; gap:8px;">
<button onClick="{{ onLike }}" style="padding:14px; border:none; border-radius:24px; background:#e60023; color:#ffffff; font-family:inherit; font-size:15px; font-weight:700; cursor:pointer; transition:background .12s ease;" style-hover="background:#ad081b;">Zur Sammlung hinzufügen</button>
<button onClick="{{ onNope }}" style="padding:14px; border:none; border-radius:24px; background:#efefef; color:#111111; font-family:inherit; font-size:15px; font-weight:700; cursor:pointer; transition:background .12s ease;" style-hover="background:#e2e2e2;">Überspringen</button>
</div>
</aside>
</section> </section>
</sc-if> </sc-if>
@@ -195,15 +134,14 @@
</sc-for> </sc-for>
</div> </div>
</div> </div>
<div style="columns:5; column-gap:16px;"> <div style="{{ gridStyle }}">
<sc-for list="{{ gallery }}" as="g" hint-placeholder-count="8"> <sc-for list="{{ gallery }}" as="g" hint-placeholder-count="8">
<div class="pin" style="{{ g.tile }}"> <div class="pin" style="position:relative; break-inside:avoid; margin-bottom:16px;">
<div onClick="{{ g.open }}" style="{{ g.media }}">
<div style="position:absolute; inset:0; background:{{ g.stripes }}; opacity:.45;"></div> <div style="position:absolute; inset:0; background:{{ g.stripes }}; opacity:.45;"></div>
<div class="pin-scrim" style="position:absolute; inset:0; background:rgba(0,0,0,.24);"></div> <div class="pin-scrim" style="position:absolute; inset:0; background:rgba(0,0,0,.24);"></div>
<div style="position:absolute; right:12px; top:12px;"> <div style="position:absolute; inset:auto 0 0 0; padding:14px; background:linear-gradient(0deg, rgba(0,0,0,.62), transparent); color:#ffffff; font-size:14px; font-weight:600;">{{ g.caption }}</div>
<button class="pin-save" onClick="{{ onLike }}" style="padding:11px 16px; border:none; border-radius:22px; background:#e60023; color:#ffffff; font-family:inherit; font-size:15px; font-weight:700; cursor:pointer;" style-hover="background:#ad081b;">Merken</button>
</div> </div>
<div style="position:absolute; inset:auto 0 0 0; padding:14px; background:linear-gradient(0deg, rgba(0,0,0,.62), transparent); color:#ffffff; font-size:14px; font-weight:600;">{{ g.title }}</div>
</div> </div>
</sc-for> </sc-for>
</div> </div>
@@ -280,157 +218,218 @@
</section> </section>
</sc-if> </sc-if>
<sc-if value="{{ selectionOpen }}" hint-placeholder-val="{{ false }}">
<div style="position:fixed; left:50%; bottom:28px; transform:translateX(-50%); display:flex; align-items:center; gap:12px; padding:12px 12px 12px 22px; border-radius:32px; background:#ffffff; box-shadow:0 1px 20px rgba(0,0,0,.22); z-index:45; animation:riseIn .2s ease;">
<div style="font-size:15px; font-weight:700;">{{ selectionCount }}</div>
<button onClick="{{ saveSelection }}" style="padding:12px 20px; border:none; border-radius:24px; background:#e60023; color:#ffffff; font-family:inherit; font-size:15px; font-weight:700; cursor:pointer; transition:background .12s ease;" style-hover="background:#ad081b;">Zur Sammlung hinzufügen</button>
<button onClick="{{ clearSelection }}" style="padding:12px 20px; border:none; border-radius:24px; background:#efefef; color:#111111; font-family:inherit; font-size:15px; font-weight:700; cursor:pointer; transition:background .12s ease;" style-hover="background:#e2e2e2;">Auswahl aufheben</button>
</div>
</sc-if>
<sc-if value="{{ toast }}" hint-placeholder-val="{{ false }}"> <sc-if value="{{ toast }}" hint-placeholder-val="{{ false }}">
<div style="position:fixed; left:50%; bottom:28px; transform:translateX(-50%); padding:16px 22px; border-radius:16px; background:#ffffff; box-shadow:0 1px 20px rgba(0,0,0,.22); color:#111111; font-size:15px; font-weight:600; animation:riseIn .2s ease; z-index:40;">{{ toast }}</div> <div style="{{ toastStyle }}">{{ toast }}</div>
</sc-if> </sc-if>
</main> </main>
<sc-if value="{{ detailOpen }}" hint-placeholder-val="{{ false }}">
<div onClick="{{ closeDetail }}" style="position:fixed; inset:0; background:rgba(0,0,0,.6); display:grid; place-items:center; padding:32px; z-index:60; animation:fadeIn .16s ease;">
<div onClick="{{ stop }}" style="position:relative; width:min(1016px,100%); max-height:calc(100vh - 64px); background:#ffffff; border-radius:32px; overflow:hidden; display:grid; grid-template-columns:1fr 1fr; animation:riseIn .2s ease;">
<button onClick="{{ closeDetail }}" title="Schließen" style="position:absolute; left:16px; top:16px; width:40px; height:40px; border:none; border-radius:50%; background:#ffffff; box-shadow:0 1px 8px rgba(0,0,0,.2); color:#111111; font-size:16px; cursor:pointer; z-index:2;" style-hover="background:#e9e9e9;"></button>
<div style="{{ detailMedia }}">
<div style="position:absolute; inset:0; background:{{ detailStripes }}; opacity:.45;"></div>
</div>
<div style="padding:32px; overflow:auto; display:flex; flex-direction:column; gap:20px;">
<div style="display:flex; align-items:center; justify-content:flex-end; gap:8px;">
<button onClick="{{ detailHide }}" style="height:48px; padding:0 18px; border:none; border-radius:24px; background:#efefef; color:#111111; font-family:inherit; font-size:15px; font-weight:700; cursor:pointer;" style-hover="background:#e2e2e2;">Nicht mehr zeigen</button>
<button onClick="{{ detailToggleSave }}" style="{{ detailSaveStyle }}">{{ detailSaveLabel }}</button>
</div>
<div>
<div style="font-size:32px; font-weight:700; letter-spacing:-.02em; text-wrap:pretty;">{{ detailTitle }}</div>
<div style="display:flex; align-items:center; gap:12px; margin-top:16px;">
<div style="width:48px; height:48px; border-radius:50%; flex:none; background:{{ detailAvatar }};"></div>
<div style="min-width:0;">
<div style="font-size:16px; font-weight:700;">{{ detailName }}</div>
<div style="font-size:14px; color:#767676;">{{ detailSub }}</div>
</div>
</div>
</div>
<p style="margin:0; font-size:16px; line-height:1.6; color:#333333; text-wrap:pretty;">{{ detailBody }}</p>
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:10px;">
<sc-for list="{{ detailStats }}" as="s" hint-placeholder-count="3">
<div style="background:#f5f5f5; border-radius:16px; padding:14px;">
<div style="font-size:18px; font-weight:700; font-variant-numeric:tabular-nums;">{{ s.value }}</div>
<div style="font-size:12px; color:#767676; margin-top:3px;">{{ s.label }}</div>
</div>
</sc-for>
</div>
<div style="display:flex; flex-wrap:wrap; gap:8px;">
<sc-for list="{{ detailTags }}" as="tag" hint-placeholder-count="4">
<span style="padding:8px 14px; border-radius:18px; background:#efefef; font-size:13px; font-weight:600; color:#111111;">{{ tag }}</span>
</sc-for>
</div>
</div>
</div>
</div>
</sc-if>
</div> </div>
</x-dc> </x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;stackDepth&quot;:{&quot;editor&quot;:&quot;int&quot;,&quot;default&quot;:3,&quot;min&quot;:1,&quot;max&quot;:5,&quot;tsType&quot;:&quot;number&quot;,&quot;section&quot;:&quot;Feed&quot;}}"> <script type="text/x-dc" data-dc-script data-props="{&quot;columns&quot;:{&quot;editor&quot;:&quot;int&quot;,&quot;default&quot;:5,&quot;min&quot;:3,&quot;max&quot;:6,&quot;tsType&quot;:&quot;number&quot;,&quot;section&quot;:&quot;Raster&quot;}}">
const DATA = { const DATA = {
posts: [ posts: [
{ name:'Mara S.', sub:'Modell · Glow Serum v3', kind:'Produkt', assets:'3 Bilder', caption:'Toner im Bogenfenster', hue:[ '#1f8163', '#0d4034' ], avatar:'linear-gradient(140deg,#b2323c,#5d1620)', body:'Drei Aufnahmen im Gegenlicht, aufgenommen im Studio Nord. Freigabe für Paid Social liegt vor.', tags:['Beauty','Studio','Hochformat','Freigegeben'], stats:[['3','Assets'],['12s','Länge'],['94','Score']] }, { id:'p1', name:'Mara S.', sub:'Modell · Glow Serum v3', kind:'Produkt', assets:'3 Bilder', caption:'Toner im Bogenfenster', hue:[ '#1f8163', '#0d4034' ], avatar:'linear-gradient(140deg,#b2323c,#5d1620)', body:'Drei Aufnahmen im Gegenlicht, aufgenommen im Studio Nord. Freigabe für Paid Social liegt vor.', tags:['Beauty','Studio','Hochformat','Freigegeben'], stats:[['3','Assets'],['12s','Länge'],['94','Score']] },
{ name:'Tobias R.', sub:'Creator · Kaltbrüh-Serie', kind:'Video', assets:'1 Clip', caption:'Erste Kanne am Morgen', hue:[ '#256f8b', '#0c2f3f' ], avatar:'linear-gradient(140deg,#2f7f9e,#10394a)', body:'Vertikaler Clip mit Naturton, geschnitten auf zwölf Sekunden. Untertitel bereits eingebrannt.', tags:['Food','Handheld','Untertitel'], stats:[['1','Asset'],['12s','Länge'],['88','Score']] }, { id:'p2', name:'Tobias R.', sub:'Creator · Kaltbrüh-Serie', kind:'Video', assets:'1 Clip', caption:'Erste Kanne am Morgen', hue:[ '#256f8b', '#0c2f3f' ], avatar:'linear-gradient(140deg,#2f7f9e,#10394a)', body:'Vertikaler Clip mit Naturton, geschnitten auf zwölf Sekunden. Untertitel bereits eingebrannt.', tags:['Food','Handheld','Untertitel'], stats:[['1','Asset'],['12s','Länge'],['88','Score']] },
{ name:'Leonie B.', sub:'Modell · Leinen SS26', kind:'Produkt', assets:'5 Bilder', caption:'Leinen gegen die Mittagssonne', hue:[ '#8a6a2f', '#3a2a11' ], avatar:'linear-gradient(140deg,#c99a4a,#6a4a17)', body:'Fünf Stills aus der Sommerstrecke, alle im gleichen Weißabgleich. Retusche steht noch aus.', tags:['Fashion','Außen','Serie'], stats:[['5','Assets'],['—','Länge'],['76','Score']] }, { id:'p3', name:'Leonie B.', sub:'Modell · Leinen SS26', kind:'Produkt', assets:'5 Bilder', caption:'Leinen gegen die Mittagssonne', hue:[ '#8a6a2f', '#3a2a11' ], avatar:'linear-gradient(140deg,#c99a4a,#6a4a17)', body:'Fünf Stills aus der Sommerstrecke, alle im gleichen Weißabgleich. Retusche steht noch aus.', tags:['Fashion','Außen','Serie'], stats:[['5','Assets'],['—','Länge'],['76','Score']] },
{ name:'Nils P.', sub:'Creator · Werkstattnotiz', kind:'Video', assets:'2 Clips', caption:'Hobel, zweiter Durchgang', hue:[ '#7a2f38', '#2c1014' ], avatar:'linear-gradient(140deg,#a63a44,#4d151c)', body:'Zwei Sequenzen aus der Werkstatt, Ton direkt aus der Kamera. Eignet sich als Hintergrundmaterial.', tags:['Handwerk','Ton','Roh'], stats:[['2','Assets'],['26s','Länge'],['81','Score']] }, { id:'p4', name:'Nils P.', sub:'Creator · Werkstattnotiz', kind:'Video', assets:'2 Clips', caption:'Hobel, zweiter Durchgang', hue:[ '#7a2f38', '#2c1014' ], avatar:'linear-gradient(140deg,#a63a44,#4d151c)', body:'Zwei Sequenzen aus der Werkstatt, Ton direkt aus der Kamera. Eignet sich als Hintergrundmaterial.', tags:['Handwerk','Ton','Roh'], stats:[['2','Assets'],['26s','Länge'],['81','Score']] },
{ name:'Anke V.', sub:'Modell · Keramik-Drop', kind:'Produkt', assets:'4 Bilder', caption:'Glasur, noch feucht', hue:[ '#2f6f5a', '#0e332b' ], avatar:'linear-gradient(140deg,#3f9a7c,#124334)', body:'Nahaufnahmen der neuen Glasur auf Naturpapier. Für den Katalog abgenommen.', tags:['Keramik','Makro','Katalog'], stats:[['4','Assets'],['—','Länge'],['90','Score']] } { id:'p5', name:'Anke V.', sub:'Modell · Keramik-Drop', kind:'Produkt', assets:'4 Bilder', caption:'Glasur, noch feucht', hue:[ '#2f6f5a', '#0e332b' ], avatar:'linear-gradient(140deg,#3f9a7c,#124334)', body:'Nahaufnahmen der neuen Glasur auf Naturpapier. Für den Katalog abgenommen.', tags:['Keramik','Makro','Katalog'], stats:[['4','Assets'],['—','Länge'],['90','Score']] },
{ id:'p6', name:'Jorin M.', sub:'Creator · Leuchtenserie', kind:'Produkt', assets:'6 Bilder', caption:'Messing bei Abendlicht', hue:[ '#7a5a2a', '#2f2110' ], avatar:'linear-gradient(140deg,#b08434,#513c14)', body:'Sechs Aufnahmen derselben Leuchte über zwei Stunden hinweg. Die letzten drei sind die stärksten.', tags:['Interior','Abendlicht','Serie'], stats:[['6','Assets'],['—','Länge'],['85','Score']] },
{ id:'p7', name:'Selma D.', sub:'Modell · Denim-Kapsel', kind:'Produkt', assets:'2 Bilder', caption:'Naht, doppelt gesteppt', hue:[ '#1c6f7f', '#0a2c33' ], avatar:'linear-gradient(140deg,#2f8fa0,#0d3b44)', body:'Zwei Detailaufnahmen für die Produktseite. Farbe ist kalibriert, kein Nachschärfen nötig.', tags:['Detail','Produktseite','Kalibriert'], stats:[['2','Assets'],['—','Länge'],['79','Score']] },
{ id:'p8', name:'Ravi K.', sub:'Creator · Marktbesuch', kind:'Video', assets:'3 Clips', caption:'Kisten werden ausgeladen', hue:[ '#6a2f7a', '#26102c' ], avatar:'linear-gradient(140deg,#8b46a0,#341340)', body:'Handkamera am frühen Morgen, viel Umgebungsgeräusch. Schnitt steht noch aus.', tags:['Reportage','Handheld','Roh'], stats:[['3','Assets'],['38s','Länge'],['72','Score']] },
{ id:'p9', name:'Nora W.', sub:'Modell · Seifen-Relaunch', kind:'Produkt', assets:'4 Bilder', caption:'Schaum auf Steingut', hue:[ '#2f6f5a', '#0e332b' ], avatar:'linear-gradient(140deg,#3f9a7c,#124334)', body:'Vier Stills mit weichem Seitenlicht. Für Social freigegeben, Print noch offen.', tags:['Beauty','Studio','Teilfreigabe'], stats:[['4','Assets'],['—','Länge'],['87','Score']] },
{ id:'p10', name:'Elias T.', sub:'Creator · Nachtfahrt', kind:'Video', assets:'1 Clip', caption:'Scheinwerfer auf nasser Straße', hue:[ '#256f8b', '#0c2f3f' ], avatar:'linear-gradient(140deg,#2f7f9e,#10394a)', body:'Ein Take aus dem fahrenden Wagen, sehr kontrastreich. Braucht eine ruhige Tonspur.', tags:['Nacht','Bewegung','Kontrast'], stats:[['1','Asset'],['22s','Länge'],['83','Score']] }
], ],
videos: [ videos: [
{ name:'Studio Nord', sub:'Reel · Behind the scenes', kind:'Video', assets:'1 Clip', caption:'Aufbau um sechs Uhr', hue:[ '#1c6f7f', '#0a2c33' ], avatar:'linear-gradient(140deg,#2f8fa0,#0d3b44)', body:'Ein durchgehender Take vom Aufbau bis zum ersten Licht.', tags:['BTS','Longtake'], stats:[['1','Asset'],['48s','Länge'],['83','Score']] }, { id:'v1', name:'Studio Nord', sub:'Reel · Behind the scenes', kind:'Video', assets:'1 Clip', caption:'Aufbau um sechs Uhr', hue:[ '#1c6f7f', '#0a2c33' ], avatar:'linear-gradient(140deg,#2f8fa0,#0d3b44)', body:'Ein durchgehender Take vom Aufbau bis zum ersten Licht.', tags:['BTS','Longtake'], stats:[['1','Asset'],['48s','Länge'],['83','Score']] },
{ name:'Yara M.', sub:'Creator · Tanzstudie', kind:'Video', assets:'3 Clips', caption:'Drehung, dritter Versuch', hue:[ '#6a2f7a', '#26102c' ], avatar:'linear-gradient(140deg,#8b46a0,#341340)', body:'Drei Varianten derselben Bewegung, Slow Motion in der Mitte.', tags:['Bewegung','Slowmo'], stats:[['3','Assets'],['31s','Länge'],['87','Score']] }, { id:'v2', name:'Yara M.', sub:'Creator · Tanzstudie', kind:'Video', assets:'3 Clips', caption:'Drehung, dritter Versuch', hue:[ '#6a2f7a', '#26102c' ], avatar:'linear-gradient(140deg,#8b46a0,#341340)', body:'Drei Varianten derselben Bewegung, Slow Motion in der Mitte.', tags:['Bewegung','Slowmo'], stats:[['3','Assets'],['31s','Länge'],['87','Score']] },
{ name:'Felix H.', sub:'Creator · Küchennotiz', kind:'Video', assets:'1 Clip', caption:'Teig ruht dreißig Minuten', hue:[ '#7a5a2a', '#2f2110' ], avatar:'linear-gradient(140deg,#b08434,#513c14)', body:'Ruhiger Ablauf ohne Schnitt, gut für Voiceover.', tags:['Food','Ruhig'], stats:[['1','Asset'],['19s','Länge'],['79','Score']] } { id:'v3', name:'Felix H.', sub:'Creator · Küchennotiz', kind:'Video', assets:'1 Clip', caption:'Teig ruht dreißig Minuten', hue:[ '#7a5a2a', '#2f2110' ], avatar:'linear-gradient(140deg,#b08434,#513c14)', body:'Ruhiger Ablauf ohne Schnitt, gut für Voiceover.', tags:['Food','Ruhig'], stats:[['1','Asset'],['19s','Länge'],['79','Score']] },
{ id:'v4', name:'Studio Nord', sub:'Reel · Lichtsetzung', kind:'Video', assets:'2 Clips', caption:'Softbox wandert nach links', hue:[ '#2f6f5a', '#0e332b' ], avatar:'linear-gradient(140deg,#3f9a7c,#124334)', body:'Zwei kurze Sequenzen, die den Aufbau erklären. Als Erklärstück brauchbar.', tags:['BTS','Erklärung'], stats:[['2','Assets'],['27s','Länge'],['80','Score']] },
{ id:'v5', name:'Mira L.', sub:'Creator · Werkbank', kind:'Video', assets:'4 Clips', caption:'Späne fallen langsam', hue:[ '#7a2f38', '#2c1014' ], avatar:'linear-gradient(140deg,#a63a44,#4d151c)', body:'Vier Makroeinstellungen in Zeitlupe. Sehr ruhig, funktioniert stumm.', tags:['Makro','Slowmo','Stumm'], stats:[['4','Assets'],['52s','Länge'],['91','Score']] },
{ id:'v6', name:'Tomas B.', sub:'Creator · Uferweg', kind:'Video', assets:'1 Clip', caption:'Gegenlicht am Wasser', hue:[ '#1f8163', '#0d4034' ], avatar:'linear-gradient(140deg,#2fa87f,#0e5442)', body:'Ein langer Schwenk gegen die tief stehende Sonne. Farbstich bewusst gelassen.', tags:['Außen','Gegenlicht'], stats:[['1','Asset'],['41s','Länge'],['77','Score']] },
{ id:'v7', name:'Ida F.', sub:'Reel · Verpackung', kind:'Video', assets:'2 Clips', caption:'Karton wird gefaltet', hue:[ '#8a6a2f', '#3a2a11' ], avatar:'linear-gradient(140deg,#c99a4a,#6a4a17)', body:'Zwei Durchgänge desselben Handgriffs, der zweite ist sauberer.', tags:['Unboxing','Hände'], stats:[['2','Assets'],['16s','Länge'],['84','Score']] },
{ id:'v8', name:'Kaan Ö.', sub:'Creator · Dachterrasse', kind:'Video', assets:'3 Clips', caption:'Wind in der Markise', hue:[ '#6a2f7a', '#26102c' ], avatar:'linear-gradient(140deg,#8b46a0,#341340)', body:'Drei Einstellungen bei starkem Wind, Ton unbrauchbar, Bild sehr gut.', tags:['Außen','Ton ersetzen'], stats:[['3','Assets'],['34s','Länge'],['75','Score']] }
], ],
following: [ following: [
{ name:'Mara S.', sub:'Folge ich · neuer Post', kind:'Produkt', assets:'2 Bilder', caption:'Nachschub aus dem Studio', hue:[ '#1f8163', '#0d4034' ], avatar:'linear-gradient(140deg,#b2323c,#5d1620)', body:'Zwei zusätzliche Aufnahmen aus derselben Strecke.', tags:['Beauty','Nachtrag'], stats:[['2','Assets'],['—','Länge'],['92','Score']] }, { id:'f1', name:'Mara S.', sub:'Folge ich · neuer Post', kind:'Produkt', assets:'2 Bilder', caption:'Nachschub aus dem Studio', hue:[ '#1f8163', '#0d4034' ], avatar:'linear-gradient(140deg,#b2323c,#5d1620)', body:'Zwei zusätzliche Aufnahmen aus derselben Strecke.', tags:['Beauty','Nachtrag'], stats:[['2','Assets'],['—','Länge'],['92','Score']] },
{ name:'Tobias R.', sub:'Folge ich · Serie läuft', kind:'Video', assets:'4 Clips', caption:'Kaltbrüh, Folge vier', hue:[ '#256f8b', '#0c2f3f' ], avatar:'linear-gradient(140deg,#2f7f9e,#10394a)', body:'Vierte Folge der Reihe, gleiche Bildsprache wie zuvor.', tags:['Serie','Food'], stats:[['4','Assets'],['44s','Länge'],['85','Score']] } { id:'f2', name:'Tobias R.', sub:'Folge ich · Serie läuft', kind:'Video', assets:'4 Clips', caption:'Kaltbrüh, Folge vier', hue:[ '#256f8b', '#0c2f3f' ], avatar:'linear-gradient(140deg,#2f7f9e,#10394a)', body:'Vierte Folge der Reihe, gleiche Bildsprache wie zuvor.', tags:['Serie','Food'], stats:[['4','Assets'],['44s','Länge'],['85','Score']] },
{ id:'f3', name:'Leonie B.', sub:'Folge ich · Vorschau', kind:'Produkt', assets:'3 Bilder', caption:'Leinen, zweite Farbe', hue:[ '#8a6a2f', '#3a2a11' ], avatar:'linear-gradient(140deg,#c99a4a,#6a4a17)', body:'Drei Aufnahmen der zweiten Farbvariante, noch nicht freigegeben.', tags:['Fashion','Vorschau'], stats:[['3','Assets'],['—','Länge'],['78','Score']] },
{ id:'f4', name:'Anke V.', sub:'Folge ich · Werkstatt', kind:'Produkt', assets:'5 Bilder', caption:'Zweiter Brand im Ofen', hue:[ '#2f6f5a', '#0e332b' ], avatar:'linear-gradient(140deg,#3f9a7c,#124334)', body:'Fünf Stills vom zweiten Brand, Farben deutlich satter als beim ersten.', tags:['Keramik','Serie'], stats:[['5','Assets'],['—','Länge'],['89','Score']] },
{ id:'f5', name:'Yara M.', sub:'Folge ich · Probe', kind:'Video', assets:'2 Clips', caption:'Aufwärmen im Spiegelsaal', hue:[ '#6a2f7a', '#26102c' ], avatar:'linear-gradient(140deg,#8b46a0,#341340)', body:'Zwei ruhige Einstellungen vor der eigentlichen Aufnahme.', tags:['Bewegung','Probe'], stats:[['2','Assets'],['29s','Länge'],['81','Score']] },
{ id:'f6', name:'Nils P.', sub:'Folge ich · Nachtrag', kind:'Video', assets:'1 Clip', caption:'Letzter Schliff', hue:[ '#7a2f38', '#2c1014' ], avatar:'linear-gradient(140deg,#a63a44,#4d151c)', body:'Ein kurzer Nachtrag zur Werkstattreihe, sauber belichtet.', tags:['Handwerk','Nachtrag'], stats:[['1','Asset'],['14s','Länge'],['86','Score']] }
] ]
}; };
const ALL = DATA.posts.concat(DATA.videos, DATA.following);
const TILE_HEIGHTS = [320, 400, 260, 340, 280, 440, 300, 360, 240, 380];
class Component extends DCLogic { class Component extends DCLogic {
state = { state = {
screen: 'feed', tab: 'posts', idx: 0, screen: 'feed', tab: 'posts',
drag: null, exiting: null, showInfo: false, toast: '', saved: ['p5'], hidden: [],
seen: 0, saved: 24, history: [], selectMode: false, selection: [],
detail: null, menuFor: null, toast: '',
draftTitle: 'Toner im Bogenfenster', draftSub: 'Modell · Glow Serum v3', draftKind: 'Produkt', draftTitle: 'Toner im Bogenfenster', draftSub: 'Modell · Glow Serum v3', draftKind: 'Produkt',
settings: { autoplay: true, sound: false, digest: true, motion: true } settings: { autoplay: true, sound: false, digest: true, motion: true }
}; };
componentDidMount() { componentDidMount() {
this._key = (e) => { this._key = (e) => {
if (this.state.screen !== 'feed') return; if (e.key !== 'Escape') return;
if (e.key === 'ArrowLeft') { e.preventDefault(); this.commit('left'); } if (this.state.detail) { e.preventDefault(); this.setState({ detail: null }); return; }
if (e.key === 'ArrowRight') { e.preventDefault(); this.commit('right'); } if (this.state.menuFor) { e.preventDefault(); this.setState({ menuFor: null }); return; }
if (e.key === 'ArrowUp') { e.preventDefault(); this.undo(); } if (this.state.selectMode) { e.preventDefault(); this.setState({ selectMode: false, selection: [] }); }
}; };
window.addEventListener('keydown', this._key); window.addEventListener('keydown', this._key);
} }
componentWillUnmount() { window.removeEventListener('keydown', this._key); if (this._t) clearTimeout(this._t); } componentWillUnmount() { window.removeEventListener('keydown', this._key); if (this._toast) clearTimeout(this._toast); }
deck() { const t = this.state.tab; return t === 'videos' ? DATA.videos : t === 'following' ? DATA.following : DATA.posts; } deck() { const t = this.state.tab; return t === 'videos' ? DATA.videos : t === 'following' ? DATA.following : DATA.posts; }
visible() { return this.deck().filter(c => !this.state.hidden.includes(c.id)); }
byId(id) { return ALL.find(c => c.id === id); }
flash(msg) { this.setState({ toast: msg }); clearTimeout(this._toast); this._toast = setTimeout(() => this.setState({ toast: '' }), 1700); } flash(msg) { this.setState({ toast: msg }); clearTimeout(this._toast); this._toast = setTimeout(() => this.setState({ toast: '' }), 1700); }
commit(dir) { toggleSave(id) {
if (this.state.exiting) return; const card = this.byId(id);
if (this.state.idx >= this.deck().length) return; const wasSaved = this.state.saved.includes(id);
const card = this.deck()[this.state.idx]; this.setState(s => ({ saved: wasSaved ? s.saved.filter(x => x !== id) : s.saved.concat([id]) }));
this.setState({ exiting: dir, drag: null }); this.flash(wasSaved ? 'Aus der Sammlung entfernt' : 'Zu „' + card.name + '" gemerkt');
this._t = setTimeout(() => this.setState(s => ({
exiting: null, idx: s.idx + 1, drag: null,
seen: s.seen + 1, saved: dir === 'right' ? s.saved + 1 : s.saved,
history: s.history.concat([{ idx: s.idx, dir }])
})), 320);
this.flash(dir === 'right' ? 'Zu „' + card.name + '" gemerkt' : 'Übersprungen');
} }
undo() { hide(id) {
const h = this.state.history; this.setState(s => ({ hidden: s.hidden.concat([id]), menuFor: null, detail: s.detail === id ? null : s.detail,
if (!h.length) { this.flash('Nichts zurückzunehmen'); return; } selection: s.selection.filter(x => x !== id) }));
const last = h[h.length - 1]; this.flash('Wird nicht mehr gezeigt');
this.setState(s => ({ idx: last.idx, history: s.history.slice(0, -1), saved: last.dir === 'right' ? Math.max(0, s.saved - 1) : s.saved, seen: Math.max(0, s.seen - 1), exiting: null, drag: null }));
} }
onDown = (e) => { toggleSelect(id) {
if (this.state.exiting) return; this.setState(s => ({ selection: s.selection.includes(id) ? s.selection.filter(x => x !== id) : s.selection.concat([id]) }));
e.currentTarget.setPointerCapture?.(e.pointerId); }
this._start = { x: e.clientX, y: e.clientY };
this.setState({ drag: { x: 0, y: 0, active: true } }); saveSelection() {
}; const n = this.state.selection.length;
onMove = (e) => { this.setState(s => ({ saved: s.saved.concat(s.selection.filter(id => !s.saved.includes(id))), selection: [], selectMode: false }));
if (!this.state.drag || !this.state.drag.active) return; this.flash(n + (n === 1 ? ' Karte' : ' Karten') + ' zur Sammlung hinzugefügt');
this.setState({ drag: { x: e.clientX - this._start.x, y: e.clientY - this._start.y, active: true } }); }
};
onUp = () => {
const d = this.state.drag;
if (!d) return;
if (d.x > 120) this.commit('right');
else if (d.x < -120) this.commit('left');
else this.setState({ drag: null });
};
stripes(i) { return 'repeating-linear-gradient(' + (40 + i * 9) + 'deg, rgba(255,255,255,.08) 0 2px, transparent 2px 16px)'; } stripes(i) { return 'repeating-linear-gradient(' + (40 + i * 9) + 'deg, rgba(255,255,255,.08) 0 2px, transparent 2px 16px)'; }
renderVals() { mediaStyle(card, h, selected) {
const s = this.state, deck = this.deck(), depth = Math.max(1, this.props.stackDepth ?? 3); return {
const current = deck[s.idx]; position: 'relative', height: h + 'px', borderRadius: '16px', overflow: 'hidden',
const dx = s.drag ? s.drag.x : 0, dy = s.drag ? s.drag.y : 0; cursor: this.state.selectMode ? 'pointer' : 'zoom-in',
const rot = dx / 22; background: 'linear-gradient(155deg,' + card.hue[0] + ' 0%,' + card.hue[1] + ' 100%)',
outline: selected ? '4px solid #e60023' : 'none', outlineOffset: '-4px',
const stack = []; transition: 'outline-color .12s ease'
for (let k = 0; k < depth; k++) {
const card = deck[s.idx + k];
if (!card) break;
const top = k === 0;
let transform = 'translate3d(0,' + (k * -12) + 'px,0) scale(' + (1 - k * 0.045) + ')';
let transition = 'transform .34s cubic-bezier(.22,.9,.3,1), opacity .3s ease';
let opacity = 1;
if (top) {
if (s.exiting) {
transform = 'translate3d(' + (s.exiting === 'right' ? 620 : -620) + 'px, -40px, 0) rotate(' + (s.exiting === 'right' ? 22 : -22) + 'deg)';
opacity = 0;
} else if (s.drag) {
transform = 'translate3d(' + dx + 'px,' + dy + 'px,0) rotate(' + rot + 'deg)';
transition = s.drag.active ? 'none' : transition;
}
}
const shell = {
position: 'absolute', inset: 0, zIndex: 10 - k, opacity, transform, transition,
borderRadius: '16px', overflow: 'hidden', boxShadow: '0 1px 20px rgba(0,0,0,.18)',
touchAction: 'none',
cursor: top ? (s.drag && s.drag.active ? 'grabbing' : 'grab') : 'default',
pointerEvents: top ? 'auto' : 'none', userSelect: 'none'
}; };
const showBadge = top && (Math.abs(dx) > 40 || !!s.exiting);
const liked = (s.exiting === 'right') || dx > 40;
stack.push({
shell: { ...shell },
down: top ? this.onDown : undefined,
move: top ? this.onMove : undefined,
up: top ? this.onUp : undefined,
media: { position: 'absolute', inset: 0, background: 'linear-gradient(155deg,' + card.hue[0] + ' 0%,' + card.hue[1] + ' 100%)' },
stripes: this.stripes(s.idx + k),
kind: card.kind, assets: card.assets, caption: card.caption, avatar: card.avatar,
name: card.name, sub: card.sub,
save: {
display: top ? 'block' : 'none', padding: '11px 16px', border: 'none', borderRadius: '22px',
background: '#e60023', color: '#ffffff', font: '700 15px/1 Manrope, system-ui, sans-serif',
cursor: 'pointer', transition: 'background .12s ease'
},
badgeText: liked ? 'GEMERKT' : 'SKIP',
badge: {
position: 'absolute', top: '86px', left: liked ? '24px' : 'auto', right: liked ? 'auto' : '24px',
padding: '10px 18px', borderRadius: '22px', fontWeight: 700, fontSize: '15px', letterSpacing: '.08em',
background: liked ? '#e60023' : '#111111', color: '#ffffff',
transform: 'rotate(' + (liked ? -10 : 10) + 'deg)',
opacity: showBadge ? Math.min(1, Math.abs(dx) / 110 || 1) : 0,
transition: 'opacity .16s ease', pointerEvents: 'none'
}
});
} }
// Rail-Zustaende liegen in .rail/.rail.active (helmet), nicht inline: das Runtime setzt saveButton(isSaved) {
// style-hover mit !important und wuerde den aktiven Button sonst beim Hover aufhellen. return {
padding: '11px 16px', border: 'none', borderRadius: '22px',
background: isSaved ? '#111111' : '#e60023', color: '#ffffff',
font: '700 15px/1 Manrope, system-ui, sans-serif', cursor: 'pointer',
transition: 'background .12s ease'
};
}
renderVals() {
const s = this.state;
const cols = Math.max(3, Math.min(6, this.props.columns ?? 5));
const gridStyle = { columns: String(cols), columnGap: '16px' };
const visible = this.visible();
const grid = visible.map((card, i) => {
const isSaved = s.saved.includes(card.id);
const selected = s.selection.includes(card.id);
return {
caption: card.caption, name: card.name, kind: card.kind,
stripes: this.stripes(i),
media: this.mediaStyle(card, TILE_HEIGHTS[i % TILE_HEIGHTS.length], selected),
activate: () => s.selectMode ? this.toggleSelect(card.id) : this.setState({ detail: card.id, menuFor: null }),
open: (e) => { e.stopPropagation(); this.setState({ detail: card.id, menuFor: null }); },
toggleSave: (e) => { e.stopPropagation(); this.toggleSave(card.id); },
openMenu: (e) => { e.stopPropagation(); this.setState(st => ({ menuFor: st.menuFor === card.id ? null : card.id })); },
hide: (e) => { e.stopPropagation(); this.hide(card.id); },
saveLabel: isSaved ? 'Gemerkt' : 'Merken',
saveStyle: this.saveButton(isSaved),
menuOpen: s.menuFor === card.id,
// Im Auswahlmodus verschwinden Merken und ⋯ — dort zaehlt nur die Auswahl.
saveSlot: s.selectMode
? { display: 'none' }
: { position: 'absolute', right: '12px', top: '12px', zIndex: 10 },
menuSlot: s.selectMode
? { display: 'none' }
: { position: 'absolute', right: '12px', bottom: '12px', zIndex: 10 },
menuBtn: {
width: '40px', height: '40px', border: 'none', borderRadius: '50%',
background: '#ffffff', boxShadow: '0 1px 8px rgba(0,0,0,.2)', color: '#111111',
fontSize: '18px', lineHeight: '1', cursor: 'pointer'
},
checkbox: s.selectMode
? { position: 'absolute', left: '12px', top: '12px', width: '28px', height: '28px',
borderRadius: '50%', display: 'grid', placeItems: 'center', fontSize: '15px', fontWeight: 700,
border: '2px solid #ffffff', color: selected ? '#ffffff' : 'transparent',
background: selected ? '#e60023' : 'rgba(0,0,0,.35)', pointerEvents: 'none' }
: { display: 'none' }
};
});
const railStyle = { const railStyle = {
width: '48px', height: '48px', display: 'grid', placeItems: 'center', border: 'none', width: '48px', height: '48px', display: 'grid', placeItems: 'center', border: 'none',
borderRadius: '50%', cursor: 'pointer', fontSize: '20px', borderRadius: '50%', cursor: 'pointer', fontSize: '20px',
@@ -438,15 +437,13 @@ class Component extends DCLogic {
}; };
const navDef = [['feed','Feed','⌂'],['profile','Profil','◍'],['create','Erstellen',''],['settings','Einstellungen','⚙']]; const navDef = [['feed','Feed','⌂'],['profile','Profil','◍'],['create','Erstellen',''],['settings','Einstellungen','⚙']];
const navAll = navDef.map(([id, label, glyph]) => ({ const navAll = navDef.map(([id, label, glyph]) => ({
label, glyph, go: () => this.setState({ screen: id }), label, glyph, go: () => this.setState({ screen: id, menuFor: null }),
cls: s.screen === id ? 'rail active' : 'rail', style: railStyle cls: s.screen === id ? 'rail active' : 'rail', style: railStyle
})); }));
const navTop = navAll.slice(0, 3);
const navBottom = navAll.slice(3);
const tabDef = [['videos','Videos'],['following','Folge ich'],['posts','Posts']]; const tabDef = [['videos','Videos'],['following','Folge ich'],['posts','Posts']];
const tabs = tabDef.map(([id, label]) => ({ const tabs = tabDef.map(([id, label]) => ({
label, go: () => this.setState({ tab: id, idx: 0, history: [], drag: null, exiting: null, screen: 'feed' }), label, go: () => this.setState({ tab: id, screen: 'feed', selection: [], menuFor: null, detail: null }),
style: { style: {
padding: '0 2px 12px', border: 'none', background: 'transparent', padding: '0 2px 12px', border: 'none', background: 'transparent',
borderBottom: '3px solid ' + (s.tab === id ? '#111111' : 'transparent'), borderBottom: '3px solid ' + (s.tab === id ? '#111111' : 'transparent'),
@@ -467,10 +464,10 @@ class Component extends DCLogic {
} }
})); }));
const setDef = [['autoplay','Videos automatisch abspielen','Clips starten stumm, sobald die Karte oben liegt.'], const setDef = [['autoplay','Videos automatisch abspielen','Clips starten stumm, sobald die Kachel im Bild ist.'],
['sound','Ton beim Öffnen','Beim Anklicken einer Karte den Ton aktivieren.'], ['sound','Ton beim Öffnen','Beim Öffnen der Detailansicht den Ton aktivieren.'],
['digest','Wochenrückblick','Jeden Montag eine Zusammenfassung der neuen Posts.'], ['digest','Wochenrückblick','Jeden Montag eine Zusammenfassung der neuen Posts.'],
['motion','Bewegte Übergänge','Karten animiert ein- und ausblenden.']]; ['motion','Bewegte Übergänge','Kacheln und Detailansicht animiert ein- und ausblenden.']];
const settings = setDef.map(([id, label, hint]) => { const settings = setDef.map(([id, label, hint]) => {
const on = s.settings[id]; const on = s.settings[id];
return { return {
@@ -483,34 +480,64 @@ class Component extends DCLogic {
}; };
}); });
const tileHeights = [320, 240, 400, 280, 360, 220, 300, 260]; const gallery = s.saved.map(id => this.byId(id)).filter(Boolean).map((c, i) => ({
const gallery = DATA.posts.concat(DATA.videos).map((c, i) => ({ caption: c.caption, stripes: this.stripes(i),
title: c.caption, stripes: this.stripes(i), media: this.mediaStyle(c, TILE_HEIGHTS[i % TILE_HEIGHTS.length], false),
tile: { position: 'relative', height: tileHeights[i % tileHeights.length] + 'px', borderRadius: '16px', open: () => this.setState({ detail: c.id })
overflow: 'hidden', cursor: 'zoom-in', breakInside: 'avoid', marginBottom: '16px',
background: 'linear-gradient(155deg,' + c.hue[0] + ' 0%,' + c.hue[1] + ' 100%)' }
})); }));
const d = s.detail ? this.byId(s.detail) : null;
const detailSaved = d ? s.saved.includes(d.id) : false;
return { return {
navTop, navBottom, tabs, stack, kindOptions, settings, gallery, navTop: navAll.slice(0, 3), navBottom: navAll.slice(3), tabs, kindOptions, settings,
grid, gridStyle, gallery,
isFeed: s.screen === 'feed', isProfile: s.screen === 'profile', isCreate: s.screen === 'create', isSettings: s.screen === 'settings', isFeed: s.screen === 'feed', isProfile: s.screen === 'profile', isCreate: s.screen === 'create', isSettings: s.screen === 'settings',
stackEmpty: stack.length === 0, gridEmpty: visible.length === 0,
showInfo: s.showInfo, toggleInfo: () => this.setState(st => ({ showInfo: !st.showInfo })), unhideAll: () => this.setState({ hidden: [] }),
counter: Math.min(s.idx + 1, deck.length) + ' / ' + deck.length, counter: visible.length + ' Karten · ' + s.saved.length + ' gemerkt',
swipesToday: String(s.seen).padStart(2, '0'),
onLike: () => this.commit('right'), onNope: () => this.commit('left'), onUndo: () => this.undo(), toggleSelectMode: () => this.setState(st => ({ selectMode: !st.selectMode, selection: [], menuFor: null })),
reset: () => this.setState({ idx: 0, history: [] }), selectToggleLabel: s.selectMode ? 'Fertig' : 'Auswählen',
detailTitle: current ? current.caption : 'Keine Karte', selectToggleStyle: {
detailSub: current ? current.name + ' · ' + current.sub : 'Stapel leer', padding: '10px 18px', border: 'none', borderRadius: '22px', cursor: 'pointer',
detailBody: current ? current.body : 'Lade den Stapel neu, um weitere Karten zu sichten.', background: s.selectMode ? '#111111' : '#efefef', color: s.selectMode ? '#ffffff' : '#111111',
detailTags: current ? current.tags : [], font: '700 15px/1 Manrope, system-ui, sans-serif', transition: 'background .12s ease, color .12s ease'
detailStats: current ? current.stats.map(([value, label]) => ({ value, label })) : [], },
profileStats: [{ value: '38', label: 'Posts' }, { value: String(s.saved), label: 'Gemerkt' }, { value: '1.204', label: 'Follower' }], selectionOpen: s.selectMode && s.selection.length > 0,
selectionCount: s.selection.length + ' ausgewählt',
saveSelection: () => this.saveSelection(),
clearSelection: () => this.setState({ selection: [] }),
detailOpen: !!d,
closeDetail: () => this.setState({ detail: null }),
stop: (e) => e.stopPropagation(),
detailMedia: d ? { position: 'relative', minHeight: '520px', background: 'linear-gradient(155deg,' + d.hue[0] + ' 0%,' + d.hue[1] + ' 100%)' } : {},
detailStripes: d ? this.stripes(2) : 'none',
detailTitle: d ? d.caption : '',
detailName: d ? d.name : '',
detailSub: d ? d.sub : '',
detailAvatar: d ? d.avatar : 'none',
detailBody: d ? d.body : '',
detailTags: d ? d.tags : [],
detailStats: d ? d.stats.map(([value, label]) => ({ value, label })) : [],
detailSaveLabel: detailSaved ? 'Gemerkt' : 'Merken',
detailSaveStyle: { ...this.saveButton(detailSaved), height: '48px', padding: '0 24px', borderRadius: '24px' },
detailToggleSave: () => this.toggleSave(s.detail),
detailHide: () => this.hide(s.detail),
profileStats: [{ value: '38', label: 'Posts' }, { value: String(s.saved.length), label: 'Gemerkt' }, { value: '1.204', label: 'Follower' }],
draftTitle: s.draftTitle, draftSub: s.draftSub, draftTitle: s.draftTitle, draftSub: s.draftSub,
setTitle: (e) => this.setState({ draftTitle: e.target.value }), setTitle: (e) => this.setState({ draftTitle: e.target.value }),
setSub: (e) => this.setState({ draftSub: e.target.value }), setSub: (e) => this.setState({ draftSub: e.target.value }),
publish: () => this.flash('Post veröffentlicht'), publish: () => this.flash('Post veröffentlicht'),
toast: s.toast toast: s.toast,
toastStyle: {
position: 'fixed', left: '50%', bottom: s.selectMode && s.selection.length ? '96px' : '28px',
transform: 'translateX(-50%)', padding: '16px 22px', borderRadius: '16px',
background: '#ffffff', boxShadow: '0 1px 20px rgba(0,0,0,.22)', color: '#111111',
fontSize: '15px', fontWeight: 600, animation: 'riseIn .2s ease', zIndex: 50
}
}; };
} }
} }