From 1cb0c2df77c942948ca3b1fa86b3da09efe83ccd Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 24 Apr 2026 20:13:22 +0200 Subject: [PATCH] =?UTF-8?q?Social:=20Trainingstipp-Generator,=20=C3=9Cbung?= =?UTF-8?q?en=20in=20DB,=203=20Stil-Varianten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - training_exercises Tabelle mit 13 Übungen aus App-Bibliothek - POST /social/training-tip: Stil-Varianten tutorial/community/aspirational - exercise_id in social_content für Wiederholungs-Tracking - Admin-Stats: Social-Media-Sektion mit Status-Übersicht + letzte 10 Posts - SW by-v349, APP_VER 336 --- backend/database.py | 25 +- backend/routes/admin.py | 41 + backend/routes/social.py | 419 +++++++++- backend/static/index.html | 2 +- backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 53 ++ backend/static/js/pages/social.js | 1290 +++++++++++++++++++++-------- backend/static/sw.js | 2 +- 8 files changed, 1497 insertions(+), 337 deletions(-) diff --git a/backend/database.py b/backend/database.py index ba76919..3961a8a 100644 --- a/backend/database.py +++ b/backend/database.py @@ -561,12 +561,35 @@ def _migrate(conn_factory): published_at TEXT, source TEXT NOT NULL DEFAULT 'generated', breed_id INTEGER REFERENCES wiki_rassen(id) ON DELETE SET NULL, - notes TEXT + coaching TEXT, + notes TEXT, + media_url TEXT, + category TEXT, + exercise_id TEXT ); CREATE INDEX IF NOT EXISTS idx_social_content_status ON social_content(status); """) + # Training-Übungen (für Social Media + Auswertung) + conn.executescript(""" + CREATE TABLE IF NOT EXISTS training_exercises ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + exercise_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + kategorie TEXT NOT NULL, + schwierigkeit TEXT, + alter_ab TEXT, + dauer TEXT, + beschreibung TEXT, + schritte TEXT, + tipp TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_training_exercises_kat + ON training_exercises(kategorie); + """) + # Knigge: Community-Votes conn.executescript(""" CREATE TABLE IF NOT EXISTS knigge_votes ( diff --git a/backend/routes/admin.py b/backend/routes/admin.py index c24b351..0f14a55 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -163,6 +163,40 @@ async def stats(user=Depends(require_mod)): except Exception: ki_today = ki_month = ki_users_today = 0 + # Social Media Tracking + try: + social_total = conn.execute("SELECT COUNT(*) FROM social_content").fetchone()[0] + social_published = conn.execute( + "SELECT COUNT(*) FROM social_content WHERE status='published'" + ).fetchone()[0] + social_scheduled = conn.execute( + "SELECT COUNT(*) FROM social_content WHERE status='scheduled'" + ).fetchone()[0] + social_ideas = conn.execute( + "SELECT COUNT(*) FROM social_content WHERE status='idea'" + ).fetchone()[0] + social_this_week = conn.execute( + "SELECT COUNT(*) FROM social_content WHERE status='published' " + "AND published_at >= datetime('now', '-7 days')" + ).fetchone()[0] + social_by_cat = { + row[0]: row[1] for row in conn.execute( + "SELECT category, COUNT(*) FROM social_content " + "WHERE category IS NOT NULL GROUP BY category ORDER BY 2 DESC" + ).fetchall() + } + social_recent = [dict(r) for r in conn.execute( + """SELECT topic, status, platform, format, created_at, + published_at, category, ai_score + FROM social_content + ORDER BY created_at DESC LIMIT 10""" + ).fetchall()] + except Exception: + social_total = social_published = social_scheduled = 0 + social_ideas = social_this_week = 0 + social_by_cat = {} + social_recent = [] + return { "users_total": users_total, "users_today": users_today, @@ -183,6 +217,13 @@ async def stats(user=Depends(require_mod)): "ki_today": ki_today, "ki_month": ki_month, "ki_users_today": ki_users_today, + "social_total": social_total, + "social_published": social_published, + "social_scheduled": social_scheduled, + "social_ideas": social_ideas, + "social_this_week": social_this_week, + "social_by_cat": social_by_cat, + "social_recent": social_recent, } diff --git a/backend/routes/social.py b/backend/routes/social.py index bd60346..508c049 100644 --- a/backend/routes/social.py +++ b/backend/routes/social.py @@ -5,6 +5,7 @@ KI-gestützte Content-Erstellung für TikTok & Instagram. import json import logging +import random from typing import Optional from fastapi import APIRouter, Depends, HTTPException, UploadFile, File @@ -13,6 +14,101 @@ from pydantic import BaseModel from auth import get_current_user, require_social_media from database import db +# ------------------------------------------------------------------ +# Übungs-Bibliothek (gespiegelt aus uebungen.js) +# ------------------------------------------------------------------ +_UEBUNGEN = [ + {"id": "grundkommandos_sitz", "name": "Sitz", "kat": "Grundkommando", "schwierigkeit": "Anfänger", "alter": "Ab 8 Wochen", "dauer": "3–5 Min", + "beschreibung": "Das erste Kommando — Basis für alles weitere. Leckerli über den Kopf führen, Hinterteil senkt sich, sofort belohnen.", + "schritte": ["Leckerli vor die Nase halten", "Langsam nach oben/hinten führen", "Hinterteil senkt sich → sofort Markerwort + Leckerli", "Erst nach 10x Wort 'Sitz' einführen"], + "tipp": "Nie zu früh das Kommandowort einführen — erst wenn die Bewegung sicher klappt."}, + {"id": "grundkommandos_platz", "name": "Platz", "kat": "Grundkommando", "schwierigkeit": "Anfänger", "alter": "Ab 10 Wochen", "dauer": "3–5 Min", + "beschreibung": "Hund legt sich auf Signal hin. Leckerli senkrecht nach unten zwischen Vorderpfoten führen.", + "schritte": ["Hund ins Sitz", "Leckerli nach unten zwischen die Pfoten führen", "Ellenbogen + Hinterteil am Boden → Markerwort + Leckerli", "Wort 'Platz' nach 10–15 erfolgreichen Wiederholungen"], + "tipp": "Klappt nicht? Leckerli unter dein angewinkeltes Knie halten — Hund kriecht drunter durch."}, + {"id": "grundkommandos_bleib", "name": "Bleib", "kat": "Grundkommando", "schwierigkeit": "Anfänger–Fortgeschr.", "alter": "Ab 12 Wochen", "dauer": "5 Min", + "beschreibung": "Drei Dimensionen: Dauer, Distanz, Ablenkung — immer nur eine steigern!", + "schritte": ["Hund ins Sitz", "2 Sekunden warten → Markerwort + Leckerli", "Freigabewort einführen: 'Okay'", "Dauer schrittweise: 2 → 5 → 10 → 30 Sekunden", "Erst dann einen Schritt zurücktreten"], + "tipp": "Immer zum Hund zurückkehren und belohnen — nie den Hund zu sich kommen lassen beim 'Bleib'."}, + {"id": "grundkommandos_hier", "name": "Hier / Komm", "kat": "Grundkommando", "schwierigkeit": "Anfänger", "alter": "Ab 8 Wochen", "dauer": "5 Min", + "beschreibung": "Lebensrettend wichtig. Niemals rufen und dann etwas Unangenehmes tun.", + "schritte": ["In der Wohnung auf 2–3 Meter beginnen", "Hinknien, Arme öffnen, freudige Stimme: 'Hier!'", "Ankommen = große Freude + Leckerli", "Nur einmal rufen — wer mehrfach ruft, trainiert Ignorieren"], + "tipp": "Schleppleine im Garten: Hund kann nicht wegbleiben, Erfolg ist garantiert."}, + {"id": "grundkommandos_fuss", "name": "Fuß", "kat": "Grundkommando", "schwierigkeit": "Fortgeschr. Anfänger", "alter": "Ab 12 Wochen", "dauer": "5–10 Min", + "beschreibung": "Hund läuft ruhig neben dir ohne zu ziehen — die Leine hängt locker durch.", + "schritte": ["Hund links positionieren, Leckerli auf Hüfthöhe halten", "Schritt vorwärts → Hund folgt", "Leine locker = belohnen", "Zieht er: stehen bleiben oder Richtung wechseln"], + "tipp": "Nie mitziehen lassen. Leine locker = Belohnung, Leine straff = Stopp."}, + {"id": "grundkommandos_aus", "name": "Aus / Lass es", "kat": "Grundkommando", "schwierigkeit": "Anfänger", "alter": "Ab 10 Wochen", "dauer": "3–5 Min", + "beschreibung": "Hund lässt Gegenstände auf Kommando los — wichtig für Sicherheit.", + "schritte": ["Hund hält Spielzeug", "Leckerli vor Nase → er lässt los", "Markerwort + Leckerli", "Gegenstand zurückgeben → Loslassen lohnt sich!"], + "tipp": "Nie einfach wegnehmen nach 'Aus' — sonst gibt er nächstes Mal nicht mehr her."}, + {"id": "tricks_pfote", "name": "Pfote / Handschlag", "kat": "Trick", "schwierigkeit": "Anfänger", "alter": "Ab 12 Wochen", "dauer": "3 Min", + "beschreibung": "Klassischer Trick — sieht toll aus und ist leicht zu lernen.", + "schritte": ["Leckerli in der Faust verstecken, Faust auf Kniehöhe", "Hund schnuppert und kratzt irgendwann → sofort öffnen", "Auf flache Hand umstellen", "Wort 'Pfote' einführen"], + "tipp": "Auf flacher Hand wird aus dem Kratzen ein elegantes Ablegen der Pfote."}, + {"id": "tricks_dreh", "name": "Dreh / Kreis", "kat": "Trick", "schwierigkeit": "Anfänger", "alter": "Ab 12 Wochen", "dauer": "3–5 Min", + "beschreibung": "Hund dreht eine Runde auf Handzeichen — sieht aus wie Tanzen!", + "schritte": ["Leckerli vor Nase", "Kreis in der Luft führen", "Volle Drehung → Markerwort + Leckerli", "'Dreh' = links, 'Runde' = rechts"], + "tipp": "Handbewegung schrittweise kleiner machen bis nur noch ein Fingerzeig reicht."}, + {"id": "tricks_such", "name": "Suchspiel / Nasenarbeit", "kat": "Trick", "schwierigkeit": "Anfänger", "alter": "Ab 8 Wochen", "dauer": "5–10 Min", + "beschreibung": "Mentale Auslastung pur — 10 Min Suchen = 1 Stunde Spaziergang!", + "schritte": ["Leckerli unter Becher verstecken", "Hund findet es → belohnen", "Mehrere Becher, einer hat Leckerli", "Später im Gras oder draußen"], + "tipp": "Perfekt für Regentage oder wenn körperliche Aktivität nicht möglich ist."}, + {"id": "tricks_decke", "name": "Platz auf Decke", "kat": "Trick", "schwierigkeit": "Fortgeschr. Anfänger", "alter": "Ab 4 Monate", "dauer": "5–10 Min", + "beschreibung": "Hund geht auf sein Platz und liegt ruhig — perfekt für Restaurant oder Besuche.", + "schritte": ["Decke hinlegen, Leckerli drauf werfen", "Jedes Betreten belohnen", "Auf das Hinlegen warten → große Belohnung", "Decke schrittweise weiter wegstellen"], + "tipp": "Ideal für den Alltag: Hund hat seinen sicheren Platz überall."}, + {"id": "problem_springen", "name": "Nicht springen", "kat": "Problemverhalten","schwierigkeit": "Anfänger", "alter": "Ab 8 Wochen", "dauer": "Bei jeder Begrüßung", + "beschreibung": "Hund begrüßt mit allen vier Pfoten am Boden.", + "schritte": ["Springt er: keine Reaktion", "Alle vier Pfoten unten → sofort Markerwort + Leckerli", "Alle im Haushalt müssen gleich reagieren"], + "tipp": "Konsequenz ist alles — ein Mal hochspringen lassen reicht, um es wieder zu lernen."}, + {"id": "problem_alleine", "name": "Alleine bleiben", "kat": "Problemverhalten","schwierigkeit": "Mittel", "alter": "Ab 10 Wochen", "dauer": "Mehrmals täglich", + "beschreibung": "Hund bleibt ruhig allein — ohne Stress, Bellen oder Zerstören.", + "schritte": ["Kong mit Futter füllen", "10 Sekunden rausgehen → ruhig zurückkommen", "Zeit schrittweise erhöhen", "Nie dramatisch verabschieden oder begrüßen"], + "tipp": "Welpen max. 1–2 Stunden, erwachsene Hunde max. 4–6 Stunden allein lassen."}, + {"id": "problem_bellen", "name": "Weniger Bellen", "kat": "Problemverhalten","schwierigkeit": "Mittel", "alter": "Ab 8 Wochen", "dauer": "5–10 Min täglich", + "beschreibung": "Hund beruhigt sich auf Signal hin — kein übermäßiges Kläffen mehr.", + "schritte": ["Ursache kennen: Alarm, Frust, Aufmerksamkeit oder Angst?", "Aufmerksamkeits-Bellen nie belohnen", "Kurze Pause abwarten → sofort markieren + belohnen", "'Ruhig' Kommando einführen"], + "tipp": "Nie schreien oder mitbellen — der Hund denkt du machst mit!"}, +] + +_TRAINING_STILE = [ + "tutorial", # "So geht das:" + "community", # "Sind grad dran das zu lernen" + "aspirational",# "Das könnte euer Hund auch" +] + +_PROMPT_TRAINING = '''\ +Erstelle einen Social-Media-Post über den Hundetraining-Tipp "{name}" für Ban Yaro (banyaro.app). + +Kategorie: {kat} +Schwierigkeit: {schwierigkeit} +Alter: {alter} +Dauer: {dauer} +Beschreibung: {beschreibung} +Schritte: {schritte} +Profi-Tipp: {tipp} + +Stil dieses Posts: "{stil}" +- "tutorial" → "Schaut, so geht das: [Schritte knapp erklärt]" +- "community" → "Sind grad dran das zu lernen 🙋 Wer kennt das?" +- "aspirational" → "Das könnte euer Hund auch — in nur [Dauer]!" + +Antworte NUR als JSON: +{{ + "caption": "Post-Text passend zum Stil, mit Emojis, max 600 Zeichen, lehrreich aber locker", + "hashtags": "10 Hashtags kommagetrennt ohne #: hundetraining, hundeschule, positivreinforcement, {name_lower}, banyaro + passende", + "hook": "Erste Zeile die sofort Aufmerksamkeit fängt", + "cta": "Frage ans Publikum: z.B. 'Übt ihr das auch gerade?' oder 'Zeigt uns eure Videos!'", + "visual_brief": "Was im Video/Foto zu sehen sein sollte: Person mit Hund beim Training, Leckerli sichtbar, bestimmte Körperhaltung", + "canva_notes": "Text-Overlay: Übungsname + 1 Key-Tipp als Grafik", + "unsplash_query": "dog training {name_en} positive reinforcement", + "ai_score": <1-5>, + "category": "training", + "coaching": "Tipp für die Creatorin: Warum spricht dieser Post die Zielgruppe an? Wie filmt man das am besten? (1-2 Sätze)" +}} +''' + logger = logging.getLogger(__name__) router = APIRouter() @@ -56,9 +152,12 @@ Plattform: {platform} Format: {format} Thema: {topic} {breed_info} -Bereits verwendete Themen (nicht wiederholen): +Bereits verwendete Themen — NICHT wiederholen, ähnliche Inhalte VERMEIDEN: {used_topics} +Kategorie-Verteilung der letzten Posts (vermeide überfüllte Kategorien): +{category_stats} + Antworte NUR mit einem JSON-Objekt: {{ "caption": "Post-Text (plattformgerecht, mit Emojis, max 2200 Zeichen für Instagram / 150 für TikTok)", @@ -71,6 +170,7 @@ Antworte NUR mit einem JSON-Objekt: "script": {script_field}, "unsplash_query": "2-3 englische Suchbegriffe für Unsplash-Stockfotos", "ai_score": , + "category": "Eine Kategorie aus: tipps|humor|rasse|community|gesundheit|training|saisonal|emotional|behind-the-scenes|quiz", "coaching": "2-3 Sätze für die Creatorin: Warum dieser Hook? Warum diese Hashtags? Was macht diesen Post stark? Locker und ermutigend formuliert." }} ''' @@ -134,6 +234,57 @@ def _used_topics(limit: int = 30) -> str: return "\n".join(f"- {r['topic']}" for r in rows) +def _category_stats(limit: int = 30) -> str: + with db() as conn: + rows = conn.execute( + """SELECT category, COUNT(*) as n FROM social_content + WHERE category IS NOT NULL + ORDER BY created_at DESC LIMIT ?""", + (limit,), + ).fetchall() + if not rows: + return "(noch keine Kategorien)" + from collections import Counter + counts = Counter(r["category"] for r in rows) + total = sum(counts.values()) + lines = [f"- {cat}: {n}x ({round(n/total*100)}%)" for cat, n in counts.most_common()] + return "\n".join(lines) + + +def _diversity_warning(limit: int = 20) -> dict | None: + """Gibt Warnung zurück wenn eine Kategorie >40% der letzten Posts dominiert.""" + with db() as conn: + rows = conn.execute( + """SELECT category FROM social_content + WHERE category IS NOT NULL + ORDER BY created_at DESC LIMIT ?""", + (limit,), + ).fetchall() + if len(rows) < 5: + return None + from collections import Counter + counts = Counter(r["category"] for r in rows) + total = len(rows) + for cat, n in counts.most_common(1): + if n / total > 0.4: + _CAT_DE = { + "tipps": "Tipps", "humor": "Humor/Spaß", "rasse": "Rassen-Info", + "community": "Community", "gesundheit": "Gesundheit", + "training": "Training", "saisonal": "Saison-Content", + "emotional": "Emotionale Posts", "behind-the-scenes": "Behind the Scenes", + "quiz": "Quiz/Fragen", + } + missing = [c for c in _CAT_DE if c not in counts] + return { + "dominant": cat, + "dominant_de": _CAT_DE.get(cat, cat), + "pct": round(n / total * 100), + "suggestions": missing[:3], + "suggestions_de": [_CAT_DE.get(c, c) for c in missing[:3]], + } + return None + + def _breed_info(breed_id: int | None) -> str: if not breed_id: return "" @@ -183,6 +334,24 @@ def _parse_json(raw: str) -> dict: raise ValueError(f"Kein JSON in Antwort: {raw[:200]}") +# ------------------------------------------------------------------ +# GET /api/social/diversity — Kreativitäts-Check +# ------------------------------------------------------------------ +@router.get("/diversity") +async def get_diversity(user=Depends(require_social_media)): + warning = _diversity_warning() + with db() as conn: + rows = conn.execute( + """SELECT category, COUNT(*) as n FROM social_content + WHERE category IS NOT NULL + ORDER BY n DESC""", + ).fetchall() + return { + "warning": warning, + "distribution": [dict(r) for r in rows], + } + + # ------------------------------------------------------------------ # GET /api/social/suggestions — KI schlägt 5 Themen vor # ------------------------------------------------------------------ @@ -254,6 +423,7 @@ async def generate_content(req: GenerateRequest, user=Depends(require_social_med topic=req.topic, breed_info=_breed_info(req.breed_id), used_topics=_used_topics(), + category_stats=_category_stats(), script_field=script_field, ) @@ -269,8 +439,8 @@ async def generate_content(req: GenerateRequest, user=Depends(require_social_med """INSERT INTO social_content (created_by, platform, format, topic, caption, hashtags, visual_brief, image_prompt, canva_notes, script, hook, cta, - unsplash_query, ai_score, source, breed_id, coaching) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + unsplash_query, ai_score, source, breed_id, coaching, category) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( user["id"], req.platform, req.format, req.topic, data.get("caption"), data.get("hashtags"), @@ -278,7 +448,7 @@ async def generate_content(req: GenerateRequest, user=Depends(require_social_med data.get("canva_notes"), data.get("script"), data.get("hook"), data.get("cta"), data.get("unsplash_query"), data.get("ai_score"), - "generated", req.breed_id, data.get("coaching"), + "generated", req.breed_id, data.get("coaching"), data.get("category"), ), ) entry_id = cur.lastrowid @@ -369,7 +539,121 @@ async def delete_content(cid: int, user=Depends(require_social_media)): # ------------------------------------------------------------------ -# GET /api/social/breeds — Rassen für Dropdown +# Übungen in DB seeden (einmalig beim Import) +# ------------------------------------------------------------------ +def _seed_exercises(): + with db() as conn: + for u in _UEBUNGEN: + conn.execute( + """INSERT OR IGNORE INTO training_exercises + (exercise_id, name, kategorie, schwierigkeit, alter_ab, + dauer, beschreibung, schritte, tipp) + VALUES (?,?,?,?,?,?,?,?,?)""", + (u["id"], u["name"], u["kat"], u["schwierigkeit"], + u.get("alter"), u.get("dauer"), u.get("beschreibung"), + json.dumps(u.get("schritte", []), ensure_ascii=False), + u.get("tipp")), + ) + +try: + _seed_exercises() +except Exception: + pass + + +# ------------------------------------------------------------------ +# POST /api/social/training-tip — Trainingstipp-Post generieren +# ------------------------------------------------------------------ +@router.post("/training-tip") +async def training_tip(user=Depends(require_social_media)): + # Übung wählen die noch nicht als Social-Post verwendet wurde + with db() as conn: + used = {r["exercise_id"] for r in conn.execute( + "SELECT exercise_id FROM social_content WHERE exercise_id IS NOT NULL" + ).fetchall()} + all_ex = conn.execute( + "SELECT * FROM training_exercises ORDER BY RANDOM()" + ).fetchall() + + unused = [e for e in all_ex if e["exercise_id"] not in used] + pool = unused if unused else list(all_ex) # Reset wenn alle durch + if not pool: + raise HTTPException(404, "Keine Übungen gefunden.") + + ex = dict(pool[0]) + stil = random.choice(_TRAINING_STILE) + schritte_list = json.loads(ex["schritte"] or "[]") + schritte_text = "\n".join(f" {i+1}. {s}" for i, s in enumerate(schritte_list[:4])) + + prompt = _PROMPT_TRAINING.format( + name=ex["name"], + kat=ex["kategorie"], + schwierigkeit=ex["schwierigkeit"] or "Anfänger", + alter=ex["alter_ab"] or "Ab 8 Wochen", + dauer=ex["dauer"] or "5 Min", + beschreibung=ex["beschreibung"] or ex["name"], + schritte=schritte_text, + tipp=ex["tipp"] or "", + stil=stil, + name_lower=ex["name"].lower().replace(" ", ""), + name_en=ex["name"], + ) + + try: + raw = await _ki_complete(prompt) + data = _parse_json(raw) + except Exception as e: + raise HTTPException(500, f"KI-Fehler: {e}") + + stil_label = { + "tutorial": f"So geht's: {ex['name']}", + "community": f"Lernen gerade: {ex['name']} 🙋", + "aspirational": f"Das kann dein Hund auch: {ex['name']}", + }[stil] + + with db() as conn: + cur = conn.execute( + """INSERT INTO social_content + (created_by, platform, format, topic, caption, hashtags, + visual_brief, canva_notes, hook, cta, unsplash_query, + ai_score, source, coaching, category, exercise_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + (user["id"], "both", "reel" if stil == "tutorial" else "post", + stil_label, data.get("caption"), data.get("hashtags"), + data.get("visual_brief"), data.get("canva_notes"), + data.get("hook"), data.get("cta"), data.get("unsplash_query"), + data.get("ai_score"), "generated", data.get("coaching"), + "training", ex["exercise_id"]), + ) + entry_id = cur.lastrowid + + with db() as conn: + row = conn.execute("SELECT * FROM social_content WHERE id=?", (entry_id,)).fetchone() + result = dict(row) + result["exercise_name"] = ex["name"] + result["exercise_kat"] = ex["kategorie"] + result["stil"] = stil + return result + + +# ------------------------------------------------------------------ +# GET /api/social/exercises — alle Übungen mit Nutzungsstatistik +# ------------------------------------------------------------------ +@router.get("/exercises") +async def get_exercises(user=Depends(require_social_media)): + with db() as conn: + rows = conn.execute( + """SELECT e.*, + (SELECT COUNT(*) FROM social_content s + WHERE s.exercise_id = e.exercise_id) as posts_count + FROM training_exercises e + ORDER BY e.kategorie, e.name""" + ).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# GET /api/social/breeds — alle Rassen für Autocomplete # ------------------------------------------------------------------ @router.get("/breeds") async def social_breeds(user=Depends(require_social_media)): @@ -380,6 +664,131 @@ async def social_breeds(user=Depends(require_social_media)): return [dict(r) for r in rows] +# ------------------------------------------------------------------ +# GET /api/social/unused-breeds — noch nie in Post verwendete Rassen +# ------------------------------------------------------------------ +@router.get("/unused-breeds") +async def unused_breeds(limit: int = 6, user=Depends(require_social_media)): + with db() as conn: + rows = conn.execute( + """SELECT r.id, r.name FROM wiki_rassen r + WHERE r.ki_enriched = 1 + AND r.id NOT IN ( + SELECT breed_id FROM social_content + WHERE breed_id IS NOT NULL + ) + ORDER BY RANDOM() + LIMIT ?""", + (limit,), + ).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# POST /api/social/breed-of-day — "Wusstest du schon?" Post generieren +# ------------------------------------------------------------------ +_PROMPT_BREED_DAY = '''\ +Erstelle einen begeisternden Social-Media-Post über die Hunderasse "{name}" für Ban Yaro (banyaro.app). + +Rassen-Daten aus unserer Datenbank: +- Herkunft: {herkunft} +- Größe: {groesse} +- Gewicht: {gewicht_min}–{gewicht_max} kg +- Lebensdauer: {lebensdauer} +- Aktivität: {aktivitaet} +- Erfahrung: {erfahrung} +- Temperament: {temperament} +- Für Kinder geeignet: {kinder} +- Wohnungsgeeignet: {wohnung} +- Beschreibung: {beschreibung} + +Antworte NUR als JSON: +{{ + "caption": "Post-Text der mit 🐾 Wusstest du schon, wie cool der/die {name} ist? beginnt. Fakten aus den Daten, mit Emojis, max 800 Zeichen.", + "hashtags": "10-12 Hashtags kommagetrennt ohne #: Rassenname, hundeleben, banyaro, passende Eigenschaften", + "hook": "Erste Zeile als Aufmerksamkeitsfänger", + "cta": "Frage ans Publikum passend zur Rasse", + "visual_brief": "Beschreibung: typisches Bild dieser Rasse, Stimmung, Setting", + "canva_notes": "Textüberlagerungen für das Rassenfoto: Rassenname groß, 1-2 Fakten", + "unsplash_query": "2-3 englische Suchbegriffe für diese Rasse", + "ai_score": <1-5>, + "category": "rasse", + "coaching": "Tipp für die Creatorin: Warum ist diese Rasse spannend für die Zielgruppe? (1-2 Sätze)" +}} +''' + +@router.post("/breed-of-day") +async def breed_of_day(user=Depends(require_social_media)): + with db() as conn: + rasse = conn.execute( + """SELECT r.id, r.name, r.herkunft, r.groesse, + r.gewicht_min_kg, r.gewicht_max_kg, r.lebensdauer, + r.aktivitaet, r.erfahrung, r.temperament, + r.kinder_geeignet, r.wohnung_geeignet, + r.beschreibung, r.foto_url + FROM wiki_rassen r + WHERE r.ki_enriched = 1 + AND r.beschreibung IS NOT NULL + AND r.id NOT IN ( + SELECT breed_id FROM social_content WHERE breed_id IS NOT NULL + ) + ORDER BY RANDOM() + LIMIT 1""", + ).fetchone() + + if not rasse: + raise HTTPException(404, "Alle Rassen wurden bereits verwendet — Reset nötig.") + + def _yn(v): return "Ja" if v else "Nein" + prompt = _PROMPT_BREED_DAY.format( + name=rasse["name"], + herkunft=rasse["herkunft"] or "unbekannt", + groesse=rasse["groesse"] or "unbekannt", + gewicht_min=rasse["gewicht_min_kg"] or "?", + gewicht_max=rasse["gewicht_max_kg"] or "?", + lebensdauer=rasse["lebensdauer"] or "unbekannt", + aktivitaet=rasse["aktivitaet"] or "mittel", + erfahrung=rasse["erfahrung"] or "fortgeschritten", + temperament=rasse["temperament"] or "unbekannt", + kinder=_yn(rasse["kinder_geeignet"]), + wohnung=_yn(rasse["wohnung_geeignet"]), + beschreibung=(rasse["beschreibung"] or "")[:400], + ) + + try: + raw = await _ki_complete(prompt) + data = _parse_json(raw) + except Exception as e: + raise HTTPException(500, f"KI-Fehler: {e}") + + with db() as conn: + cur = conn.execute( + """INSERT INTO social_content + (created_by, platform, format, topic, caption, hashtags, + visual_brief, image_prompt, canva_notes, hook, cta, + unsplash_query, ai_score, source, breed_id, coaching, + category, media_url) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + user["id"], "both", "post", + f"Rasse des Tages: {rasse['name']}", + data.get("caption"), data.get("hashtags"), + data.get("visual_brief"), None, + data.get("canva_notes"), data.get("hook"), data.get("cta"), + data.get("unsplash_query"), data.get("ai_score"), + "generated", rasse["id"], data.get("coaching"), + "rasse", rasse["foto_url"], + ), + ) + entry_id = cur.lastrowid + + with db() as conn: + row = conn.execute("SELECT * FROM social_content WHERE id=?", (entry_id,)).fetchone() + result = dict(row) + result["breed_foto"] = rasse["foto_url"] + return result + + # ------------------------------------------------------------------ # GET /api/social/stats — Level / XP # ------------------------------------------------------------------ diff --git a/backend/static/index.html b/backend/static/index.html index 444a728..ba0cd02 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -182,7 +182,7 @@ + +
+

+ 📱 Social Media Tracking

+
+ ${[ + ['Gesamt generiert', s.social_total, 'var(--c-text-secondary)'], + ['Veröffentlicht', s.social_published, 'var(--c-success)'], + ['Geplant', s.social_scheduled, 'var(--c-primary)'], + ['Ideen offen', s.social_ideas, 'var(--c-warning)'], + ['Diese Woche live', s.social_this_week, 'var(--c-success)'], + ].map(([label, val, color]) => ` +
+
${label}
+
${val ?? 0}
+
`).join('')} +
+ ${Object.keys(s.social_by_cat||{}).length ? ` +
+
Kategorien
+
+ ${Object.entries(s.social_by_cat).map(([cat, n]) => ` + + ${cat} ${n}`).join('')} +
+
` : ''} + ${s.social_recent?.length ? ` +
+
Letzte 10 Posts
+
+ ${s.social_recent.map(p => ` +
+ + ${p.status} + ${p.topic} + + ${(p.published_at||p.created_at||'').slice(0,10)} +
`).join('')} +
+
` : ''} +
+

-

Social Media Manager

+
+
+ 📱 +
+
Social Media
+
Luna ist dein KI-Coach
+
+ ${_stats ? `
+
${_stats.level}
+
${_stats.xp} XP
+
` : ''} +
+ ${lvlBar}
-
- ${['archiv','generator','bewerten'].map(t => ` - `).join('')} +
+ ${[['idee','✨ Ideen'],['archiv','📂 Archiv'],['bewerten','🔍 Prüfen']].map(([t,l]) => ` + `).join('')}
-
`; - _el.querySelectorAll('.sm-tab').forEach(btn => - btn.addEventListener('click', () => { _activeTab = btn.dataset.tab; _render(); }) - ); + _el.querySelectorAll('.sm-tab').forEach(b => + b.addEventListener('click', () => { _activeTab = b.dataset.tab; _render(); })); - const cont = _el.querySelector('#sm-content'); - if (_activeTab === 'archiv') await _renderArchiv(cont); - if (_activeTab === 'generator') _renderGenerator(cont); - if (_activeTab === 'bewerten') _renderBewerten(cont); + const c = _el.querySelector('#sm-content'); + if (_activeTab === 'idee') _renderIdee(c); + if (_activeTab === 'archiv') _renderArchiv(c); + if (_activeTab === 'bewerten') _renderBewerten(c); + } + + function _levelBar(s) { + if (!s.xp_next) return ''; + const pct = Math.min(100, Math.round( + ((s.xp - s.xp_current_min) / (s.xp_next - s.xp_current_min)) * 100)); + return `
+
+ ${s.level} + ${s.next_level ? `${s.xp_next - s.xp} XP bis ${s.next_level}` : ''} +
+
+
+
+
`; + } + + // --------------------------------------------------------------- + // IDEEN-TAB + // --------------------------------------------------------------- + function _renderIdee(el) { + let selPlatform = 'both', selFormat = 'post', uploadedMediaUrl = null; + + el.innerHTML = ` + +
+ 🌙 +
+
+ Hey, ich bin Luna 👋
+
+ Ich schlage dir Ideen vor und erkläre warum sie funktionieren. + Lern Social Media richtig — nicht nur kopieren!
+
+
+ + + ${_diversity?.warning ? ` +
+
+ ⚠️ +
+
Luna bemerkt eine Wiederholung!
+
+ ${_diversity.warning.pct}% deiner letzten Posts waren + ${_diversity.warning.dominant_de}-Content. + Versuch mal was anderes, damit dein Feed abwechslungsreicher wird!
+ ${_diversity.warning.suggestions_de.length ? ` +
+ 💡 Probier mal: ${_diversity.warning.suggestions_de.join(', ')}
` : ''} +
+
+
` : ''} + + +
+
+
💡 Was könntest du heute posten?
+ +
+
+ ${_lunaThinking('Klein...')} +
+
+ +
+
✏️ Oder eigenes Thema
+
+ + +
+ +
+
Plattform
+
+ ${['both','instagram','tiktok'].map((p,i) => ` + `).join('')} +
+
+ +
+
Format
+
+ ${['post','reel','story','carousel'].map((f,i) => ` + `).join('')} +
+
+ +
+
Thema
+ +
+ + + +
+
Foto / Video (optional)
+
+ + +
+ +
+ +
+
Rasse (optional)
+ ${_unusedBreeds.length ? ` +
+ 🌙 Noch nicht gezeigt: + ${_unusedBreeds.map(b => + ``).join('')} +
` : ''} + + + ${_breeds.map(b => ` + +
+ + + +
+
`; + + // Platform toggle + el.querySelectorAll('.sm-plat').forEach(b => b.addEventListener('click', () => { + selPlatform = b.dataset.p; + el.querySelectorAll('.sm-plat').forEach(x => + x.className = `btn btn-sm sm-plat ${x===b?'btn-primary':'btn-secondary'}`); + })); + el.querySelectorAll('.sm-fmt').forEach(b => b.addEventListener('click', () => { + selFormat = b.dataset.f; + el.querySelectorAll('.sm-fmt').forEach(x => + x.className = `btn btn-sm sm-fmt ${x===b?'btn-primary':'btn-secondary'}`); + })); + + // Breed-Chips + el.querySelectorAll('.sm-breed-chip').forEach(chip => { + chip.addEventListener('click', () => { + const search = el.querySelector('#sm-breed-search'); + const hiddenId = el.querySelector('#sm-breed-id'); + if (search) search.value = chip.dataset.name; + if (hiddenId) hiddenId.value = chip.dataset.id; + el.querySelectorAll('.sm-breed-chip').forEach(c => + c.style.background = c === chip ? 'var(--c-primary)' : 'var(--c-surface-2)'); + el.querySelectorAll('.sm-breed-chip').forEach(c => + c.style.color = c === chip ? '#fff' : 'var(--c-text)'); + }); + }); + + // Datalist-Autocomplete → breed-id setzen + el.querySelector('#sm-breed-search')?.addEventListener('input', e => { + const val = e.target.value.trim().toLowerCase(); + const match = _breeds.find(b => b.name.toLowerCase() === val); + const hiddenId = el.querySelector('#sm-breed-id'); + if (hiddenId) hiddenId.value = match ? match.id : ''; + }); + + // Media upload + function handleMedia(file) { + if (!file) return; + const preview = el.querySelector('#sm-media-preview'); + const reader = new FileReader(); + reader.onload = e => { + preview.style.display = ''; + if (file.type.startsWith('video/')) { + preview.innerHTML = ``; + } else { + preview.innerHTML = ``; + } + // Upload + const fd = new FormData(); + fd.append('file', file); + fetch('/api/social/media', { + method: 'POST', + headers: {Authorization: `Bearer ${localStorage.getItem('by_token')}`}, + body: fd, + }).then(r => r.json()).then(d => { uploadedMediaUrl = d.url; }); + }; + reader.readAsDataURL(file); + } + el.querySelector('#sm-media-file').addEventListener('change', e => handleMedia(e.target.files[0])); + el.querySelector('#sm-media-file2').addEventListener('change', e => handleMedia(e.target.files[0])); + + // Vorschläge laden + async function loadSuggestions() { + const box = el.querySelector('#sm-suggestions'); + box.innerHTML = `
+
🌙
+
+ Luna: Ich überlege…
+
`; + let mi = 0; + const sgInt = setInterval(() => { + mi = (mi + 1) % _LUNA_MSGS.length; + const [e, t] = _LUNA_MSGS[mi]; + const te = box.querySelector('#sg-text'); + const ee = box.querySelector('#sg-emoji'); + if (te) te.textContent = 'Luna: ' + t; + if (ee) ee.textContent = e; + }, 10000); + try { + const ideas = await API.get('/social/suggestions'); + clearInterval(sgInt); + if (!ideas?.length) { box.innerHTML = '
Keine Ideen erhalten.
'; return; } + box.innerHTML = ideas.map((idea, i) => ` +
+
+ ${idea.emoji||'💡'} +
+
${_esc(idea.thema)}
+
🎓 ${_esc(idea.warum)}
+
+ ${_FL[idea.format]||idea.format} + ${_PL[idea.platform]||idea.platform} +
+
+ +
+
`).join(''); + + box.querySelectorAll('.sm-use').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + el.querySelector('#sm-topic').value = btn.dataset.thema; + const fb = el.querySelector(`.sm-fmt[data-f="${btn.dataset.format}"]`); + if (fb) fb.click(); + const pb = el.querySelector(`.sm-plat[data-p="${btn.dataset.platform}"]`); + if (pb) pb.click(); + // Banner + Scroll zu "Los geht's!" + const hint = el.querySelector('#sm-next-hint'); + if (hint) hint.style.display = ''; + const genBtn = el.querySelector('#sm-gen'); + if (genBtn) { + setTimeout(() => { + genBtn.scrollIntoView({behavior:'smooth', block:'center'}); + genBtn.style.transform = 'scale(1.04)'; + setTimeout(() => genBtn.style.transform = '', 600); + }, 200); + } + }); + }); + box.querySelectorAll('.sm-idea').forEach(card => { + card.addEventListener('mouseenter', () => card.style.borderColor = 'var(--c-primary)'); + card.addEventListener('mouseleave', () => card.style.borderColor = 'transparent'); + }); + } catch { + clearInterval(sgInt); + box.innerHTML = '
Ideen konnten nicht geladen werden.
'; + } + } + el.querySelector('#sm-refresh').addEventListener('click', loadSuggestions); + loadSuggestions(); + + // Trainingstipp + el.querySelector('#sm-training-tip').addEventListener('click', async () => { + const btn = el.querySelector('#sm-training-tip'); + const res = el.querySelector('#sm-gen-result'); + btn.disabled = true; + res.innerHTML = _lunaProgressHtml(); + const interval = _startProgress(res); + try { + const data = await API.post('/social/training-tip', {}); + clearInterval(interval.bar); clearInterval(interval.msg); + _progressDone(res); + await new Promise(r => setTimeout(r, 400)); + const stilLabel = {tutorial:'📹 Tutorial', community:'🙋 Community', aspirational:'💪 Aspirational'}[data.stil] || ''; + res.innerHTML = ` +
+ 🎾 +
+
+ Trainingstipp · ${_esc(data.exercise_kat||'')} · ${stilLabel}
+
+ ${_esc(data.exercise_name||'')}
+
+
+ ${_renderResult(data, null)}`; + _bindResultEvents(res); + Promise.all([API.get('/social/stats'), API.get('/social/diversity')]) + .then(([s,d]) => { _stats = s; _diversity = d; }); + } catch(e) { + clearInterval(interval.bar); clearInterval(interval.msg); + res.innerHTML = `
+ 😬 ${_esc(e.message||String(e))}
`; + } finally { + btn.disabled = false; + } + }); + + // Rasse des Tages + el.querySelector('#sm-breed-day').addEventListener('click', async () => { + const btn = el.querySelector('#sm-breed-day'); + const res = el.querySelector('#sm-gen-result'); + btn.disabled = true; + res.innerHTML = _lunaProgressHtml(); + const interval = _startProgress(res); + try { + const data = await API.post('/social/breed-of-day', {}); + clearInterval(interval.bar); clearInterval(interval.msg); + _progressDone(res); + await new Promise(r => setTimeout(r, 400)); + // Foto anzeigen wenn vorhanden + const mediaUrl = data.breed_foto || data.media_url || null; + res.innerHTML = ` +
+ ${mediaUrl ? `` : '🐶'} +
+
Rasse des Tages
+
${_esc(data.topic?.replace('Rasse des Tages: ',''))}
+
+
+ ${_renderResult(data, mediaUrl)}`; + _bindResultEvents(res); + // unused breeds neu laden + API.get('/social/unused-breeds?limit=6').then(r => { _unusedBreeds = r; }); + Promise.all([API.get('/social/stats'), API.get('/social/diversity')]) + .then(([s,d]) => { _stats = s; _diversity = d; }); + } catch(e) { + clearInterval(interval.bar); clearInterval(interval.msg); + res.innerHTML = `
+ 😬 ${_esc(e.message||String(e))}
`; + } finally { + btn.disabled = false; + } + }); + + // Generieren + el.querySelector('#sm-gen').addEventListener('click', async () => { + const topic = el.querySelector('#sm-topic').value.trim(); + if (!topic) { UI.toast('Gib ein Thema ein 🐾', 'warning'); return; } + + const btn = el.querySelector('#sm-gen'); + const res = el.querySelector('#sm-gen-result'); + btn.disabled = true; + + // Luna-Progress starten + res.innerHTML = _lunaProgressHtml(); + const interval = _startProgress(res); + + try { + const data = await API.post('/social/generate', { + platform: selPlatform, format: selFormat, + topic, + breed_id: parseInt(el.querySelector('#sm-breed-id')?.value) || null, + }); + clearInterval(interval.bar); clearInterval(interval.msg); + _progressDone(res); + await new Promise(r => setTimeout(r, 400)); + res.innerHTML = _renderResult(data, uploadedMediaUrl); + _bindResultEvents(res); + el.querySelector('#sm-topic').value = ''; + const hint = el.querySelector('#sm-next-hint'); + if (hint) hint.style.display = 'none'; + uploadedMediaUrl = null; + el.querySelector('#sm-media-preview').style.display = 'none'; + // Stats + Diversity aktualisieren + Promise.all([ + API.get('/social/stats'), + API.get('/social/diversity'), + ]).then(([s, d]) => { _stats = s; _diversity = d; _updateLevelDisplay(); }); + } catch(e) { + clearInterval(interval); + res.innerHTML = `
+ 😬 Ups: ${_esc(e.message||String(e))}
`; + } finally { + btn.disabled = false; + btn.innerHTML = '✨ Los geht\'s!'; + } + }); + } + + // --------------------------------------------------------------- + // LUNA PROGRESS ANIMATION + // --------------------------------------------------------------- + function _lunaThinking(msg = 'Denkt nach…') { + return `
+
🌙
+
${msg}
+
`; + } + + function _lunaProgressHtml() { + return `
+
🌙
+
Luna liest dein Thema…
+
+
+
+
0%
+
`; + } + + function _startProgress(container) { + let elapsed = 0, msgIdx = 0; + const totalMs = 14000; + + // Fortschrittsbalken: alle 250ms + const barInterval = setInterval(() => { + elapsed += 250; + const pct = Math.min(93, Math.round((elapsed / totalMs) * 100)); + const bar = container.querySelector('#lp-bar'); + const pctEl = container.querySelector('#lp-pct'); + if (bar) bar.style.width = pct + '%'; + if (pctEl) pctEl.textContent = pct + '%'; + }, 250); + + // Text: alle 10s rotieren + const [e0, t0] = _LUNA_MSGS[0]; + const txt = container.querySelector('#lp-text'); + const emoji = container.querySelector('#lp-emoji'); + if (txt) txt.textContent = 'Luna: ' + t0; + if (emoji) emoji.textContent = e0; + + const msgInterval = setInterval(() => { + msgIdx = (msgIdx + 1) % _LUNA_MSGS.length; + const [e, t] = _LUNA_MSGS[msgIdx]; + const txt2 = container.querySelector('#lp-text'); + const emoji2 = container.querySelector('#lp-emoji'); + if (txt2) txt2.textContent = 'Luna: ' + t; + if (emoji2) emoji2.textContent = e; + }, 10000); + + // Beide Intervalle zurückgeben + return { bar: barInterval, msg: msgInterval }; + } + + function _progressDone(container) { + const bar = container.querySelector('#lp-bar'); + const txt = container.querySelector('#lp-text'); + const emoji = container.querySelector('#lp-emoji'); + const pctEl = container.querySelector('#lp-pct'); + if (bar) bar.style.width = '100%'; + if (pctEl) pctEl.textContent = '100%'; + if (txt) txt.textContent = '🎉 Fertig!'; + if (emoji) emoji.textContent = '✨'; + } + + // --------------------------------------------------------------- + // RESULT RENDER + // --------------------------------------------------------------- + function _renderResult(data, mediaUrl) { + const score = data.ai_score ? '⭐'.repeat(Math.min(data.ai_score,5)) : ''; + const unsplash = data.unsplash_query + ? `https://unsplash.com/s/photos/${encodeURIComponent(data.unsplash_query)}` : null; + + return ` + ${data.coaching ? ` +
+
+ 🌙 +
+
+ Luna sagt:
+
${_esc(data.coaching)}
+
+
+
` : ''} + +
+ ✓ Gespeichert + ${score ? `${score}` : ''} + +
+ + ${mediaUrl ? ` +
+
📎 Dein Medien-Upload
+ +
` : ''} + + ${_resultBlock('📝 Caption', data.caption, true)} + ${data.hashtags ? ` +
+
🏷 Hashtags
+
+ ${data.hashtags.split(',').map(h=>`#${h.trim()}`).join(' ')}
+ ${_copyBtn(data.hashtags.split(',').map(h=>`#${h.trim()}`).join(' '))} +
` : ''} + ${(data.hook||data.cta) ? ` +
+ ${data.hook ? `
🎣 Hook
+
+ "${_esc(data.hook)}"
` : ''} + ${data.cta ? `
📣 Call-to-Action
+
${_esc(data.cta)}
` : ''} +
` : ''} + ${_resultBlock('📸 Was du filmen/fotografieren solltest', data.visual_brief, false)} + ${data.script ? ` +
+
🎬 Video-Aufbau
+
${_esc(data.script)}
+
` : ''} + ${(data.image_prompt||data.canva_notes||unsplash) ? ` +
+
🛠 Wenn du kein eigenes Bild hast
+ ${data.image_prompt ? ` +
+ DALL-E / Midjourney:
+
${_esc(data.image_prompt)}
+ ${_copyBtn(data.image_prompt)}` : ''} + ${data.canva_notes ? ` +
Canva:
+
${_esc(data.canva_notes)}
` : ''} + ${unsplash ? ` + 🔍 Kostenlose Fotos auf Unsplash →` : ''} +
` : ''}`; + } + + function _resultBlock(label, text, copyable) { + if (!text) return ''; + return `
+
${label}
+
${_esc(text)}
+ ${copyable ? _copyBtn(text) : ''} +
`; + } + + function _copyBtn(text) { + return ``; + } + + function _bindResultEvents(el) { + el.querySelectorAll('.sm-copy').forEach(btn => { + btn.addEventListener('click', () => { + const text = btn.getAttribute('data-copy') + .replace(/&/g,'&').replace(/</g,'<') + .replace(/>/g,'>').replace(/"/g,'"'); + navigator.clipboard?.writeText(text).then(() => { + btn.textContent = '✓ Kopiert!'; + setTimeout(() => btn.textContent = '📋 Kopieren', 2000); + }); + }); + }); + el.querySelectorAll('.sm-preview-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const id = parseInt(btn.dataset.id); + const contents = await API.get('/social/content').catch(() => []); + const item = contents.find(c => c.id === id); + if (item) _showPreview(item); + }); + }); + } + + // --------------------------------------------------------------- + // POST-VORSCHAU + // --------------------------------------------------------------- + function _showPreview(item) { + const modal = document.createElement('div'); + modal.style.cssText = `position:fixed;inset:0;background:rgba(0,0,0,.7); + z-index:9999;display:flex;align-items:center;justify-content:center;padding:16px; + overflow-y:auto`; + + const isReel = item.format === 'reel'; + const w = isReel ? '220px' : '280px'; + const h = isReel ? '390px' : '300px'; + + modal.innerHTML = ` +
+
+
+ ${item.platform==='tiktok'?'🎵 TikTok':'📸 Instagram'} Vorschau
+ +
+ + +
+ +
+
🐾
+
+
banyaro.app
+
Jetzt
+
+
···
+
+ +
🐶
+ +
+
+ 🤍💬↗️ + 🔖 +
+
+ banyaro.app
+
+ ${_esc((item.caption||'').substring(0,150))}${(item.caption||'').length>150?'…':''}
+ ${item.hashtags ? `
+ ${item.hashtags.split(',').slice(0,5).map(h=>`#${h.trim()}`).join(' ')}
` : ''} +
+
+ + +
`; + + document.body.appendChild(modal); + ['sm-close-preview','sm-close-preview2'].forEach(id => { + modal.querySelector(`#${id}`)?.addEventListener('click', () => modal.remove()); + }); + modal.addEventListener('click', e => { if (e.target===modal) modal.remove(); }); + } + + function _updateLevelDisplay() { + if (!_stats || !_el) return; + const lvlEl = _el.querySelector('[data-level]'); + if (lvlEl) lvlEl.textContent = _stats.level; } // --------------------------------------------------------------- // ARCHIV // --------------------------------------------------------------- async function _renderArchiv(el) { - el.innerHTML = UI.skeleton(2); - const statusFilter = ['alle','idea','draft','scheduled','published','archived']; - let currentFilter = 'alle'; + el.innerHTML = _lunaThinking('Lädt…'); + let filter = 'alle'; + const fLabel = {alle:'Alle',idea:'Ideen',draft:'Entwürfe', + scheduled:'Geplant',published:'Veröffentlicht',archived:'Archiviert'}; - async function load(filter) { - currentFilter = filter; - const url = filter === 'alle' ? '/social/content' : `/social/content?status=${filter}`; - _contents = await API.get(url).catch(() => []); - renderList(); + async function load(f) { + filter = f; + const url = f==='alle' ? '/social/content' : `/social/content?status=${f}`; + const items = await API.get(url).catch(() => []); + render(items); } - function renderList() { + function render(items) { el.innerHTML = ` -
- ${statusFilter.map(s => ` - `).join('')} +
+ ${['alle','idea','draft','scheduled','published','archived'].map(s => ` + `).join('')}
- ${_contents.length === 0 - ? UI.emptyState({icon:'instagram-logo', title:'Noch keine Inhalte', - text:'Erstelle deinen ersten Content-Vorschlag im Tab "Idee generieren".'}) - : `
- ${_contents.map(_renderCard).join('')} -
`}`; + ${!items.length + ? UI.emptyState({icon:'camera',title:'Noch nichts hier', + text:'Geh zu "✨ Ideen" und erstelle deinen ersten Post!'}) + : items.map(c => ` +
+
+
+
+ ${_SL[c.status]||c.status} + ${c.created_at?.slice(0,10)||''} + ${c.ai_score ? `${'⭐'.repeat(c.ai_score)}` : ''} +
+
${_esc(c.topic)}
+ ${c.hook ? `
🎣 ${_esc(c.hook)}
` : ''} +
+
+ + + +
+
+ +
`).join('')}`; - el.querySelectorAll('[data-filter]').forEach(btn => - btn.addEventListener('click', () => load(btn.dataset.filter)) - ); - el.querySelectorAll('.sm-status-btn').forEach(btn => - btn.addEventListener('click', async () => { - const {id, status} = btn.dataset; - await API.patch(`/social/content/${id}`, {status}); - await load(currentFilter); - }) - ); - el.querySelectorAll('.sm-delete-btn').forEach(btn => - btn.addEventListener('click', async () => { - if (!window.confirm('Eintrag löschen?')) return; - await API.delete(`/social/content/${btn.dataset.id}`); - await load(currentFilter); - }) - ); - el.querySelectorAll('.sm-expand-btn').forEach(btn => - btn.addEventListener('click', () => { - const detail = el.querySelector(`#sm-detail-${btn.dataset.id}`); - if (detail) detail.style.display = detail.style.display === 'none' ? '' : 'none'; - }) - ); + el.querySelectorAll('[data-f]').forEach(b => b.addEventListener('click', () => load(b.dataset.f))); + el.querySelectorAll('.sm-exp').forEach(b => b.addEventListener('click', () => { + const d = el.querySelector(`#sm-d-${b.dataset.id}`); + if (d) { d.style.display = d.style.display==='none'?'':'none'; } + })); + el.querySelectorAll('.sm-copy').forEach(b => b.addEventListener('click', () => { + const text = b.getAttribute('data-copy') + .replace(/&/g,'&').replace(/</g,'<') + .replace(/>/g,'>').replace(/"/g,'"'); + navigator.clipboard?.writeText(text).then(() => { + b.textContent = '✓ Kopiert!'; + setTimeout(() => b.textContent = '📋 Kopieren', 2000); + }); + })); + el.querySelectorAll('.sm-sts').forEach(b => b.addEventListener('click', async () => { + await API.patch(`/social/content/${b.dataset.id}`, {status: b.dataset.s}); + load(filter); + })); + el.querySelectorAll('.sm-del').forEach(b => b.addEventListener('click', async () => { + if (!window.confirm('Löschen?')) return; + await API.delete(`/social/content/${b.dataset.id}`); + load(filter); + })); + el.querySelectorAll('.sm-prev-arch').forEach(b => b.addEventListener('click', async () => { + const it = items.find(c => c.id === parseInt(b.dataset.id)); + if (it) _showPreview(it); + })); } - await load('alle'); } - function _renderCard(c) { - const score = c.ai_score ? '⭐'.repeat(c.ai_score) : ''; - const unsplashUrl = c.unsplash_query - ? `https://unsplash.com/s/photos/${encodeURIComponent(c.unsplash_query)}` - : null; - const nextStatuses = { - idea: ['draft','archived'], draft: ['scheduled','archived'], - scheduled: ['published','draft'], published: ['archived'], archived: ['idea'], - }[c.status] || []; - - return ` -
-
-
-
- - - - - - ${_STATUS_LABEL[c.status]||c.status} - ${score ? `${score}` : ''} - - ${c.created_at?.slice(0,10)||''} -
-
- ${_esc(c.topic)}
- ${c.hook ? `
- 🎣 ${_esc(c.hook)}
` : ''} -
-
- - -
-
- - - -
`; - } - - // --------------------------------------------------------------- - // GENERATOR - // --------------------------------------------------------------- - function _renderGenerator(el) { - el.innerHTML = ` -
-
-
-
- - -
-
- - -
-
-
- - -
-
- - -
- -
-
-
`; - - _el.querySelector('#sm-gen-btn').addEventListener('click', async () => { - const topic = _el.querySelector('#sm-topic').value.trim(); - if (!topic) { UI.toast('Bitte ein Thema eingeben.', 'warning'); return; } - - const btn = _el.querySelector('#sm-gen-btn'); - const res = _el.querySelector('#sm-gen-result'); - btn.disabled = true; - btn.textContent = 'Generiere…'; - res.innerHTML = '
KI denkt nach… (~15s)
'; - - try { - const data = await API.post('/social/generate', { - platform: _el.querySelector('#sm-platform').value, - format: _el.querySelector('#sm-format').value, - topic, - breed_id: parseInt(_el.querySelector('#sm-breed').value) || null, - }); - res.innerHTML = ` -
- ✓ Gespeichert im Archiv (Score: ${'⭐'.repeat(data.ai_score||0)}) -
- ${_renderCard(data)}`; - res.querySelectorAll('.sm-expand-btn').forEach(btn => { - const detail = res.querySelector(`#sm-detail-${btn.dataset.id}`); - btn.addEventListener('click', () => { - if (detail) detail.style.display = detail.style.display==='none' ? '' : 'none'; - }); - }); - _el.querySelector('#sm-topic').value = ''; - } catch(e) { - res.innerHTML = `
${UI.icon('warning')} Fehler: ${_esc(e.message||String(e))}
`; - } finally { - btn.disabled = false; - btn.innerHTML = `${UI.icon('sparkle')} Content generieren`; - } - }); - } - // --------------------------------------------------------------- // BEWERTEN // --------------------------------------------------------------- function _renderBewerten(el) { + let selPlatform = 'instagram'; el.innerHTML = ` -
-

- Füge deinen eigenen Entwurf ein — die KI bewertet ihn, verbessert Caption, - Hashtags und gibt dir einen Visual Brief dazu. -

-
-
- - -
-
- - -
+
+ 🌙 +
+ Zeig mir deinen Entwurf — ich sage dir was gut ist und wie du ihn + noch besser machen kannst!
+
+
+
+ ${['instagram','tiktok','both'].map((p,i) => ` + `).join('')}
- - -
+
`; - _el.querySelector('#sm-eval-btn').addEventListener('click', async () => { - const draft = _el.querySelector('#sm-eval-draft').value.trim(); - if (!draft) { UI.toast('Bitte einen Entwurf eingeben.', 'warning'); return; } + el.querySelectorAll('.sm-ep').forEach(b => b.addEventListener('click', () => { + selPlatform = b.dataset.p; + el.querySelectorAll('.sm-ep').forEach(x => + x.className = `btn btn-sm sm-ep ${x===b?'btn-primary':'btn-secondary'}`); + })); - const btn = _el.querySelector('#sm-eval-btn'); - const res = _el.querySelector('#sm-eval-result'); + el.querySelector('#sm-eval').addEventListener('click', async () => { + const draft = el.querySelector('#sm-draft').value.trim(); + if (!draft) { UI.toast('Gib einen Text ein 😊', 'warning'); return; } + const btn = el.querySelector('#sm-eval'); + const res = el.querySelector('#sm-eval-res'); btn.disabled = true; - btn.textContent = 'Analysiere…'; - res.innerHTML = '
KI analysiert… (~10s)
'; - + res.innerHTML = _lunaProgressHtml(); + const interval = _startProgress(res); try { const data = await API.post('/social/evaluate', { - platform: _el.querySelector('#sm-eval-platform').value, - format: _el.querySelector('#sm-eval-format').value, - draft, + platform: selPlatform, format: 'post', draft, }); + clearInterval(interval.bar); clearInterval(interval.msg); + _progressDone(res); + await new Promise(r => setTimeout(r, 400)); res.innerHTML = ` - ${data.notes ? `
-
KI-Feedback
-
${_esc(data.notes)}
+ ${data.notes ? `
+
+ 🌙 +
+
+ Lunas Feedback:
+
${_esc(data.notes)}
+
+
` : ''} - ${_renderCard(data)}`; - res.querySelectorAll('.sm-expand-btn').forEach(btn => { - const detail = res.querySelector(`#sm-detail-${btn.dataset.id}`); - btn.addEventListener('click', () => { - if (detail) detail.style.display = detail.style.display==='none' ? '' : 'none'; - }); - }); + ${_renderResult(data, null)}`; + _bindResultEvents(res); + API.get('/social/stats').then(s => { _stats = s; }); } catch(e) { - res.innerHTML = `
${UI.icon('warning')} Fehler: ${_esc(e.message||String(e))}
`; + clearInterval(interval); + res.innerHTML = `
😬 Fehler: ${_esc(e.message||String(e))}
`; } finally { btn.disabled = false; - btn.innerHTML = `${UI.icon('magnifying-glass')} Bewerten & verbessern`; + btn.innerHTML = '🔍 Luna, schau mal drüber!'; } }); } + function _lunaBreedSuggestions(breeds, n = 3) { + // 3 zufällige Rassen aus den ersten 100 (bekannte Rassen) + const pool = breeds.slice(0, 100); + const picked = []; + const used = new Set(); + while (picked.length < n && picked.length < pool.length) { + const i = Math.floor(Math.random() * pool.length); + if (!used.has(i)) { used.add(i); picked.push(pool[i]); } + } + return picked; + } + function _esc(s) { if (!s) return ''; - return String(s) - .replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } // CSS const style = document.createElement('style'); - style.textContent = `.sm-label{font-size:var(--text-xs);font-weight:600; - color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.5px; - margin-bottom:3px;}`; + style.textContent = ` + .sm-label{font-size:11px;font-weight:700;color:var(--c-text-muted); + text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px;display:block} + @keyframes luna-pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.15)}} + `; document.head.appendChild(style); return { init, refresh }; diff --git a/backend/static/sw.js b/backend/static/sw.js index 533aadf..a0e1b3a 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v338'; +const CACHE_VERSION = 'by-v349'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten