Compare commits

...

5 commits

Author SHA1 Message Date
ac0814e687 Forum: Anpinnen pro Thema/global + Admin-Berechtigung (v1303)
- Anpinnen-Scope: pin_scope ('global' | 'kategorie'). Global haelt oben in
  jeder Ansicht; Themen-Pin nur in der gefilterten Kategorie (nicht in 'Alle').
- Bugfix Berechtigung: Forum pruefte nur is_moderator -> Admins ohne das Flag
  wurden ausgesperrt. Neuer Helper _can_moderate() = rolle in (admin,moderator)
  ODER is_moderator, an allen 7 Forum-Checks + beiden Frontend-isMod-Gates.
- Thread-Detail-Toolbar (nur Admin/Mod): 'Global anpinnen' / 'Im Thema anpinnen'
  / 'Loesen' + Status- und Badge-Anzeige nach Scope.
- DB-Migration forum_threads.pin_scope (idempotent, Default 'global').
- Tests: tests/test_forum_pinning.py (Berechtigung + Scope-Sortierung).
2026-06-18 20:36:06 +02:00
901df5468c Album-Download: Umlaute (UTF-8 BOM) + Copyright-Hinweis
- liner-de.txt: echte Umlaute statt ASCII-Ersatz, UTF-8-BOM für korrekte
  Anzeige auch auf Windows
- Copyright in beiden Linern + als ID3-copyright-Feld auf jedem Song:
  "Kommerzielle Nutzung nur nach Absprache (banyaro.app)"
- ZIPs neu gebaut (make album); LIVE auf Prod + Staging
2026-06-17 06:36:02 +02:00
1a03cab5dd Album: Download im Profil — zwei ZIPs (DE+EN) mit Cover, ID3-Tags, Liner Notes (v1302)
- Profil-Karte "Ban Yaro — das Album" in settings.js mit Cover-Thumbnail +
  zwei Download-Buttons (Deutsch/English), rein deklarativ (CSP-safe)
- /downloads StaticFiles-Mount in main.py (makedirs-Schutz); ZIPs matchen
  keine SW-Cache-Regel -> fluten den Cache nicht
- backend/static/downloads/ban-yaro-album-{de,en}.zip: je 7 MP3s mit ID3-Tags
  + eingebettetem Cover, cover.jpg, LIESMICH.txt/README.txt (Tracklist + Lizenz)
- Cover aus Fruehling-Playdate-Foto (quadr. Crop + Wortmarke), DE/EN-Variante;
  textfreies album-thumb.jpg fuer die Karte
- Reproduzierbar: make album (tools/album-build/build.sh + Liner-Notes)
- LIVE auf Prod + Staging v1302
2026-06-17 06:25:34 +02:00
aea489aa5a Album: Englische Edition (7 Songs) + DE/EN-Umschalter im Album-Modal (v1301)
- 7 englische Suno-Pro-Songs als *-en.mp3 in static/sounds/ (MD5-geprüft,
  eigene Generierungen, alle != deutsche Tracks)
- worlds.js _anthem: SONGS_DE/SONGS_EN, _lang-State (localStorage
  by_album_lang), DE/EN-Segmented-Control (_fillAlbum/_setLang), EN_READY=true,
  Modal-Chrome zweisprachig
- components.css: .album-lang / .album-lang-btn
- UX: DE bleibt Default, keine Auto-Vorwahl, User schaltet selbst, beides
  anhörbar, Wahl gemerkt
- LIVE auf Prod + Staging v1301
2026-06-16 20:57:46 +02:00
6dc944eeb8 Marketing: Song-Reel-Plan + Platsch!-Pilot-Shotlist; Album-Stand auf 7 Songs/v1300 2026-06-15 18:50:54 +02:00
30 changed files with 575 additions and 71 deletions

3
.gitignore vendored
View file

@ -21,3 +21,6 @@ tiles/build/
*.mbtiles *.mbtiles
tiles/build.log tiles/build.log
tiles/.DS_Store tiles/.DS_Store
# Album-Build: Zwischendateien (ZIPs + Thumbnail werden committet)
tools/album-build/dist/

View file

@ -2,7 +2,7 @@
**Single Source of Truth fürs Marketing.** Vor jeder Aktion hier prüfen, danach updaten — so wird nichts doppelt gemacht, vergessen oder übersehen. Pflege: René + Claude. **Single Source of Truth fürs Marketing.** Vor jeder Aktion hier prüfen, danach updaten — so wird nichts doppelt gemacht, vergessen oder übersehen. Pflege: René + Claude.
_Stand: 2026-06-09_ _Stand: 2026-06-15_
> Diese Datei = Planung & Checkliste. Für **Live-Daten** (User-Meilenstein, Kanal-Tracking) lohnt zusätzlich ein Marketing-Tab im **Admin-Bereich** — siehe „Ausbau" unten. > Diese Datei = Planung & Checkliste. Für **Live-Daten** (User-Meilenstein, Kanal-Tracking) lohnt zusätzlich ein Marketing-Tab im **Admin-Bereich** — siehe „Ausbau" unten.
@ -16,6 +16,7 @@ _Stand: 2026-06-09_
| Empfehlung / Referral | 🟡 Infra da (`referral_code`) | Empfehlungs-QR + Tracking sichtbar machen | | Empfehlung / Referral | 🟡 Infra da (`referral_code`) | Empfehlungs-QR + Tracking sichtbar machen |
| Partner-Programm | 🟢 Infra komplett (v1265, 07.06.) | Partner einladen! Showcase `#partner`, Pro gratis, Partner-Dashboard, QR-Kontingente (Druck-PDF) mit Einzel-Code-Tracking, Dank-Mails mit Statistik, Pause-Notbremse für geleakte Codes. Onboarding: Admin → Code anlegen → Partner-Badge → Besitzer zuordnen | | Partner-Programm | 🟢 Infra komplett (v1265, 07.06.) | Partner einladen! Showcase `#partner`, Pro gratis, Partner-Dashboard, QR-Kontingente (Druck-PDF) mit Einzel-Code-Tracking, Dank-Mails mit Statistik, Pause-Notbremse für geleakte Codes. Onboarding: Admin → Code anlegen → Partner-Badge → Besitzer zuordnen |
| Influencer | 🟡 2 Runden (Mai), kaum Resonanz | Runde 3 erst ab ~50 aktiven Usern — jetzt mit Partner-Paket als konkretem Angebot | | Influencer | 🟡 2 Runden (Mai), kaum Resonanz | Runde 3 erst ab ~50 aktiven Usern — jetzt mit Partner-Paket als konkretem Angebot |
| Social / Short-Video (Reels/Shorts/TikTok) | 💡 Plan steht (15.06.) | Pilot-Clip „Platsch!" (Sommer-Timing) produzieren — Details unten unter „Song-Reel" |
| Presse / Blogs | 🟡 1 Runde, kaum Resonanz | keine Massenwelle; Nische zuerst | | Presse / Blogs | 🟡 1 Runde, kaum Resonanz | keine Massenwelle; Nische zuerst |
| Verzeichnisse / Listings | ⬜ offen | Product Hunt, PWA-Dirs, Google Business EBE | | Verzeichnisse / Listings | ⬜ offen | Product Hunt, PWA-Dirs, Google Business EBE |
| SEO / KI-Auffindbarkeit | 🟡 technisch optimiert | Rechtsseiten crawlbar (v1278) + 3 URLs (datenschutz/agb/impressum) am 09.06. in GSC zur Indexierung eingereicht — in ~Tagen auf „indexiert" prüfen; llms.txt aktuell. Nächster echter Hebel: Backlinks (Blog-Testberichte) | | SEO / KI-Auffindbarkeit | 🟡 technisch optimiert | Rechtsseiten crawlbar (v1278) + 3 URLs (datenschutz/agb/impressum) am 09.06. in GSC zur Indexierung eingereicht — in ~Tagen auf „indexiert" prüfen; llms.txt aktuell. Nächster echter Hebel: Backlinks (Blog-Testberichte) |
@ -37,10 +38,10 @@ Legende: 🟢 läuft/erledigt · 🟡 angefangen · ⬜ offen · 💡 Idee ·
- [ ] **Verzeichnisse** — Product Hunt, progressivewebappstore.com, pwafire.org/directory, Google Business (Ebersberg). - [ ] **Verzeichnisse** — Product Hunt, progressivewebappstore.com, pwafire.org/directory, Google Business (Ebersberg).
- [ ] **Landing-Page-Redesign** nach Briefing (3 Zielgruppen-Einstiege Hundebesitzer/Züchter/Welpenkäufer, Outcomes statt Features, Züchter-SaaS prominent, Datenschutz als Argument, Gründer-Story + Foto). - [ ] **Landing-Page-Redesign** nach Briefing (3 Zielgruppen-Einstiege Hundebesitzer/Züchter/Welpenkäufer, Outcomes statt Features, Züchter-SaaS prominent, Datenschutz als Argument, Gründer-Story + Foto).
- [ ] **Messung einbauen** — „Wie hast du von uns gehört?" im Onboarding + QR-refs pro Kanal. - [ ] **Messung einbauen** — „Wie hast du von uns gehört?" im Onboarding + QR-refs pro Kanal.
- [ ] **Song-Reel** — die Album-Songs + App-Screenshots/Hundefotos → **Reel/YouTube-Video** für Social. Album ist erweiterbar (neuer Suno-Pro-Song = MP3 + Array-Zeile). - [ ] **Song-Reel / Short-Video-Serie** — Plan ausgearbeitet, siehe „Details je Kanal → Song-Reel / Short-Video" unten. **Nächster Schritt:** Pilot-Clip „Platsch!" (Sommer-Timing jetzt ideal).
## ✅ Erledigt ## ✅ Erledigt
- [x] **Ban-Yaro-Album**3 eigene Songs (Ban Yaro Blues, Ban Yaro Mobil, Amy) als Album-Modal in der WELT-Welt — 14.06., Prod v1297. Alle **Suno Pro** = kommerziell lizenziert. (Lektion: Lizenz hängt am Generierungszeitpunkt; neu generieren ≠ neu downloaden, per MD5 geprüft.) - [x] **Ban-Yaro-Album****7 eigene Songs** (Ban Yaro Blues, Ban Yaro Mobil, Amy, Beim Friseur, Leckerli-Paradies, Platsch!, Bester Freund) als Album-Modal in der WELT-Welt — Prod v1300. Alle **Suno Pro** = kommerziell lizenziert (granted commercial rights, Suno nimmt 0 % Royalties). (Lektion: Lizenz hängt am Generierungszeitpunkt; neu generieren ≠ neu downloaden, per MD5 geprüft.)
- [x] 1000 Flyer A5 (zweiseitig) gedruckt — 03.06.2026 - [x] 1000 Flyer A5 (zweiseitig) gedruckt — 03.06.2026
- [x] iOS-App nativ gebaut + **im App Store freigegeben** (Ban Yaro Go, 09.06.) — Details im Repo `banyaro-ios` - [x] iOS-App nativ gebaut + **im App Store freigegeben** (Ban Yaro Go, 09.06.) — Details im Repo `banyaro-ios`
- [x] Landing-Promotion für „Ban Yaro Go" LIVE (iOS-Abschnitt + Profil, eigenes braunes App-Store-Badge; Hero bewusst ohne Badge) — 09.06., Prod v1278 - [x] Landing-Promotion für „Ban Yaro Go" LIVE (iOS-Abschnitt + Profil, eigenes braunes App-Store-Badge; Hero bewusst ohne Badge) — 09.06., Prod v1278
@ -71,6 +72,42 @@ Tag recherchiert: **HID Laundry Tag 16 mm** (shopnfc, SKU RE-ICO2-16, ~1 €/Stk
### Flyer ### Flyer
Print: A5 zweiseitig, Quelle `promotion/flyer_a5_allgemein.html` + `flyer_a5_rueckseite.html`, QR → banyaro.app. Vorderseite = alle Hundebesitzer, Rückseite stark Züchter-fokussiert. Print: A5 zweiseitig, Quelle `promotion/flyer_a5_allgemein.html` + `flyer_a5_rueckseite.html`, QR → banyaro.app. Vorderseite = alle Hundebesitzer, Rückseite stark Züchter-fokussiert.
### Song-Reel / Short-Video
**Ziel:** Das Album ist kein Einnahme-Produkt (Streaming zahlt ~34 €/1000 Streams), sondern ein **Top-of-Funnel-Marketing-Motor**. Ein ohrwurmiger Hundesong ist von Natur aus teilbar → Awareness → App-Installs. **Erfolgskennzahl = Installs pro Clip, nicht Tantiemen.**
**Lizenz/Compliance:** Social-Reels (IG/TikTok/Shorts) brauchen **keine** DDEX-KI-Flag — die gilt nur bei Streaming-Distribution (Spotify/Apple). Optional sympathischer Hinweis „selbst gemacht 🎸 (mit KI)" in der Caption. Suno-Pro-Songs sind kommerziell freigegeben.
**Song → Clip-Konzept** (visuelle/lustige Songs zuerst short-form, Balladen long-form):
| Song | Sub | Clip-Idee | Format |
|---|---|---|---|
| **Platsch!** | Ab ins kühle Nass | Hund springt ins Wasser, Zeitlupe beim Sprung → Drop auf den Beat. **Sommer-Timing = JETZT.** | Short (Pilot) |
| **Leckerli-Paradies** | Voller Napf, volles Glück | Hund vor vollem Napf / im Tierladen, große Augen, Tierladen-Boogie. Sehr relatable. | Short |
| **Beim Friseur** | Halbes Fell, Energie pur | Grooming Vorher/Nachher, lustiger Schnitt. | Short |
| **Ban Yaro Mobil** | Erste Fahrt im Anhänger | Hund im Anhänger/Auto, Ohren im Fahrtwind. | Short |
| **Bester Freund** | Du und ich | Emotionale Foto-Montage Mensch-Hund-Bindung. | YouTube-Lyric + Story-Reel |
| **Amy** | Eine Liebesromanze | Zwei-Hunde-Story. | YouTube-Lyric |
| **Ban Yaro Blues** | Die Hymne | Marken-Hymne = Brand-/Pinned-Video. | YouTube + Pinned |
**Clip-Bauplan (Template, 1530 s, 9:16):**
1. **01,5 s Hook:** stärkster visueller Moment + stärkste Textzeile als Caption-Pop.
2. **1,5~20 s:** 35 schnelle Hunde-Clips, **auf den Beat** geschnitten.
3. **letzte ~3 s CTA:** App-Logo + „Gratis-App für Hundemenschen → banyaro.app" + Pfoten-Badge.
4. **durchgehend:** animierte **Lyric-Untertitel** (Sound-off-Tauglichkeit — die meisten scrollen stumm!).
**Footage:** Bans & Yaros eigenes Material (Renés Hunde) + App-Screenshots wo passend. Schnitt z. B. in **CapCut** (kostenlos, Beat-Marker + Auto-Captions).
**Distribution & Tracking:**
- Kanäle: **Instagram Reels · TikTok · YouTube Shorts** (gleicher Clip) + Facebook (lokale EBE-Gruppen).
- **Per-Kanal-ref-Link** (on-screen + in Bio/Pinned): `banyaro.app/?ref=reel-ig` · `?ref=reel-tt` · `?ref=reel-yt` → zählbar (siehe „Messung").
- Hashtags: #hundeliebe #dogsofinstagram #hundsetiktok + lokal #ebersberg.
- Balladen (Bester Freund, Amy, Blues) zusätzlich als **volles Lyric-Video auf YouTube** = evergreen + SEO.
**Cadence:** Lead-Clips (Platsch!, Leckerli, Friseur, Mobil) in **einer Batch-Session** produzieren, dann **2/Woche** posten über ~23 Wochen. Erst der Pilot, dann Batch.
**Erster Schritt:** **1 Pilot-Clip „Platsch!"** (Saison) als Test posten → Reaktion messen, bevor die Batch läuft.
## 🚀 Ausbau: Live-Tool im Admin-Bereich (optional) ## 🚀 Ausbau: Live-Tool im Admin-Bereich (optional)
Diese Datei deckt Planung/Checkliste ab (Claude pflegt sie). Der **Admin-Bereich** lohnt sich für die Teile mit echten Daten: Diese Datei deckt Planung/Checkliste ab (Claude pflegt sie). Der **Admin-Bereich** lohnt sich für die Teile mit echten Daten:
- **User-Meilenstein-Anzeige** (aktive User) → blendet automatisch den „Outreach Runde 3"-Hinweis ein, sobald ~50 erreicht. - **User-Meilenstein-Anzeige** (aktive User) → blendet automatisch den „Outreach Runde 3"-Hinweis ein, sobald ~50 erreicht.

View file

@ -28,7 +28,7 @@ TAR_EXCLUDE := --exclude='.git' \
--exclude='./.DS_Store' --exclude='./.DS_Store'
.PHONY: help deploy deploy-clean staging release sync push restart build stop status \ .PHONY: help deploy deploy-clean staging release sync push restart build stop status \
logs logs-f shell db dev clean-cache check-ssh reports bump test tiles tiles-deploy logs logs-f shell db dev clean-cache check-ssh reports bump test tiles tiles-deploy album
# ---------------------------------------------------------- # ----------------------------------------------------------
# SSH-Prüfung — Abhängigkeit aller DS-Befehle # SSH-Prüfung — Abhängigkeit aller DS-Befehle
@ -343,6 +343,13 @@ bump:
sed -i.bak -E "s/\?v=[0-9]+/?v=$$NEW/g" backend/static/landing.html && rm -f backend/static/landing.html.bak; \ sed -i.bak -E "s/\?v=[0-9]+/?v=$$NEW/g" backend/static/landing.html && rm -f backend/static/landing.html.bak; \
echo " ✓ APP_VER $$CUR → $$NEW (VERSION, sw.js, app.js, index.html, landing.html aktualisiert)" echo " ✓ APP_VER $$CUR → $$NEW (VERSION, sw.js, app.js, index.html, landing.html aktualisiert)"
# ----------------------------------------------------------
# ALBUM — die zwei Download-ZIPs (DE+EN) neu bauen (Cover, ID3-Tags, Liner Notes)
# Nur nötig, wenn sich Songs/Cover/Beschreibung ändern. Braucht ImageMagick + ffmpeg.
# ----------------------------------------------------------
album:
@bash tools/album-build/build.sh
# ---------------------------------------------------------- # ----------------------------------------------------------
# TEST — Smoke-Tests gegen isolierte Test-DB (kein Docker, kein DS) # TEST — Smoke-Tests gegen isolierte Test-DB (kein Docker, kein DS)
# ---------------------------------------------------------- # ----------------------------------------------------------

View file

@ -1 +1 @@
1300 1303

View file

@ -522,6 +522,7 @@ def _migrate(conn_factory):
# Forum Sprint 11: erweiterte Thread-Felder # Forum Sprint 11: erweiterte Thread-Felder
("forum_threads", "foto_urls", "TEXT"), ("forum_threads", "foto_urls", "TEXT"),
("forum_threads", "is_pinned", "INTEGER NOT NULL DEFAULT 0"), ("forum_threads", "is_pinned", "INTEGER NOT NULL DEFAULT 0"),
("forum_threads", "pin_scope", "TEXT NOT NULL DEFAULT 'global'"),
("forum_threads", "is_locked", "INTEGER NOT NULL DEFAULT 0"), ("forum_threads", "is_locked", "INTEGER NOT NULL DEFAULT 0"),
("forum_threads", "is_deleted", "INTEGER NOT NULL DEFAULT 0"), ("forum_threads", "is_deleted", "INTEGER NOT NULL DEFAULT 0"),
("forum_threads", "likes", "INTEGER NOT NULL DEFAULT 0"), ("forum_threads", "likes", "INTEGER NOT NULL DEFAULT 0"),

View file

@ -375,6 +375,8 @@ app.mount("/js", StaticFiles(directory=f"{STATIC_DIR}/js"), name="js")
app.mount("/icons", StaticFiles(directory=f"{STATIC_DIR}/icons"), name="icons") app.mount("/icons", StaticFiles(directory=f"{STATIC_DIR}/icons"), name="icons")
app.mount("/img", StaticFiles(directory=f"{STATIC_DIR}/img"), name="img") app.mount("/img", StaticFiles(directory=f"{STATIC_DIR}/img"), name="img")
app.mount("/sounds", StaticFiles(directory=f"{STATIC_DIR}/sounds"), name="sounds") # Yaro-Navi-Sounds app.mount("/sounds", StaticFiles(directory=f"{STATIC_DIR}/sounds"), name="sounds") # Yaro-Navi-Sounds
os.makedirs(f"{STATIC_DIR}/downloads", exist_ok=True) # Album-ZIPs (vom Build-Skript erzeugt)
app.mount("/downloads", StaticFiles(directory=f"{STATIC_DIR}/downloads"), name="downloads")
# Selbst-gehostete Vektor-Tiles (.pmtiles) — liegen im data-Volume, NICHT im Image. # Selbst-gehostete Vektor-Tiles (.pmtiles) — liegen im data-Volume, NICHT im Image.
# WICHTIG: Starlettes StaticFiles/FileResponse liefert hinter unserer BaseHTTPMiddleware # WICHTIG: Starlettes StaticFiles/FileResponse liefert hinter unserer BaseHTTPMiddleware

View file

@ -42,6 +42,7 @@ class PostCreate(BaseModel):
class ThreadPatch(BaseModel): class ThreadPatch(BaseModel):
is_pinned: Optional[int] = None is_pinned: Optional[int] = None
is_locked: Optional[int] = None is_locked: Optional[int] = None
pin_scope: Optional[str] = None # 'global' (überall oben) | 'kategorie' (nur im Thema oben)
class ThreadUpdate(BaseModel): class ThreadUpdate(BaseModel):
titel: Optional[str] = Field(None, max_length=200) titel: Optional[str] = Field(None, max_length=200)
@ -71,6 +72,15 @@ class ResolveReport(BaseModel):
resolved: int = 1 resolved: int = 1
def _can_moderate(user) -> bool:
"""Admin ODER Moderator dürfen moderieren (pin/lock/löschen).
Wichtig: Admins haben nicht zwingend das is_moderator-Flag gesetzt
daher zusätzlich die Rolle prüfen (analog auth.require_moderator)."""
if not user:
return False
return user.get('rolle') in ('admin', 'moderator') or bool(user.get('is_moderator'))
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Helpers # Helpers
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -126,12 +136,13 @@ async def list_threads(
user=Depends(get_current_user_optional), user=Depends(get_current_user_optional),
): ):
uid = user['id'] if user else None uid = user['id'] if user else None
has_cat = bool(kategorie and kategorie != 'alle')
with db() as conn: with db() as conn:
q = """ q = """
SELECT t.id, t.kategorie, t.titel, SELECT t.id, t.kategorie, t.titel,
SUBSTR(t.text, 1, 120) AS text_preview, SUBSTR(t.text, 1, 120) AS text_preview,
t.antworten, t.likes, t.views, t.antworten, t.likes, t.views,
t.is_pinned, t.is_locked, t.foto_urls, t.is_pinned, t.pin_scope, t.is_locked, t.foto_urls,
t.created_at, t.user_id, t.created_at, t.user_id,
u.name AS autor_name, u.founder_number AS autor_founder_number u.name AS autor_name, u.founder_number AS autor_founder_number
FROM forum_threads t FROM forum_threads t
@ -139,13 +150,18 @@ async def list_threads(
WHERE t.is_deleted = 0 WHERE t.is_deleted = 0
""" """
params = [] params = []
if kategorie and kategorie != 'alle': if has_cat:
q += " AND t.kategorie = ?" q += " AND t.kategorie = ?"
params.append(kategorie) params.append(kategorie)
if search: if search:
q += " AND (t.titel LIKE ? OR t.text LIKE ?)" q += " AND (t.titel LIKE ? OR t.text LIKE ?)"
params.extend([f'%{search}%', f'%{search}%']) params.extend([f'%{search}%', f'%{search}%'])
q += " ORDER BY t.is_pinned DESC, t.created_at DESC LIMIT ? OFFSET ?" # Kategorie-Ansicht: globale UND Themen-Pins steigen nach oben.
# "Alle"-Ansicht: nur globale Pins oben — Themen-Pins bleiben in ihrem Thema.
if has_cat:
q += " ORDER BY t.is_pinned DESC, t.created_at DESC LIMIT ? OFFSET ?"
else:
q += " ORDER BY (t.is_pinned = 1 AND t.pin_scope = 'global') DESC, t.created_at DESC LIMIT ? OFFSET ?"
params.extend([limit, offset]) params.extend([limit, offset])
rows = conn.execute(q, params).fetchall() rows = conn.execute(q, params).fetchall()
@ -323,7 +339,7 @@ async def delete_thread(thread_id: int, user=Depends(get_current_user)):
).fetchone() ).fetchone()
if not thread: if not thread:
raise HTTPException(404, "Thread nicht gefunden.") raise HTTPException(404, "Thread nicht gefunden.")
if thread['user_id'] != user['id'] and not user.get('is_moderator'): if thread['user_id'] != user['id'] and not _can_moderate(user):
raise HTTPException(403, "Keine Berechtigung.") raise HTTPException(403, "Keine Berechtigung.")
conn.execute( conn.execute(
"UPDATE forum_threads SET is_deleted = 1 WHERE id = ?", (thread_id,) "UPDATE forum_threads SET is_deleted = 1 WHERE id = ?", (thread_id,)
@ -335,7 +351,7 @@ async def delete_thread(thread_id: int, user=Depends(get_current_user)):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@router.patch("/threads/{thread_id}") @router.patch("/threads/{thread_id}")
async def patch_thread(thread_id: int, data: ThreadPatch, user=Depends(get_current_user)): async def patch_thread(thread_id: int, data: ThreadPatch, user=Depends(get_current_user)):
if not user.get('is_moderator'): if not _can_moderate(user):
raise HTTPException(403, "Nur Moderatoren können Threads bearbeiten.") raise HTTPException(403, "Nur Moderatoren können Threads bearbeiten.")
with db() as conn: with db() as conn:
thread = conn.execute( thread = conn.execute(
@ -345,6 +361,8 @@ async def patch_thread(thread_id: int, data: ThreadPatch, user=Depends(get_curre
raise HTTPException(404, "Thread nicht gefunden.") raise HTTPException(404, "Thread nicht gefunden.")
updates = data.model_dump(exclude_none=True) updates = data.model_dump(exclude_none=True)
if 'pin_scope' in updates and updates['pin_scope'] not in ('global', 'kategorie'):
raise HTTPException(400, "Ungültiger pin_scope (erlaubt: 'global', 'kategorie').")
if updates: if updates:
cols = ', '.join(f"{k} = ?" for k in updates) cols = ', '.join(f"{k} = ?" for k in updates)
conn.execute( conn.execute(
@ -476,7 +494,7 @@ async def delete_post(post_id: int, user=Depends(get_current_user)):
).fetchone() ).fetchone()
if not post: if not post:
raise HTTPException(404, "Beitrag nicht gefunden.") raise HTTPException(404, "Beitrag nicht gefunden.")
if post['user_id'] != user['id'] and not user.get('is_moderator'): if post['user_id'] != user['id'] and not _can_moderate(user):
raise HTTPException(403, "Keine Berechtigung.") raise HTTPException(403, "Keine Berechtigung.")
conn.execute( conn.execute(
"UPDATE forum_posts SET is_deleted = 1 WHERE id = ?", (post_id,) "UPDATE forum_posts SET is_deleted = 1 WHERE id = ?", (post_id,)
@ -504,7 +522,7 @@ async def upload_thread_foto(
).fetchone() ).fetchone()
if not thread: if not thread:
raise HTTPException(404, "Thread nicht gefunden.") raise HTTPException(404, "Thread nicht gefunden.")
if thread['user_id'] != user['id'] and not user.get('is_moderator'): if thread['user_id'] != user['id'] and not _can_moderate(user):
raise HTTPException(403, "Keine Berechtigung.") raise HTTPException(403, "Keine Berechtigung.")
existing = _parse_foto_urls(thread['foto_urls']) existing = _parse_foto_urls(thread['foto_urls'])
@ -537,7 +555,7 @@ async def upload_post_foto(
).fetchone() ).fetchone()
if not post: if not post:
raise HTTPException(404, "Beitrag nicht gefunden.") raise HTTPException(404, "Beitrag nicht gefunden.")
if post['user_id'] != user['id'] and not user.get('is_moderator'): if post['user_id'] != user['id'] and not _can_moderate(user):
raise HTTPException(403, "Keine Berechtigung.") raise HTTPException(403, "Keine Berechtigung.")
existing = _parse_foto_urls(post['foto_urls']) existing = _parse_foto_urls(post['foto_urls'])
@ -642,7 +660,7 @@ async def report_content(data: ReportBody, user=Depends(get_current_user)):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@router.get("/reports") @router.get("/reports")
async def list_reports(user=Depends(get_current_user)): async def list_reports(user=Depends(get_current_user)):
if not user.get('is_moderator'): if not _can_moderate(user):
raise HTTPException(403, "Nur Moderatoren.") raise HTTPException(403, "Nur Moderatoren.")
with db() as conn: with db() as conn:
rows = conn.execute( rows = conn.execute(
@ -660,7 +678,7 @@ async def list_reports(user=Depends(get_current_user)):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@router.patch("/reports/{report_id}") @router.patch("/reports/{report_id}")
async def resolve_report(report_id: int, data: ResolveReport, user=Depends(get_current_user)): async def resolve_report(report_id: int, data: ResolveReport, user=Depends(get_current_user)):
if not user.get('is_moderator'): if not _can_moderate(user):
raise HTTPException(403, "Nur Moderatoren.") raise HTTPException(403, "Nur Moderatoren.")
with db() as conn: with db() as conn:
conn.execute( conn.execute(

View file

@ -8307,6 +8307,10 @@ svg.empty-state-icon {
.album-title { font-size: var(--text-base); font-weight: 700; color: var(--c-text); } .album-title { font-size: var(--text-base); font-weight: 700; color: var(--c-text); }
.album-subtitle { font-size: var(--text-xs); color: var(--c-text-muted); margin-top: 2px; } .album-subtitle { font-size: var(--text-xs); color: var(--c-text-muted); margin-top: 2px; }
.album-close { background: none; border: none; font-size: 1.5rem; line-height: 1; color: var(--c-text-muted); cursor: pointer; padding: 0 4px; } .album-close { background: none; border: none; font-size: 1.5rem; line-height: 1; color: var(--c-text-muted); cursor: pointer; padding: 0 4px; }
.album-head-actions { display: flex; align-items: center; gap: var(--space-2); flex-shrink: 0; }
.album-lang { display: inline-flex; border: 1px solid var(--c-border); border-radius: var(--radius-full); overflow: hidden; background: var(--c-surface-2); }
.album-lang-btn { background: none; border: none; cursor: pointer; padding: 4px 10px; font-size: var(--text-xs); font-weight: 700; color: var(--c-text-muted); line-height: 1.4; transition: background .15s, color .15s; }
.album-lang-btn.is-active { background: var(--c-primary); color: #fff; }
.album-list { display: flex; flex-direction: column; gap: var(--space-2); } .album-list { display: flex; flex-direction: column; gap: var(--space-2); }
.album-song { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3); border-radius: var(--radius-md); background: var(--c-surface-2); cursor: pointer; transition: background .15s; } .album-song { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3); border-radius: var(--radius-md); background: var(--c-surface-2); cursor: pointer; transition: background .15s; }
.album-song:active { background: var(--c-border); } .album-song:active { background: var(--c-border); }

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

View file

@ -86,14 +86,14 @@
<title>Ban Yaro</title> <title>Ban Yaro</title>
<!-- Theme + theme-color Statusleiste vor CSS setzen --> <!-- Theme + theme-color Statusleiste vor CSS setzen -->
<script src="/js/boot-early.js?v=1300"></script> <script src="/js/boot-early.js?v=1303"></script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung --> <!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=1300"> <link rel="stylesheet" href="/css/design-system.css?v=1303">
<link rel="stylesheet" href="/css/layout.css?v=1300"> <link rel="stylesheet" href="/css/layout.css?v=1303">
<link rel="stylesheet" href="/css/components.css?v=1300"> <link rel="stylesheet" href="/css/components.css?v=1303">
<link rel="stylesheet" href="/css/utilities.css?v=1300"> <link rel="stylesheet" href="/css/utilities.css?v=1303">
<link rel="stylesheet" href="/css/lists.css?v=1300"> <link rel="stylesheet" href="/css/lists.css?v=1303">
</head> </head>
<body> <body>
@ -624,12 +624,12 @@
<div id="modal-container"></div> <div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features --> <!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=1300"></script> <script src="/js/api.js?v=1303"></script>
<script src="/js/ui.js?v=1300"></script> <script src="/js/ui.js?v=1303"></script>
<script src="/js/app.js?v=1300"></script> <script src="/js/app.js?v=1303"></script>
<script src="/js/worlds.js?v=1300"></script> <script src="/js/worlds.js?v=1303"></script>
<script src="/js/offline-indicator.js?v=1300"></script> <script src="/js/offline-indicator.js?v=1303"></script>
<script src="/js/contact-form.js?v=1300"></script> <script src="/js/contact-form.js?v=1303"></script>
<!-- Feature-Seiten werden lazy geladen --> <!-- Feature-Seiten werden lazy geladen -->
@ -639,7 +639,7 @@
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) --> <!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
<script src="/js/boot.js?v=1300"></script> <script src="/js/boot.js?v=1303"></script>
</body> </body>

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. Router, State-Management, Navigation, Initialisierung.
============================================================ */ ============================================================ */
const APP_VER = '1300'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VER = '1303'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator) window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
window.APP_VERSION = APP_VERSION; window.APP_VERSION = APP_VERSION;

View file

@ -82,7 +82,8 @@ function _fmtDate(iso) {
// RENDER — Grundstruktur // RENDER — Grundstruktur
// ---------------------------------------------------------- // ----------------------------------------------------------
function _render() { function _render() {
const isMod = !!_appState.user?.is_moderator; const _u = _appState.user;
const isMod = !!(_u && (_u.rolle === 'admin' || _u.rolle === 'moderator' || _u.is_moderator));
_container.innerHTML = ` _container.innerHTML = `
<div class="forum-layout"> <div class="forum-layout">
@ -438,7 +439,7 @@ function _fmtDate(iso) {
const preview = t.text_preview const preview = t.text_preview
? UI.escape(t.text_preview.slice(0, 120)) + (t.text_preview.length >= 120 ? '…' : '') ? UI.escape(t.text_preview.slice(0, 120)) + (t.text_preview.length >= 120 ? '…' : '')
: ''; : '';
const pinBadge = t.is_pinned ? `<span class="forum-pin-badge" title="Angepinnt">${UI.icon('push-pin')}</span>` : ''; const pinBadge = t.is_pinned ? `<span class="forum-pin-badge" title="${t.pin_scope === 'kategorie' ? 'Im Thema angepinnt' : 'Angepinnt'}">${UI.icon('push-pin')}</span>` : '';
const lockBadge = t.is_locked ? `<span class="forum-lock-badge" title="Gesperrt">${UI.icon('lock')}</span>` : ''; const lockBadge = t.is_locked ? `<span class="forum-lock-badge" title="Gesperrt">${UI.icon('lock')}</span>` : '';
const fotoHtml = t.foto_preview const fotoHtml = t.foto_preview
? /\.(mp4|mov|webm|m4v|avi)$/i.test(t.foto_preview) ? /\.(mp4|mov|webm|m4v|avi)$/i.test(t.foto_preview)
@ -515,14 +516,25 @@ function _fmtDate(iso) {
} }
const uid = _appState.user?.id; const uid = _appState.user?.id;
const isMod = !!_appState.user?.is_moderator; const _u = _appState.user;
const isMod = !!(_u && (_u.rolle === 'admin' || _u.rolle === 'moderator' || _u.is_moderator));
const isOwn = uid && uid === thread.user_id; const isOwn = uid && uid === thread.user_id;
const pinControls = thread.is_pinned
? `<span class="forum-pin-state" style="display:inline-flex;align-items:center;gap:4px;font-size:var(--text-sm);color:var(--c-text-secondary)">
${UI.icon('push-pin')} Angepinnt${thread.pin_scope === 'kategorie' ? ` (Thema „${UI.escape(thread.kategorie)}")` : ' (global)'}
</span>
<button class="btn btn-ghost btn-sm forum-mod-unpin" title="Anpinnen aufheben">Lösen</button>`
: `<button class="btn btn-ghost btn-sm forum-mod-pin-global" title="Überall ganz oben halten">
${UI.icon('push-pin')} Global anpinnen
</button>
<button class="btn btn-ghost btn-sm forum-mod-pin-cat" title="Nur im Thema „${UI.escape(thread.kategorie)}" oben halten">
${UI.icon('push-pin')} Im Thema anpinnen
</button>`;
const modToolbar = (isMod) ? ` const modToolbar = (isMod) ? `
<div class="forum-mod-toolbar"> <div class="forum-mod-toolbar">
<button class="btn btn-ghost btn-sm forum-mod-pin" title="${thread.is_pinned ? 'Unpin' : 'Anpinnen'}"> ${pinControls}
${UI.icon('push-pin')} ${thread.is_pinned ? 'Unpin' : 'Pin'}
</button>
<button class="btn btn-ghost btn-sm forum-mod-lock" title="${thread.is_locked ? 'Entsperren' : 'Sperren'}"> <button class="btn btn-ghost btn-sm forum-mod-lock" title="${thread.is_locked ? 'Entsperren' : 'Sperren'}">
${UI.icon('lock')} ${thread.is_locked ? 'Entsperren' : 'Sperren'} ${UI.icon('lock')} ${thread.is_locked ? 'Entsperren' : 'Sperren'}
</button> </button>
@ -677,14 +689,20 @@ function _fmtDate(iso) {
}); });
// Moderator: pin/lock/delete // Moderator: pin/lock/delete
document.querySelector('.forum-mod-pin')?.addEventListener('click', async () => { const _applyPin = async (payload) => {
try { try {
await API.forum.patchThread(thread.id, { is_pinned: thread.is_pinned ? 0 : 1 }); await API.forum.patchThread(thread.id, payload);
UI.toast.success('Gespeichert.'); UI.toast.success('Gespeichert.');
UI.modal.close(); UI.modal.close();
_loadThreads(true); _loadThreads(true);
} catch (err) { UI.toast.error(err.message); } } catch (err) { UI.toast.error(err.message); }
}); };
document.querySelector('.forum-mod-pin-global')?.addEventListener('click',
() => _applyPin({ is_pinned: 1, pin_scope: 'global' }));
document.querySelector('.forum-mod-pin-cat')?.addEventListener('click',
() => _applyPin({ is_pinned: 1, pin_scope: 'kategorie' }));
document.querySelector('.forum-mod-unpin')?.addEventListener('click',
() => _applyPin({ is_pinned: 0 }));
document.querySelector('.forum-mod-lock')?.addEventListener('click', async () => { document.querySelector('.forum-mod-lock')?.addEventListener('click', async () => {
try { try {

View file

@ -877,6 +877,35 @@ window.Page_settings = (() => {
</div> </div>
</div> </div>
<!-- Album herunterladen -->
<div class="card mb-4">
<div class="by-card-section-header">Ban Yaro das Album</div>
<div style="padding:var(--space-4);display:flex;gap:var(--space-4);align-items:center">
<img src="/img/banyaro/album-thumb.jpg" alt="Ban Yaro — Album-Cover"
width="84" height="84" loading="lazy"
style="width:84px;height:84px;border-radius:var(--radius-md);object-fit:cover;flex-shrink:0">
<div style="min-width:0">
<div style="font-weight:600">7 Songs zum Behalten 🎸</div>
<div class="text-xs-secondary" style="margin-top:2px">
Das ganze Album als Download auf Deutsch oder Englisch.
Behalten &amp; teilen ausdrücklich erwünscht.
</div>
</div>
</div>
<div style="padding:0 var(--space-4) var(--space-4);display:flex;gap:var(--space-2);flex-wrap:wrap">
<a class="btn btn-primary" href="/downloads/ban-yaro-album-de.zip"
download="Ban Yaro - Das Album.zip"
style="flex:1;min-width:140px;justify-content:center;text-decoration:none">
${UI.icon('download-simple')} Deutsch · 33 MB
</a>
<a class="btn btn-secondary" href="/downloads/ban-yaro-album-en.zip"
download="Ban Yaro - The Album.zip"
style="flex:1;min-width:140px;justify-content:center;text-decoration:none">
${UI.icon('download-simple')} English · 36 MB
</a>
</div>
</div>
<!-- App empfehlen --> <!-- App empfehlen -->
<div class="card" style="margin-bottom:var(--space-5)" id="referral-card"> <div class="card" style="margin-bottom:var(--space-5)" id="referral-card">
<div style="padding:var(--space-4);border-bottom:1px solid var(--c-border)"> <div style="padding:var(--space-4);border-bottom:1px solid var(--c-border)">

View file

@ -1941,7 +1941,11 @@ window.Worlds = (() => {
// zentral in index.html → übersteht Re-Renders & Welt-Wechsel. // zentral in index.html → übersteht Re-Renders & Welt-Wechsel.
const _anthem = (() => { const _anthem = (() => {
const KEY = 'by_anthem_heard'; const KEY = 'by_anthem_heard';
const SONGS = [ const LANG_KEY = 'by_album_lang';
// EN-Album erst sichtbar, wenn die 7 *-en.mp3 in static/sounds/ liegen.
// Aktivieren: Dateien ablegen → EN_READY = true → make bump → deploy.
const EN_READY = true; // 2026-06-16: 7 *-en.mp3 liegen in static/sounds/
const SONGS_DE = [
{ title: 'Ban Yaro Blues', sub: 'Die Hymne', file: '/sounds/ban-yaro-blues.mp3?v=2' }, { title: 'Ban Yaro Blues', sub: 'Die Hymne', file: '/sounds/ban-yaro-blues.mp3?v=2' },
{ title: 'Ban Yaro Mobil', sub: 'Erste Fahrt im Anhänger', file: '/sounds/ban-yaro-mobil.mp3' }, { title: 'Ban Yaro Mobil', sub: 'Erste Fahrt im Anhänger', file: '/sounds/ban-yaro-mobil.mp3' },
{ title: 'Amy', sub: 'Eine Liebesromanze', file: '/sounds/amy.mp3' }, { title: 'Amy', sub: 'Eine Liebesromanze', file: '/sounds/amy.mp3' },
@ -1950,6 +1954,19 @@ window.Worlds = (() => {
{ title: 'Platsch!', sub: 'Ab ins kühle Nass', file: '/sounds/platsch.mp3' }, { title: 'Platsch!', sub: 'Ab ins kühle Nass', file: '/sounds/platsch.mp3' },
{ title: 'Bester Freund', sub: 'Du und ich', file: '/sounds/bester-freund.mp3' }, { title: 'Bester Freund', sub: 'Du und ich', file: '/sounds/bester-freund.mp3' },
]; ];
const SONGS_EN = [
{ title: 'Ban Yaro Blues', sub: 'The anthem', file: '/sounds/ban-yaro-blues-en.mp3' },
{ title: 'Ban Yaro Mobile', sub: 'First ride in the trailer', file: '/sounds/ban-yaro-mobil-en.mp3' },
{ title: 'Amy', sub: 'A love duet', file: '/sounds/amy-en.mp3' },
{ title: "At the Groomer's", sub: 'Half the fur, all the energy', file: '/sounds/at-the-groomers-en.mp3' },
{ title: 'Treat Paradise', sub: 'Full bowl, full heart', file: '/sounds/treat-paradise-en.mp3' },
{ title: 'Splash!', sub: 'Into the cool water', file: '/sounds/splash-en.mp3' },
{ title: 'Best Friend', sub: 'You and me', file: '/sounds/best-friend-en.mp3' },
];
let _lang = (() => {
try { return (EN_READY && localStorage.getItem(LANG_KEY) === 'en') ? 'en' : 'de'; } catch (_) { return 'de'; }
})();
const _songs = () => (_lang === 'en' && EN_READY) ? SONGS_EN : SONGS_DE;
let _bound = false, _curIdx = -1; let _bound = false, _curIdx = -1;
const _audio = () => document.getElementById('anthem-audio'); const _audio = () => document.getElementById('anthem-audio');
// Entdeckt? Server-Flag (geräteübergreifend, deploy-fest) ODER lokal (sofort/offline). // Entdeckt? Server-Flag (geräteübergreifend, deploy-fest) ODER lokal (sofort/offline).
@ -1988,17 +2005,18 @@ window.Worlds = (() => {
a.addEventListener('play', _sync); a.addEventListener('play', _sync);
a.addEventListener('pause', _sync); a.addEventListener('pause', _sync);
a.addEventListener('ended', () => { // automatisch zum nächsten Song a.addEventListener('ended', () => { // automatisch zum nächsten Song
if (_curIdx >= 0 && _curIdx < SONGS.length - 1) _play(_curIdx + 1); if (_curIdx >= 0 && _curIdx < _songs().length - 1) _play(_curIdx + 1);
else { _curIdx = -1; _sync(); } else { _curIdx = -1; _sync(); }
}); });
} }
function _play(i) { function _play(i) {
const a = _audio(); const a = _audio();
if (!a || !SONGS[i]) return; const songs = _songs();
if (!a || !songs[i]) return;
if (i === _curIdx && !a.paused) { a.pause(); return; } // aktiven Song pausieren if (i === _curIdx && !a.paused) { a.pause(); return; } // aktiven Song pausieren
_curIdx = i; _curIdx = i;
a.src = SONGS[i].file; a.src = songs[i].file;
a.play().catch(() => {}); a.play().catch(() => {});
_markHeard(); _markHeard();
_sync(); _sync();
@ -2006,39 +2024,67 @@ window.Worlds = (() => {
function _closeAlbum() { document.getElementById('album-modal')?.remove(); } function _closeAlbum() { document.getElementById('album-modal')?.remove(); }
// Sprache wechseln: aktuelle Wiedergabe stoppen (andere Datei) und Liste neu zeichnen.
function _setLang(l) {
if (l === _lang || !EN_READY) return;
_lang = l;
try { localStorage.setItem(LANG_KEY, l); } catch (_) {}
const a = _audio(); if (a) a.pause();
_curIdx = -1;
_fillAlbum();
_sync();
}
// Inhalt des Sheets (neu) rendern + innere Controls binden — auch bei Sprachwechsel.
function _fillAlbum() {
const sheet = document.querySelector('#album-modal .album-sheet');
if (!sheet) return;
const songs = _songs();
const en = _lang === 'en';
sheet.innerHTML = `
<div class="album-head">
<div>
<div class="album-title">${en ? 'Ban Yaro — The Album' : 'Ban Yaro — das Album'}</div>
<div class="album-subtitle">${songs.length} ${en ? 'songs · homemade' : 'Songs · selbst gemacht'} 🎸</div>
</div>
<div class="album-head-actions">
${EN_READY ? `
<div class="album-lang" role="group" aria-label="Sprache / Language">
<button class="album-lang-btn ${en ? '' : 'is-active'}" data-lang="de" type="button">DE</button>
<button class="album-lang-btn ${en ? 'is-active' : ''}" data-lang="en" type="button">EN</button>
</div>` : ''}
<button class="album-close" aria-label="${en ? 'Close' : 'Schließen'}">×</button>
</div>
</div>
<div class="album-list">
${songs.map((s, i) => `
<div class="album-song" data-i="${i}" role="button" tabindex="0">
<span class="album-song-play"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#play"></use></svg></span>
<span class="album-song-meta">
<span class="album-song-title">${_esc(s.title)}</span>
<span class="album-song-sub">${_esc(s.sub)}</span>
</span>
</div>`).join('')}
</div>`;
sheet.querySelector('.album-close').addEventListener('click', _closeAlbum);
sheet.querySelectorAll('.album-lang-btn').forEach(b =>
b.addEventListener('click', () => _setLang(b.dataset.lang)));
sheet.querySelectorAll('.album-song').forEach(row => {
const i = parseInt(row.dataset.i, 10);
row.addEventListener('click', () => _play(i));
row.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _play(i); } });
});
}
function openAlbum() { function openAlbum() {
_markHeard(); _markHeard();
if (document.getElementById('album-modal')) return; if (document.getElementById('album-modal')) return;
const ov = document.createElement('div'); const ov = document.createElement('div');
ov.id = 'album-modal'; ov.id = 'album-modal';
ov.innerHTML = ` ov.innerHTML = `<div class="album-sheet"></div>`;
<div class="album-sheet">
<div class="album-head">
<div>
<div class="album-title">Ban Yaro das Album</div>
<div class="album-subtitle">${SONGS.length} Songs · selbst gemacht 🎸</div>
</div>
<button class="album-close" aria-label="Schließen">×</button>
</div>
<div class="album-list">
${SONGS.map((s, i) => `
<div class="album-song" data-i="${i}" role="button" tabindex="0">
<span class="album-song-play"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#play"></use></svg></span>
<span class="album-song-meta">
<span class="album-song-title">${_esc(s.title)}</span>
<span class="album-song-sub">${_esc(s.sub)}</span>
</span>
</div>`).join('')}
</div>
</div>`;
document.body.appendChild(ov); document.body.appendChild(ov);
ov.addEventListener('click', e => { if (e.target === ov) _closeAlbum(); }); ov.addEventListener('click', e => { if (e.target === ov) _closeAlbum(); });
ov.querySelector('.album-close').addEventListener('click', _closeAlbum); _fillAlbum();
ov.querySelectorAll('.album-song').forEach(row => {
const i = parseInt(row.dataset.i, 10);
row.addEventListener('click', () => _play(i));
row.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _play(i); } });
});
_sync(); _sync();
} }
@ -2061,7 +2107,7 @@ window.Worlds = (() => {
updateButton(); updateButton();
} }
return { heard, toggle: openAlbum, updateButton, initWelt, count: SONGS.length }; return { heard, toggle: openAlbum, updateButton, initWelt, count: SONGS_DE.length };
})(); })();
function _renderWelt() { function _renderWelt() {

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<script src="/js/landing-init.js?v=1300"></script> <script src="/js/landing-init.js?v=1303"></script>
<title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title> <title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title>
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, direkt im Browser oder als native iPhone-App (Ban Yaro Go)."> <meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, direkt im Browser oder als native iPhone-App (Ban Yaro Go).">
<meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz"> <meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz">

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -4,7 +4,7 @@
============================================================ */ ============================================================ */
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab
const VER = '1300'; const VER = '1303';
const CACHE_VERSION = `by-v${VER}`; const CACHE_VERSION = `by-v${VER}`;
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten

View file

@ -0,0 +1,80 @@
# Platsch! — Reel-Shotlist (Pilot-Clip)
**Zweck:** Erster Social-Pilot der Song-Reel-Serie (siehe `MARKETING.md` → „Song-Reel / Short-Video").
**Ziel-Kennzahl:** App-Installs pro Clip (nicht Tantiemen) → ref-Link unten.
**Song:** `Platsch Nassrein.mp3` / live als `/sounds/platsch.mp3`. Funk-Blues, ~96 BPM, A-Moll, bass-getrieben, sommerlich.
**Format:** vertikal 9:16, ~1620 s, Ton **kommerziell frei** (Suno Pro).
**Saison:** Sommer = JETZT ideal — zeitnah raus.
---
## Audio-Segment
Nicht den ganzen Song — nur den **Refrain** als Clip-Audio (sofortiger Hook):
> Platsch! — und ich bin drin,
> Platsch! — kein Halten mehr.
> Schütteln, lachen, nochmal rein —
> Wasser, ich komm rein!
Danach 12 s Bass-Groove als Auslauf für die Endcard.
**Beat-Raster:** 96 BPM → 1 Beat = 0,625 s, 1 Takt = 2,5 s. In CapCut **Beat-Marker** setzen; die beiden **„Platsch!"** sind die wichtigsten Sync-Punkte (Wasser-Aufprall genau aufs Wort).
---
## Teil A — Was filmen (Roh-Shots mit Ban & Yaro)
In **Slow-Mo (120/240 fps)** filmen, damit Spritzer episch werden. Möglichst direkt **vertikal** oder mit Rand.
| # | Shot | Hinweis |
|---|---|---|
| A1 | **Sprung ins Wasser**, Seitenprofil | DER Schlüssel-Shot, Slow-Mo, großer Spritzer |
| A2 | Auftauchen / Paddeln, Nahaufnahme Gesicht | glückliche Augen |
| A3 | Rennen durchs flache Wasser **auf die Kamera zu** | niedrige Perspektive, Spray |
| A4 | **Schüttel-Shot**, Slow-Mo, Gegenlicht | Tropfen fliegen radial — sehr fotogen |
| A5 | Pfote schlägt aufs Wasser / Sprung in Pfütze | Detail, anderer Winkel |
| A6 | Zufrieden, tropfnass, **Blick in die Kamera** | ruhiger Schluss-Shot |
| B | Schnittbilder | Wasserspiegelung, Ufer, Sommerstimmung |
---
## Teil B — Schnitt-Timeline (auf den Beat)
| Clip-Zeit | Audio-Cue | Shot | On-Screen-Text | Schnitt |
|---|---|---|---|---|
| 0,01,3 s | **„Platsch!"** (1) | A1 Sprung, Aufprall **genau aufs Wort** | **„PLATSCH!"** groß, zentriert | 0,2 s Freeze auf Impact-Frame |
| 1,32,6 s | „und ich bin drin," | A2 Auftauchen/Paddeln | Lyric-Untertitel unten | Cut auf Beat |
| 2,63,9 s | **„Platsch!"** (2) | A5 Pfote/Pfütze, anderer Winkel | **„PLATSCH!"** Pop | Cut aufs Wort |
| 3,95,2 s | „kein Halten mehr." | A3 Rennen auf Kamera zu | Untertitel | Beat-Cut |
| 5,27,0 s | „Schütteln, lachen, nochmal rein —" | A4 Schüttel-Slow-Mo, Gegenlicht | „Schütteln, lachen…" | Slow-Mo rein auf „Schütteln" |
| 7,09,0 s | „Wasser, ich komm rein!" | A1 Sprung **nochmal** (Thema „nochmal rein") | Untertitel | Beat-Cut |
| 9,011 s | Bass-Groove | Mikro-Montage: 3× ~0,5 s (Plansch/Grinsen/Schütteln) | — | schnelle Beat-Cuts |
| 1113 s | „Platsch…" / Bass-Fade | A6 zufrieden, Blick in Kamera | — | ruhiger Ausklang |
| 1316 s | Bass-Fade | **Endcard** (Logo-BG oder über letztem Frame) | App-Logo + „Ban Yaro — Gratis-App für Hundemenschen" + **banyaro.app** + 🐾 | sanfter Fade-in |
---
## Untertitel & Sound-off
Die meisten scrollen **stumm** → animierte Lyric-Untertitel sind Pflicht (CapCut Auto-Captions, dann je Zeile timen). „Platsch!" größer/farbig als Akzent.
## CTA & Tracking
- Endcard-Link & Bio/Pinned: **`banyaro.app/?ref=reel-ig`** (bzw. `-tt`, `-yt` je Kanal) — zählbar via „Messung" in `MARKETING.md`.
- Kanäle: Instagram Reels · TikTok · YouTube Shorts (gleicher Clip) + lokale FB-Gruppen (EBE).
## Caption-Vorschlag
> Wenn's heiß ist, gibt's für Ban Yaro nur eine Lösung 💦 **Platsch!** 🐾
> Song aus unserem selbstgemachten Album 🎸
> Mehr Hundeliebe (Gratis-App) → banyaro.app
> #hundeliebe #dogsofinstagram #hundevideo #sommer #ebersberg
## Tools / Workflow
1. Roh-Shots A1A6 sammeln/filmen (Slow-Mo!).
2. CapCut: Audio rein → Beat-Marker → Shots auf die Marker ziehen.
3. Auto-Captions → Lyrics anpassen, „Platsch!" betonen.
4. Endcard + ref-Link.
5. Export 9:16, 1080p → IG/TikTok/Shorts + FB.
6. Reaktion messen, **dann** Batch (Leckerli-Paradies, Beim Friseur, Ban Yaro Mobil).

102
tests/test_forum_pinning.py Normal file
View file

@ -0,0 +1,102 @@
"""Forum-Anpinnen: Berechtigung (Admin/Moderator) + Scope-Sortierung.
Deckt zwei neue Verhalten ab:
1. Admins OHNE is_moderator-Flag dürfen anpinnen (Rolle zählt, nicht nur Flag).
2. pin_scope='global' hält oben in JEDER Ansicht, pin_scope='kategorie' nur in
der gefilterten Kategorie (nicht in der "Alle"-Liste).
Hinweise zu den Fixtures/Route-Constraints:
- `admin`-Fixture setzt rolle='admin' direkt in der DB, OHNE is_moderator.
- forum.create_thread hat 30s-Cooldown + 5-Threads/Stunde daher gestaffelte
client_time. Texte sind eindeutig (token_hex) gegen den Duplikat-Check.
- Kategorie 'tauschboerse' isoliert die Sortier-Tests von anderen Suiten;
Assertions vergleichen nur die RELATIVE Reihenfolge der eigenen Thread-IDs.
"""
from __future__ import annotations
import secrets
def _mk_thread(client, headers, kategorie, titel, client_time):
r = client.post(
"/api/forum/threads",
headers=headers,
json={
"kategorie": kategorie,
"titel": titel,
"text": f"{titel}: Hallo zusammen, ein Testbeitrag {secrets.token_hex(8)}.",
"client_time": client_time,
},
)
assert r.status_code == 201, f"create_thread failed: {r.status_code} {r.text}"
return r.json()
class TestForumPinPermission:
def test_admin_without_moderator_flag_can_pin(self, client, admin):
"""rolle='admin' ohne is_moderator darf anpinnen (Kernursache-Fix)."""
t = _mk_thread(client, admin["headers"], "tauschboerse", "Admin Pin", "2026-06-18T08:00:00")
r = client.patch(
f"/api/forum/threads/{t['id']}",
headers=admin["headers"],
json={"is_pinned": 1, "pin_scope": "global"},
)
assert r.status_code == 200, f"{r.status_code} {r.text}"
body = r.json()
assert body["is_pinned"] == 1
assert body["pin_scope"] == "global"
def test_normal_user_cannot_pin(self, client, user):
"""Normaler User → 403, Anpinnen bleibt Admin/Mod vorbehalten."""
t = _mk_thread(client, user["headers"], "tauschboerse", "User Pin", "2026-06-18T08:00:00")
r = client.patch(
f"/api/forum/threads/{t['id']}",
headers=user["headers"],
json={"is_pinned": 1, "pin_scope": "global"},
)
assert r.status_code == 403
def test_invalid_pin_scope_rejected(self, client, admin):
"""Ungültiger pin_scope → 400."""
t = _mk_thread(client, admin["headers"], "tauschboerse", "Bad Scope", "2026-06-18T08:00:00")
r = client.patch(
f"/api/forum/threads/{t['id']}",
headers=admin["headers"],
json={"is_pinned": 1, "pin_scope": "bogus"},
)
assert r.status_code == 400
class TestForumPinScopeSorting:
def test_scope_controls_where_thread_floats(self, client, admin):
h = admin["headers"]
t1 = _mk_thread(client, h, "tauschboerse", "Erster", "2026-06-18T09:00:00")
t2 = _mk_thread(client, h, "tauschboerse", "Zweiter", "2026-06-18T09:20:00")
t3 = _mk_thread(client, h, "tauschboerse", "Dritter", "2026-06-18T09:40:00")
ids = {t1["id"], t2["id"], t3["id"]}
def order(kategorie=None):
url = "/api/forum/threads?limit=200"
if kategorie:
url += f"&kategorie={kategorie}"
r = client.get(url, headers=h)
assert r.status_code == 200
return [x["id"] for x in r.json() if x["id"] in ids]
# Ohne Pin: neueste zuerst.
assert order() == [t3["id"], t2["id"], t1["id"]]
# Ältesten Thread GLOBAL anpinnen → oben in "Alle" UND in der Kategorie.
r = client.patch(f"/api/forum/threads/{t1['id']}", headers=h,
json={"is_pinned": 1, "pin_scope": "global"})
assert r.status_code == 200
assert order()[0] == t1["id"]
assert order("tauschboerse")[0] == t1["id"]
# Auf Themen-Pin umstellen → NUR in der Kategorie oben, in "Alle" wieder nach Datum.
r = client.patch(f"/api/forum/threads/{t1['id']}", headers=h,
json={"pin_scope": "kategorie"})
assert r.status_code == 200
assert order() == [t3["id"], t2["id"], t1["id"]] # "Alle": Themen-Pin zählt nicht
assert order("tauschboerse")[0] == t1["id"] # Kategorie: oben

93
tools/album-build/build.sh Executable file
View file

@ -0,0 +1,93 @@
#!/usr/bin/env bash
# Baut die zwei Album-Downloads (DE + EN) reproduzierbar:
# Cover (Foto quadr. + Wortmarke) → ID3-Tags + eingebettetes Cover → ZIP mit Liner Notes.
# Quelle: die committeten MP3s in backend/static/sounds/ + ein Hi-Res-Foto.
# Ausgabe: backend/static/downloads/ban-yaro-album-{de,en}.zip + img/banyaro/album-thumb.jpg
# Benötigt: ImageMagick (magick), ffmpeg, zip. Aufruf: make album (oder bash tools/album-build/build.sh)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
SND="$ROOT/backend/static/sounds"
PHOTO="$ROOT/backend/static/img/banyaro/hires/banyaro_fruehling_playdate_hires.jpg"
FONT="/System/Library/Fonts/Supplemental/Arial Bold.ttf"
BUILD="$ROOT/tools/album-build"
DIST="$BUILD/dist"
DL="$ROOT/backend/static/downloads"
IMG="$ROOT/backend/static/img/banyaro"
rm -rf "$DIST"; mkdir -p "$DIST" "$DL" "$IMG"
# --- 1) Cover (quadratischer Crop auf die Hunde + Verlauf unten + Wortmarke) ---
make_cover() { # $1=subtitle $2=outfile
magick "$PHOTO" -auto-orient -crop 2648x2648+551+0 +repage -resize 2000x2000 \
\( -size 2000x950 gradient:none-'rgba(0,0,0,0.80)' \) -gravity south -compose over -composite \
-gravity south -font "$FONT" -kerning 10 \
-fill 'rgba(0,0,0,0.55)' -pointsize 168 -annotate +4+254 "BAN YARO" \
-fill white -pointsize 168 -annotate +0+250 "BAN YARO" \
-kerning 7 -fill 'rgba(255,255,255,0.92)' -pointsize 60 -annotate +0+160 "$1" \
-quality 90 "$2"
}
make_cover "DAS ALBUM · 7 SONGS" "$DIST/cover-de.jpg"
make_cover "THE ALBUM · 7 SONGS" "$DIST/cover-en.jpg"
# Eingebettetes Albumart kleiner halten (sonst blähen sich die MP3s auf)
magick "$DIST/cover-de.jpg" -resize 800x800 -quality 85 "$DIST/art-de.jpg"
magick "$DIST/cover-en.jpg" -resize 800x800 -quality 85 "$DIST/art-en.jpg"
# Neutrales Thumbnail (ohne Text) für die Profil-Karte — der Titel steht dort im HTML
magick "$PHOTO" -auto-orient -crop 2648x2648+551+0 +repage -resize 600x600 -quality 85 "$IMG/album-thumb.jpg"
# --- 2)+3) pro Sprache: taggen + zippen ---
# Zeilenformat: "quelldatei(ohne .mp3)|Titel|Untertitel"
DE_TRACKS=(
"ban-yaro-blues|Ban Yaro Blues|Die Hymne"
"ban-yaro-mobil|Ban Yaro Mobil|Erste Fahrt im Anhänger"
"amy|Amy|Eine Liebesromanze"
"beim-friseur|Beim Friseur|Halbes Fell, Energie pur"
"leckerli-paradies|Leckerli-Paradies|Voller Napf, volles Glück"
"platsch|Platsch!|Ab ins kühle Nass"
"bester-freund|Bester Freund|Du und ich"
)
EN_TRACKS=(
"ban-yaro-blues-en|Ban Yaro Blues|The anthem"
"ban-yaro-mobil-en|Ban Yaro Mobile|First ride in the trailer"
"amy-en|Amy|A love duet"
"at-the-groomers-en|At the Groomer's|Half the fur, all the energy"
"treat-paradise-en|Treat Paradise|Full bowl, full heart"
"splash-en|Splash!|Into the cool water"
"best-friend-en|Best Friend|You and me"
)
zip_album() { # $1=lang $2=AlbumName(ID3, Em-Dash ok) $3=Ordner(ASCII) $4=art $5=liner shift 5; rest=tracks
local lang="$1" album="$2" fname="$3" art="$4" liner="$5"; shift 5
local folder="$DIST/$fname"; rm -rf "$folder"; mkdir -p "$folder"
if [ "$lang" = "de" ]; then cp "$liner" "$folder/LIESMICH.txt"; else cp "$liner" "$folder/README.txt"; fi
cp "$art" "$folder/cover.jpg"
local copyr
if [ "$lang" = "de" ]; then
copyr="© 2026 Ban Yaro — Alle Rechte vorbehalten. Kommerzielle Nutzung nur nach Absprache (banyaro.app)."
else
copyr="© 2026 Ban Yaro — All rights reserved. Commercial use only by prior arrangement (banyaro.app)."
fi
local n=0 line src title sub nn
for line in "$@"; do
n=$((n+1)); nn=$(printf "%02d" "$n")
IFS='|' read -r src title sub <<<"$line"
ffmpeg -y -loglevel error -i "$SND/$src.mp3" -i "$art" \
-map 0:a -map 1:v -c copy -id3v2_version 3 \
-metadata title="$title" -metadata artist="Ban Yaro" \
-metadata album="$album" -metadata album_artist="Ban Yaro" \
-metadata track="$n/7" -metadata date="2026" -metadata genre="Blues" \
-metadata copyright="$copyr" -metadata comment="$sub" \
-disposition:v:0 attached_pic \
"$folder/$nn $title.mp3"
done
( cd "$DIST" && rm -f "$DL/ban-yaro-album-$lang.zip" && zip -r -X -q "$DL/ban-yaro-album-$lang.zip" "$fname" )
echo "$fname → ban-yaro-album-$lang.zip ($(du -h "$DL/ban-yaro-album-$lang.zip" | cut -f1))"
}
# LIESMICH.txt für DE, README.txt für EN
cp "$BUILD/liner-de.txt" "$DIST/_liner-de.txt"
cp "$BUILD/liner-en.txt" "$DIST/_liner-en.txt"
zip_album "de" "Ban Yaro — Das Album" "Ban Yaro - Das Album" "$DIST/art-de.jpg" "$DIST/_liner-de.txt" "${DE_TRACKS[@]}"
zip_album "en" "Ban Yaro — The Album" "Ban Yaro - The Album" "$DIST/art-en.jpg" "$DIST/_liner-en.txt" "${EN_TRACKS[@]}"
echo "Fertig. Downloads in backend/static/downloads/, Thumbnail in img/banyaro/album-thumb.jpg"

View file

@ -0,0 +1,32 @@
==================================================
BAN YARO — DAS ALBUM
7 Songs
==================================================
Sieben Lieder über einen Hund namens Ban Yaro:
übers Gassigehen bei jedem Wetter, die erste Fahrt
im Anhänger, die große Liebe, den Friseur, den
vollen Napf, das kühle Wasser — und den besten
Freund, den ein Mensch haben kann.
Selbst gemacht, mit viel Herz. Aus der Ban-Yaro-App.
--------------------------------------------------
TRACKLIST
--------------------------------------------------
1. Ban Yaro Blues Die Hymne
2. Ban Yaro Mobil Erste Fahrt im Anhänger
3. Amy Eine Liebesromanze
4. Beim Friseur Halbes Fell, Energie pur
5. Leckerli-Paradies Voller Napf, volles Glück
6. Platsch! Ab ins kühle Nass
7. Bester Freund Du und ich
--------------------------------------------------
© 2026 Ban Yaro — Musik und Texte. Alle Rechte vorbehalten.
Privat behalten und teilen ist ausdrücklich erwünscht.
Kommerzielle Nutzung nur nach Absprache: banyaro.app
Viel Freude beim Hören und einen schönen
Gassi-Gang! Wuff. 🐾

View file

@ -0,0 +1,32 @@
==================================================
BAN YARO — THE ALBUM
7 Songs
==================================================
Seven songs about a dog named Ban Yaro:
about walks in every kind of weather, the first
ride in the trailer, falling in love, the groomer,
a full bowl, cool water — and the best friend a
human could ever ask for.
Homemade, with all our heart. From the Ban Yaro app.
--------------------------------------------------
TRACKLIST
--------------------------------------------------
1. Ban Yaro Blues The anthem
2. Ban Yaro Mobile First ride in the trailer
3. Amy A love duet
4. At the Groomer's Half the fur, all the energy
5. Treat Paradise Full bowl, full heart
6. Splash! Into the cool water
7. Best Friend You and me
--------------------------------------------------
© 2026 Ban Yaro — music and lyrics. All rights reserved.
Keeping and sharing privately is expressly welcome.
Commercial use only by prior arrangement: banyaro.app
Enjoy the music, and have a wonderful walk!
Woof. 🐾