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:
@@ -19,10 +19,13 @@
|
||||
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 fadeIn { from { opacity:0 } to { opacity:1 } }
|
||||
.pin-save { opacity:0; transition:opacity .12s ease; }
|
||||
.pin:hover .pin-save, .pin:focus-within .pin-save { opacity:1; }
|
||||
/* Hover-Steuerung haengt an der Kachel, nicht am Element selbst. */
|
||||
.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: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:hover { background:#e9e9e9; }
|
||||
.rail.active { background:#111111; color:#ffffff; }
|
||||
@@ -68,115 +71,51 @@
|
||||
<button onClick="{{ t.go }}" style="{{ t.style }}" style-hover="color:#111111;">{{ t.label }}</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div style="display:flex; align-items:center; gap:14px; padding-bottom:10px;">
|
||||
<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; font-variant-numeric:tabular-nums;">{{ counter }}</div>
|
||||
<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 style="display:flex; align-items:center; gap:12px; padding-bottom:8px;">
|
||||
<div style="font-size:13px; color:#767676;">{{ counter }}</div>
|
||||
<button onClick="{{ toggleSelectMode }}" style="{{ selectToggleStyle }}">{{ selectToggleLabel }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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;">
|
||||
|
||||
<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;">
|
||||
<div style="position:relative; width:min(404px,100%); height:560px; flex:none;">
|
||||
<sc-for list="{{ stack }}" as="c" hint-placeholder-count="3">
|
||||
<div class="pin" style="{{ c.shell }}" onPointerDown="{{ c.down }}" onPointerMove="{{ c.move }}" onPointerUp="{{ c.up }}" onPointerCancel="{{ c.up }}">
|
||||
<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 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:relative; display:flex; align-items:flex-start; justify-content:space-between; gap:9px; padding:16px;">
|
||||
<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>
|
||||
<button class="pin-save" onClick="{{ onLike }}" style="{{ c.save }}" style-hover="background:#ad081b;">Merken</button>
|
||||
</div>
|
||||
|
||||
<div style="position:absolute; inset:auto 0 104px 0; display:flex; justify-content:center; padding:0 22px;">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div style="position:absolute; inset:auto 0 0 0; display:flex; align-items:center; gap:12px; padding:18px; color:#ffffff;">
|
||||
<div style="width:44px; height:44px; border-radius:50%; flex:none; background:{{ c.avatar }};"></div>
|
||||
<div style="min-width:0;">
|
||||
<div style="font-size:18px; font-weight:700; letter-spacing:-.01em;">{{ c.name }}</div>
|
||||
<div style="font-size:14px; color:rgba(255,255,255,.82);">{{ c.sub }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="{{ c.badge }}">{{ c.badgeText }}</div>
|
||||
<section style="flex:1; min-height:0; overflow:auto; padding:24px 24px 96px;">
|
||||
<div style="{{ gridStyle }}">
|
||||
<sc-for list="{{ grid }}" as="c" hint-placeholder-count="10">
|
||||
<div class="pin" style="position:relative; break-inside:avoid; margin-bottom:16px;">
|
||||
<div onClick="{{ c.activate }}" style="{{ c.media }}">
|
||||
<div style="position:absolute; inset:0; background:{{ c.stripes }}; opacity:.45;"></div>
|
||||
<div class="pin-scrim" style="position:absolute; inset:0; background:rgba(0,0,0,.24);"></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="font-size:12px; color:rgba(255,255,255,.82); margin-top:2px;">{{ c.name }} · {{ c.kind }}</div>
|
||||
</div>
|
||||
<div style="{{ c.checkbox }}">✓</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 class="pin-hover" style="{{ c.saveSlot }}">
|
||||
<button onClick="{{ c.toggleSave }}" style="{{ c.saveStyle }}" style-hover="filter:brightness(.88);">{{ c.saveLabel }}</button>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<sc-if value="{{ showInfo }}" 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="font-weight:700; margin-bottom:8px;">Bedienung</div>
|
||||
<div style="display:grid; grid-template-columns:auto 1fr; gap:6px 12px; color:#5f5f5f;">
|
||||
<span style="font-weight:600; color:#111111;">←</span><span>Ablehnen</span>
|
||||
<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 class="pin-hover" style="{{ c.menuSlot }}">
|
||||
<button onClick="{{ c.openMenu }}" title="Weitere Optionen" style="{{ c.menuBtn }}" style-hover="background:#e9e9e9;">⋯</button>
|
||||
</div>
|
||||
|
||||
<sc-if value="{{ c.menuOpen }}" hint-placeholder-val="{{ false }}">
|
||||
<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;">
|
||||
<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>
|
||||
<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>
|
||||
</sc-if>
|
||||
</div>
|
||||
</sc-if>
|
||||
</sc-for>
|
||||
</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>
|
||||
<sc-if value="{{ gridEmpty }}" hint-placeholder-val="{{ false }}">
|
||||
<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-size:20px; font-weight:700;">Nichts mehr in dieser Ansicht</div>
|
||||
<div style="font-size:15px; color:#5f5f5f; text-wrap:pretty;">Alle Karten dieses Tabs sind ausgeblendet.</div>
|
||||
<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>
|
||||
</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>
|
||||
</sc-if>
|
||||
</section>
|
||||
</sc-if>
|
||||
|
||||
@@ -195,15 +134,14 @@
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
<div style="columns:5; column-gap:16px;">
|
||||
<div style="{{ gridStyle }}">
|
||||
<sc-for list="{{ gallery }}" as="g" hint-placeholder-count="8">
|
||||
<div class="pin" style="{{ g.tile }}">
|
||||
<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 style="position:absolute; right:12px; top:12px;">
|
||||
<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 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 class="pin-scrim" style="position:absolute; inset:0; background:rgba(0,0,0,.24);"></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.caption }}</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>
|
||||
</sc-for>
|
||||
</div>
|
||||
@@ -280,157 +218,218 @@
|
||||
</section>
|
||||
</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 }}">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"stackDepth":{"editor":"int","default":3,"min":1,"max":5,"tsType":"number","section":"Feed"}}">
|
||||
<script type="text/x-dc" data-dc-script data-props="{"columns":{"editor":"int","default":5,"min":3,"max":6,"tsType":"number","section":"Raster"}}">
|
||||
const DATA = {
|
||||
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']] },
|
||||
{ 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']] },
|
||||
{ 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:'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']] },
|
||||
{ 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']] },
|
||||
{ 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']] },
|
||||
{ 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']] },
|
||||
{ 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: [
|
||||
{ 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']] },
|
||||
{ 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:'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']] },
|
||||
{ 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']] },
|
||||
{ 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: [
|
||||
{ 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:'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']] },
|
||||
{ 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 {
|
||||
state = {
|
||||
screen: 'feed', tab: 'posts', idx: 0,
|
||||
drag: null, exiting: null, showInfo: false, toast: '',
|
||||
seen: 0, saved: 24, history: [],
|
||||
screen: 'feed', tab: 'posts',
|
||||
saved: ['p5'], hidden: [],
|
||||
selectMode: false, selection: [],
|
||||
detail: null, menuFor: null, toast: '',
|
||||
draftTitle: 'Toner im Bogenfenster', draftSub: 'Modell · Glow Serum v3', draftKind: 'Produkt',
|
||||
settings: { autoplay: true, sound: false, digest: true, motion: true }
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this._key = (e) => {
|
||||
if (this.state.screen !== 'feed') return;
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); this.commit('left'); }
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); this.commit('right'); }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); this.undo(); }
|
||||
if (e.key !== 'Escape') return;
|
||||
if (this.state.detail) { e.preventDefault(); this.setState({ detail: null }); return; }
|
||||
if (this.state.menuFor) { e.preventDefault(); this.setState({ menuFor: null }); return; }
|
||||
if (this.state.selectMode) { e.preventDefault(); this.setState({ selectMode: false, selection: [] }); }
|
||||
};
|
||||
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; }
|
||||
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); }
|
||||
|
||||
commit(dir) {
|
||||
if (this.state.exiting) return;
|
||||
if (this.state.idx >= this.deck().length) return;
|
||||
const card = this.deck()[this.state.idx];
|
||||
this.setState({ exiting: dir, drag: null });
|
||||
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');
|
||||
toggleSave(id) {
|
||||
const card = this.byId(id);
|
||||
const wasSaved = this.state.saved.includes(id);
|
||||
this.setState(s => ({ saved: wasSaved ? s.saved.filter(x => x !== id) : s.saved.concat([id]) }));
|
||||
this.flash(wasSaved ? 'Aus der Sammlung entfernt' : 'Zu „' + card.name + '" gemerkt');
|
||||
}
|
||||
|
||||
undo() {
|
||||
const h = this.state.history;
|
||||
if (!h.length) { this.flash('Nichts zurückzunehmen'); return; }
|
||||
const last = h[h.length - 1];
|
||||
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 }));
|
||||
hide(id) {
|
||||
this.setState(s => ({ hidden: s.hidden.concat([id]), menuFor: null, detail: s.detail === id ? null : s.detail,
|
||||
selection: s.selection.filter(x => x !== id) }));
|
||||
this.flash('Wird nicht mehr gezeigt');
|
||||
}
|
||||
|
||||
onDown = (e) => {
|
||||
if (this.state.exiting) return;
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||
this._start = { x: e.clientX, y: e.clientY };
|
||||
this.setState({ drag: { x: 0, y: 0, active: true } });
|
||||
};
|
||||
onMove = (e) => {
|
||||
if (!this.state.drag || !this.state.drag.active) return;
|
||||
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 });
|
||||
};
|
||||
toggleSelect(id) {
|
||||
this.setState(s => ({ selection: s.selection.includes(id) ? s.selection.filter(x => x !== id) : s.selection.concat([id]) }));
|
||||
}
|
||||
|
||||
saveSelection() {
|
||||
const n = this.state.selection.length;
|
||||
this.setState(s => ({ saved: s.saved.concat(s.selection.filter(id => !s.saved.includes(id))), selection: [], selectMode: false }));
|
||||
this.flash(n + (n === 1 ? ' Karte' : ' Karten') + ' zur Sammlung hinzugefügt');
|
||||
}
|
||||
|
||||
stripes(i) { return 'repeating-linear-gradient(' + (40 + i * 9) + 'deg, rgba(255,255,255,.08) 0 2px, transparent 2px 16px)'; }
|
||||
|
||||
mediaStyle(card, h, selected) {
|
||||
return {
|
||||
position: 'relative', height: h + 'px', borderRadius: '16px', overflow: 'hidden',
|
||||
cursor: this.state.selectMode ? 'pointer' : 'zoom-in',
|
||||
background: 'linear-gradient(155deg,' + card.hue[0] + ' 0%,' + card.hue[1] + ' 100%)',
|
||||
outline: selected ? '4px solid #e60023' : 'none', outlineOffset: '-4px',
|
||||
transition: 'outline-color .12s ease'
|
||||
};
|
||||
}
|
||||
|
||||
saveButton(isSaved) {
|
||||
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, deck = this.deck(), depth = Math.max(1, this.props.stackDepth ?? 3);
|
||||
const current = deck[s.idx];
|
||||
const dx = s.drag ? s.drag.x : 0, dy = s.drag ? s.drag.y : 0;
|
||||
const rot = dx / 22;
|
||||
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 stack = [];
|
||||
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'
|
||||
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'
|
||||
},
|
||||
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'
|
||||
}
|
||||
});
|
||||
}
|
||||
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' }
|
||||
};
|
||||
});
|
||||
|
||||
// Rail-Zustaende liegen in .rail/.rail.active (helmet), nicht inline: das Runtime setzt
|
||||
// style-hover mit !important und wuerde den aktiven Button sonst beim Hover aufhellen.
|
||||
const railStyle = {
|
||||
width: '48px', height: '48px', display: 'grid', placeItems: 'center', border: 'none',
|
||||
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 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
|
||||
}));
|
||||
const navTop = navAll.slice(0, 3);
|
||||
const navBottom = navAll.slice(3);
|
||||
|
||||
const tabDef = [['videos','Videos'],['following','Folge ich'],['posts','Posts']];
|
||||
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: {
|
||||
padding: '0 2px 12px', border: 'none', background: '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.'],
|
||||
['sound','Ton beim Öffnen','Beim Anklicken einer Karte den Ton aktivieren.'],
|
||||
const setDef = [['autoplay','Videos automatisch abspielen','Clips starten stumm, sobald die Kachel im Bild ist.'],
|
||||
['sound','Ton beim Öffnen','Beim Öffnen der Detailansicht den Ton aktivieren.'],
|
||||
['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 on = s.settings[id];
|
||||
return {
|
||||
@@ -483,34 +480,64 @@ class Component extends DCLogic {
|
||||
};
|
||||
});
|
||||
|
||||
const tileHeights = [320, 240, 400, 280, 360, 220, 300, 260];
|
||||
const gallery = DATA.posts.concat(DATA.videos).map((c, i) => ({
|
||||
title: c.caption, stripes: this.stripes(i),
|
||||
tile: { position: 'relative', height: tileHeights[i % tileHeights.length] + 'px', borderRadius: '16px',
|
||||
overflow: 'hidden', cursor: 'zoom-in', breakInside: 'avoid', marginBottom: '16px',
|
||||
background: 'linear-gradient(155deg,' + c.hue[0] + ' 0%,' + c.hue[1] + ' 100%)' }
|
||||
const gallery = s.saved.map(id => this.byId(id)).filter(Boolean).map((c, i) => ({
|
||||
caption: c.caption, stripes: this.stripes(i),
|
||||
media: this.mediaStyle(c, TILE_HEIGHTS[i % TILE_HEIGHTS.length], false),
|
||||
open: () => this.setState({ detail: c.id })
|
||||
}));
|
||||
|
||||
const d = s.detail ? this.byId(s.detail) : null;
|
||||
const detailSaved = d ? s.saved.includes(d.id) : false;
|
||||
|
||||
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',
|
||||
stackEmpty: stack.length === 0,
|
||||
showInfo: s.showInfo, toggleInfo: () => this.setState(st => ({ showInfo: !st.showInfo })),
|
||||
counter: Math.min(s.idx + 1, deck.length) + ' / ' + deck.length,
|
||||
swipesToday: String(s.seen).padStart(2, '0'),
|
||||
onLike: () => this.commit('right'), onNope: () => this.commit('left'), onUndo: () => this.undo(),
|
||||
reset: () => this.setState({ idx: 0, history: [] }),
|
||||
detailTitle: current ? current.caption : 'Keine Karte',
|
||||
detailSub: current ? current.name + ' · ' + current.sub : 'Stapel leer',
|
||||
detailBody: current ? current.body : 'Lade den Stapel neu, um weitere Karten zu sichten.',
|
||||
detailTags: current ? current.tags : [],
|
||||
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' }],
|
||||
gridEmpty: visible.length === 0,
|
||||
unhideAll: () => this.setState({ hidden: [] }),
|
||||
counter: visible.length + ' Karten · ' + s.saved.length + ' gemerkt',
|
||||
|
||||
toggleSelectMode: () => this.setState(st => ({ selectMode: !st.selectMode, selection: [], menuFor: null })),
|
||||
selectToggleLabel: s.selectMode ? 'Fertig' : 'Auswählen',
|
||||
selectToggleStyle: {
|
||||
padding: '10px 18px', border: 'none', borderRadius: '22px', cursor: 'pointer',
|
||||
background: s.selectMode ? '#111111' : '#efefef', color: s.selectMode ? '#ffffff' : '#111111',
|
||||
font: '700 15px/1 Manrope, system-ui, sans-serif', transition: 'background .12s ease, color .12s ease'
|
||||
},
|
||||
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,
|
||||
setTitle: (e) => this.setState({ draftTitle: e.target.value }),
|
||||
setSub: (e) => this.setState({ draftSub: e.target.value }),
|
||||
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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user