diff --git a/.gitignore b/.gitignore index 8319981..22979bd 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ tiles/build/ *.mbtiles tiles/build.log tiles/.DS_Store + +# Album-Build: Zwischendateien (ZIPs + Thumbnail werden committet) +tools/album-build/dist/ diff --git a/MARKETING.md b/MARKETING.md index 4692ffe..c52125d 100644 --- a/MARKETING.md +++ b/MARKETING.md @@ -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. -_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. @@ -16,6 +16,7 @@ _Stand: 2026-06-09_ | 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 | | 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 | | 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) | @@ -37,10 +38,10 @@ Legende: 🟢 läuft/erledigt · 🟡 angefangen · ⬜ offen · 💡 Idee · - [ ] **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). - [ ] **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 -- [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] 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 @@ -71,6 +72,42 @@ Tag recherchiert: **HID Laundry Tag 16 mm** (shopnfc, SKU RE-ICO2-16, ~1 €/Stk ### 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. +### Song-Reel / Short-Video + +**Ziel:** Das Album ist kein Einnahme-Produkt (Streaming zahlt ~3–4 €/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, 15–30 s, 9:16):** +1. **0–1,5 s Hook:** stärkster visueller Moment + stärkste Textzeile als Caption-Pop. +2. **1,5–~20 s:** 3–5 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 ~2–3 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) 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. diff --git a/Makefile b/Makefile index 7fc461a..1906455 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,7 @@ TAR_EXCLUDE := --exclude='.git' \ --exclude='./.DS_Store' .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 @@ -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; \ 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) # ---------------------------------------------------------- diff --git a/VERSION b/VERSION index 16b7561..61a4199 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1300 \ No newline at end of file +1303 \ No newline at end of file diff --git a/backend/database.py b/backend/database.py index a26bd07..8fad182 100644 --- a/backend/database.py +++ b/backend/database.py @@ -522,6 +522,7 @@ def _migrate(conn_factory): # Forum Sprint 11: erweiterte Thread-Felder ("forum_threads", "foto_urls", "TEXT"), ("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_deleted", "INTEGER NOT NULL DEFAULT 0"), ("forum_threads", "likes", "INTEGER NOT NULL DEFAULT 0"), diff --git a/backend/main.py b/backend/main.py index 21a5069..13da5cf 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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("/img", StaticFiles(directory=f"{STATIC_DIR}/img"), name="img") 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. # WICHTIG: Starlettes StaticFiles/FileResponse liefert hinter unserer BaseHTTPMiddleware diff --git a/backend/routes/forum.py b/backend/routes/forum.py index 58d03b4..f8943ac 100644 --- a/backend/routes/forum.py +++ b/backend/routes/forum.py @@ -42,6 +42,7 @@ class PostCreate(BaseModel): class ThreadPatch(BaseModel): is_pinned: Optional[int] = None is_locked: Optional[int] = None + pin_scope: Optional[str] = None # 'global' (überall oben) | 'kategorie' (nur im Thema oben) class ThreadUpdate(BaseModel): titel: Optional[str] = Field(None, max_length=200) @@ -71,6 +72,15 @@ class ResolveReport(BaseModel): 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 # ------------------------------------------------------------------ @@ -126,12 +136,13 @@ async def list_threads( user=Depends(get_current_user_optional), ): uid = user['id'] if user else None + has_cat = bool(kategorie and kategorie != 'alle') with db() as conn: q = """ SELECT t.id, t.kategorie, t.titel, SUBSTR(t.text, 1, 120) AS text_preview, 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, u.name AS autor_name, u.founder_number AS autor_founder_number FROM forum_threads t @@ -139,13 +150,18 @@ async def list_threads( WHERE t.is_deleted = 0 """ params = [] - if kategorie and kategorie != 'alle': + if has_cat: q += " AND t.kategorie = ?" params.append(kategorie) if search: q += " AND (t.titel LIKE ? OR t.text LIKE ?)" 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]) rows = conn.execute(q, params).fetchall() @@ -323,7 +339,7 @@ async def delete_thread(thread_id: int, user=Depends(get_current_user)): ).fetchone() if not thread: 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.") conn.execute( "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}") 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.") with db() as conn: 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.") 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: cols = ', '.join(f"{k} = ?" for k in updates) conn.execute( @@ -476,7 +494,7 @@ async def delete_post(post_id: int, user=Depends(get_current_user)): ).fetchone() if not post: 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.") conn.execute( "UPDATE forum_posts SET is_deleted = 1 WHERE id = ?", (post_id,) @@ -504,7 +522,7 @@ async def upload_thread_foto( ).fetchone() if not thread: 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.") existing = _parse_foto_urls(thread['foto_urls']) @@ -537,7 +555,7 @@ async def upload_post_foto( ).fetchone() if not post: 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.") 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") 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.") with db() as conn: rows = conn.execute( @@ -660,7 +678,7 @@ async def list_reports(user=Depends(get_current_user)): # ------------------------------------------------------------------ @router.patch("/reports/{report_id}") 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.") with db() as conn: conn.execute( diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 91dfd2d..f3e34e7 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -8307,6 +8307,10 @@ svg.empty-state-icon { .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-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-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); } diff --git a/backend/static/downloads/ban-yaro-album-de.zip b/backend/static/downloads/ban-yaro-album-de.zip new file mode 100644 index 0000000..6cc9e85 Binary files /dev/null and b/backend/static/downloads/ban-yaro-album-de.zip differ diff --git a/backend/static/downloads/ban-yaro-album-en.zip b/backend/static/downloads/ban-yaro-album-en.zip new file mode 100644 index 0000000..d7e52e4 Binary files /dev/null and b/backend/static/downloads/ban-yaro-album-en.zip differ diff --git a/backend/static/img/banyaro/album-thumb.jpg b/backend/static/img/banyaro/album-thumb.jpg new file mode 100644 index 0000000..bbf38d8 Binary files /dev/null and b/backend/static/img/banyaro/album-thumb.jpg differ diff --git a/backend/static/index.html b/backend/static/index.html index eaec6fc..5a9d95f 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@