Social Media Manager: Route, DB, KI-Prompts, Frontend, Rolle; SW by-v338
This commit is contained in:
parent
d90d4f1eeb
commit
0df6d569c1
9 changed files with 784 additions and 6 deletions
|
|
@ -128,3 +128,10 @@ def require_admin(user=Depends(get_current_user)):
|
||||||
if user["rolle"] != "admin":
|
if user["rolle"] != "admin":
|
||||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "Kein Zugriff.")
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "Kein Zugriff.")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_social_media(user=Depends(get_current_user)):
|
||||||
|
"""Dependency: Social-Media-Manager oder Admin."""
|
||||||
|
if not (user.get("is_social_media") or user["rolle"] == "admin"):
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "Kein Zugriff.")
|
||||||
|
return user
|
||||||
|
|
|
||||||
|
|
@ -522,6 +522,8 @@ def _migrate(conn_factory):
|
||||||
("users", "current_streak", "INTEGER NOT NULL DEFAULT 0"),
|
("users", "current_streak", "INTEGER NOT NULL DEFAULT 0"),
|
||||||
("users", "max_streak", "INTEGER NOT NULL DEFAULT 0"),
|
("users", "max_streak", "INTEGER NOT NULL DEFAULT 0"),
|
||||||
("users", "last_activity_date","TEXT"),
|
("users", "last_activity_date","TEXT"),
|
||||||
|
# Social Media Manager
|
||||||
|
("users", "is_social_media", "INTEGER NOT NULL DEFAULT 0"),
|
||||||
]
|
]
|
||||||
with conn_factory() as conn:
|
with conn_factory() as conn:
|
||||||
for table, column, col_type in migrations:
|
for table, column, col_type in migrations:
|
||||||
|
|
@ -535,6 +537,36 @@ def _migrate(conn_factory):
|
||||||
)
|
)
|
||||||
logger.info(f"Migration: {table}.{column} hinzugefügt.")
|
logger.info(f"Migration: {table}.{column} hinzugefügt.")
|
||||||
|
|
||||||
|
# Social Media Manager
|
||||||
|
conn.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS social_content (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
platform TEXT NOT NULL DEFAULT 'both',
|
||||||
|
format TEXT NOT NULL DEFAULT 'post',
|
||||||
|
topic TEXT NOT NULL,
|
||||||
|
caption TEXT,
|
||||||
|
hashtags TEXT,
|
||||||
|
visual_brief TEXT,
|
||||||
|
image_prompt TEXT,
|
||||||
|
canva_notes TEXT,
|
||||||
|
script TEXT,
|
||||||
|
hook TEXT,
|
||||||
|
cta TEXT,
|
||||||
|
unsplash_query TEXT,
|
||||||
|
ai_score INTEGER,
|
||||||
|
status TEXT NOT NULL DEFAULT 'idea',
|
||||||
|
scheduled_at TEXT,
|
||||||
|
published_at TEXT,
|
||||||
|
source TEXT NOT NULL DEFAULT 'generated',
|
||||||
|
breed_id INTEGER REFERENCES wiki_rassen(id) ON DELETE SET NULL,
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_social_content_status
|
||||||
|
ON social_content(status);
|
||||||
|
""")
|
||||||
|
|
||||||
# Knigge: Community-Votes
|
# Knigge: Community-Votes
|
||||||
conn.executescript("""
|
conn.executescript("""
|
||||||
CREATE TABLE IF NOT EXISTS knigge_votes (
|
CREATE TABLE IF NOT EXISTS knigge_votes (
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,7 @@ from routes.achievements import router as achievements_router
|
||||||
from routes.training import router as training_router
|
from routes.training import router as training_router
|
||||||
from routes.praise import router as praise_router
|
from routes.praise import router as praise_router
|
||||||
from routes.weather import router as weather_router
|
from routes.weather import router as weather_router
|
||||||
|
from routes.social import router as social_router
|
||||||
|
|
||||||
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
|
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
|
||||||
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
||||||
|
|
@ -136,6 +137,7 @@ app.include_router(events_router, prefix="/api/events", tags=["Events"])
|
||||||
app.include_router(sitting_router, prefix="/api/sitting", tags=["Sitting"])
|
app.include_router(sitting_router, prefix="/api/sitting", tags=["Sitting"])
|
||||||
app.include_router(osm_router, prefix="/api/osm", tags=["OSM"])
|
app.include_router(osm_router, prefix="/api/osm", tags=["OSM"])
|
||||||
app.include_router(weather_router, prefix="/api/weather", tags=["Wetter"])
|
app.include_router(weather_router, prefix="/api/weather", tags=["Wetter"])
|
||||||
|
app.include_router(social_router, prefix="/api/social", tags=["Social"])
|
||||||
app.include_router(forum_router, prefix="/api/forum", tags=["Forum"])
|
app.include_router(forum_router, prefix="/api/forum", tags=["Forum"])
|
||||||
app.include_router(lost_router, prefix="/api/lost", tags=["Verlorener Hund"])
|
app.include_router(lost_router, prefix="/api/lost", tags=["Verlorener Hund"])
|
||||||
app.include_router(knigge_router, prefix="/api/knigge", tags=["Knigge"])
|
app.include_router(knigge_router, prefix="/api/knigge", tags=["Knigge"])
|
||||||
|
|
|
||||||
|
|
@ -81,10 +81,11 @@ def require_admin(user=Depends(get_current_user)):
|
||||||
# Schemas
|
# Schemas
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
class UserPatch(BaseModel):
|
class UserPatch(BaseModel):
|
||||||
rolle: Optional[str] = None # user | moderator | admin
|
rolle: Optional[str] = None # user | moderator | admin
|
||||||
is_moderator: Optional[int] = None
|
is_moderator: Optional[int] = None
|
||||||
is_banned: Optional[int] = None
|
is_banned: Optional[int] = None
|
||||||
ban_reason: Optional[str] = None
|
ban_reason: Optional[str] = None
|
||||||
|
is_social_media: Optional[int] = None
|
||||||
|
|
||||||
class WikiEnrichBody(BaseModel):
|
class WikiEnrichBody(BaseModel):
|
||||||
limit: int = 10
|
limit: int = 10
|
||||||
|
|
|
||||||
327
backend/routes/social.py
Normal file
327
backend/routes/social.py
Normal file
|
|
@ -0,0 +1,327 @@
|
||||||
|
"""
|
||||||
|
BAN YARO — Social Media Manager
|
||||||
|
KI-gestützte Content-Erstellung für TikTok & Instagram.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from auth import get_current_user, require_social_media
|
||||||
|
from database import db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_PLATFORMS = {"tiktok", "instagram", "both"}
|
||||||
|
_FORMATS = {"reel", "story", "post", "carousel"}
|
||||||
|
_STATUSES = {"idea", "draft", "scheduled", "published", "archived"}
|
||||||
|
|
||||||
|
_SYSTEM = (
|
||||||
|
"Du bist ein erfahrener Social-Media-Stratege für Ban Yaro, "
|
||||||
|
"die deutschsprachige Hunde-App (banyaro.app). "
|
||||||
|
"Zielgruppe: Hundebesitzer in DACH, 20–45 Jahre, Fokus Frauen. "
|
||||||
|
"Ton: warmherzig, authentisch, informativ — nie aufdringlich werblich. "
|
||||||
|
"Antworte immer auf Deutsch."
|
||||||
|
)
|
||||||
|
|
||||||
|
_PROMPT_GENERATE = '''\
|
||||||
|
Erstelle einen vollständigen Social-Media-Content-Plan für folgenden Post.
|
||||||
|
|
||||||
|
Plattform: {platform}
|
||||||
|
Format: {format}
|
||||||
|
Thema: {topic}
|
||||||
|
{breed_info}
|
||||||
|
Bereits verwendete Themen (nicht wiederholen):
|
||||||
|
{used_topics}
|
||||||
|
|
||||||
|
Antworte NUR mit einem JSON-Objekt:
|
||||||
|
{{
|
||||||
|
"caption": "Post-Text (plattformgerecht, mit Emojis, max 2200 Zeichen für Instagram / 150 für TikTok)",
|
||||||
|
"hashtags": "kommagetrennte Hashtags ohne #, 5-15 Stück, mix aus groß/nische",
|
||||||
|
"hook": "Ersten 1-3 Sätze / Sekunden — sofortiger Aufmerksamkeitsfänger",
|
||||||
|
"cta": "Call-to-Action am Ende (Frage, Aufforderung zum Kommentieren/Teilen)",
|
||||||
|
"visual_brief": "Detaillierte Beschreibung was auf dem Foto/Video zu sehen sein soll (Motiv, Stimmung, Licht, Perspektive)",
|
||||||
|
"image_prompt": "Englischer DALL-E/Midjourney Prompt für ein passendes Illustration/Cartoon (wenn kein eigenes Foto verfügbar)",
|
||||||
|
"canva_notes": "Empfehlung für Canva: Template-Typ, Textüberlagerungen, Farbstimmung",
|
||||||
|
"script": {script_field},
|
||||||
|
"unsplash_query": "2-3 englische Suchbegriffe für Unsplash-Stockfotos",
|
||||||
|
"ai_score": <Zahl 1-5, geschätztes Engagement-Potenzial>
|
||||||
|
}}
|
||||||
|
'''
|
||||||
|
|
||||||
|
_SCRIPT_FIELD_REEL = '''"Videostruktur: Hook (0-3s): ... | Hauptteil (3-25s): Schritt 1... Schritt 2... | CTA (25-30s): ..."'''
|
||||||
|
_SCRIPT_FIELD_OTHER = "null"
|
||||||
|
|
||||||
|
_PROMPT_EVALUATE = '''\
|
||||||
|
Bewerte und verbessere diesen Social-Media-Entwurf für Ban Yaro (Hunde-App).
|
||||||
|
|
||||||
|
Plattform: {platform}
|
||||||
|
Format: {format}
|
||||||
|
Entwurf:
|
||||||
|
{draft}
|
||||||
|
|
||||||
|
Antworte NUR mit einem JSON-Objekt:
|
||||||
|
{{
|
||||||
|
"caption": "Verbesserter Post-Text",
|
||||||
|
"hashtags": "Optimierte Hashtags, kommagetrennt ohne #",
|
||||||
|
"hook": "Verbesserter Hook",
|
||||||
|
"cta": "Verbesserter CTA",
|
||||||
|
"visual_brief": "Empfehlung für passendes Bildmaterial",
|
||||||
|
"image_prompt": "DALL-E/Midjourney Prompt für Illustration",
|
||||||
|
"canva_notes": "Canva-Tipps",
|
||||||
|
"script": null,
|
||||||
|
"unsplash_query": "Unsplash-Suchbegriffe",
|
||||||
|
"ai_score": <1-5>,
|
||||||
|
"feedback": "Kurzes Feedback was gut war und was verbessert wurde (2-3 Sätze)"
|
||||||
|
}}
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateRequest(BaseModel):
|
||||||
|
platform: str = "both"
|
||||||
|
format: str = "post"
|
||||||
|
topic: str
|
||||||
|
breed_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class EvaluateRequest(BaseModel):
|
||||||
|
platform: str = "instagram"
|
||||||
|
format: str = "post"
|
||||||
|
draft: str
|
||||||
|
|
||||||
|
|
||||||
|
class StatusUpdate(BaseModel):
|
||||||
|
status: Optional[str] = None
|
||||||
|
scheduled_at: Optional[str] = None
|
||||||
|
published_at: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _used_topics(limit: int = 30) -> str:
|
||||||
|
with db() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT topic FROM social_content ORDER BY created_at DESC LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
if not rows:
|
||||||
|
return "(noch keine)"
|
||||||
|
return "\n".join(f"- {r['topic']}" for r in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _breed_info(breed_id: int | None) -> str:
|
||||||
|
if not breed_id:
|
||||||
|
return ""
|
||||||
|
with db() as conn:
|
||||||
|
r = conn.execute(
|
||||||
|
"SELECT name, beschreibung, temperament, groesse FROM wiki_rassen WHERE id=?",
|
||||||
|
(breed_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not r:
|
||||||
|
return ""
|
||||||
|
parts = [f"Rasse: {r['name']}"]
|
||||||
|
if r["beschreibung"]:
|
||||||
|
parts.append(f"Info: {r['beschreibung'][:300]}")
|
||||||
|
if r["temperament"]:
|
||||||
|
parts.append(f"Temperament: {r['temperament']}")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
async def _ki_complete(prompt: str) -> str:
|
||||||
|
import ki as ki_module
|
||||||
|
return await ki_module.complete(
|
||||||
|
prompt,
|
||||||
|
system=_SYSTEM,
|
||||||
|
max_tokens=1200,
|
||||||
|
requires_premium=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json(raw: str) -> dict:
|
||||||
|
import re
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
m = re.search(r"```(?:json)?\s*([\s\S]+?)\s*```", raw)
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
return json.loads(m.group(1))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
m = re.search(r"\{[\s\S]+\}", raw)
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
return json.loads(m.group(0))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
raise ValueError(f"Kein JSON in Antwort: {raw[:200]}")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/social/content — alle Einträge
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.get("/content")
|
||||||
|
async def list_content(
|
||||||
|
status: Optional[str] = None,
|
||||||
|
user=Depends(require_social_media),
|
||||||
|
):
|
||||||
|
with db() as conn:
|
||||||
|
if status:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM social_content WHERE status=? ORDER BY created_at DESC",
|
||||||
|
(status,),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM social_content ORDER BY created_at DESC LIMIT 200",
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/social/generate — KI-Content generieren
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.post("/generate")
|
||||||
|
async def generate_content(req: GenerateRequest, user=Depends(require_social_media)):
|
||||||
|
if req.platform not in _PLATFORMS:
|
||||||
|
raise HTTPException(400, f"Ungültige Plattform: {req.platform}")
|
||||||
|
if req.format not in _FORMATS:
|
||||||
|
raise HTTPException(400, f"Ungültiges Format: {req.format}")
|
||||||
|
if not req.topic.strip():
|
||||||
|
raise HTTPException(400, "Thema darf nicht leer sein.")
|
||||||
|
|
||||||
|
script_field = _SCRIPT_FIELD_REEL if req.format == "reel" else _SCRIPT_FIELD_OTHER
|
||||||
|
prompt = _PROMPT_GENERATE.format(
|
||||||
|
platform=req.platform,
|
||||||
|
format=req.format,
|
||||||
|
topic=req.topic,
|
||||||
|
breed_info=_breed_info(req.breed_id),
|
||||||
|
used_topics=_used_topics(),
|
||||||
|
script_field=script_field,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = await _ki_complete(prompt)
|
||||||
|
data = _parse_json(raw)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Social-Media-Generierung fehlgeschlagen: %s", 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, script, hook, cta,
|
||||||
|
unsplash_query, ai_score, source, breed_id)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
user["id"], req.platform, req.format, req.topic,
|
||||||
|
data.get("caption"), data.get("hashtags"),
|
||||||
|
data.get("visual_brief"), data.get("image_prompt"),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
entry_id = cur.lastrowid
|
||||||
|
|
||||||
|
with db() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM social_content WHERE id=?", (entry_id,)
|
||||||
|
).fetchone()
|
||||||
|
result = dict(row)
|
||||||
|
result["feedback"] = data.get("feedback")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/social/evaluate — eigenen Entwurf bewerten + verbessern
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.post("/evaluate")
|
||||||
|
async def evaluate_content(req: EvaluateRequest, user=Depends(require_social_media)):
|
||||||
|
if not req.draft.strip():
|
||||||
|
raise HTTPException(400, "Entwurf darf nicht leer sein.")
|
||||||
|
|
||||||
|
prompt = _PROMPT_EVALUATE.format(
|
||||||
|
platform=req.platform,
|
||||||
|
format=req.format,
|
||||||
|
draft=req.draft,
|
||||||
|
)
|
||||||
|
|
||||||
|
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, script, hook, cta,
|
||||||
|
unsplash_query, ai_score, source, notes)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
user["id"], req.platform, req.format,
|
||||||
|
req.draft[:80] + ("…" if len(req.draft) > 80 else ""),
|
||||||
|
data.get("caption"), data.get("hashtags"),
|
||||||
|
data.get("visual_brief"), data.get("image_prompt"),
|
||||||
|
data.get("canva_notes"), data.get("script"),
|
||||||
|
data.get("hook"), data.get("cta"),
|
||||||
|
data.get("unsplash_query"), data.get("ai_score"),
|
||||||
|
"user", data.get("feedback"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
entry_id = cur.lastrowid
|
||||||
|
|
||||||
|
with db() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM social_content WHERE id=?", (entry_id,)
|
||||||
|
).fetchone()
|
||||||
|
result = dict(row)
|
||||||
|
result["feedback"] = data.get("feedback")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# PATCH /api/social/content/{id} — Status / Notizen aktualisieren
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.patch("/content/{cid}")
|
||||||
|
async def update_content(cid: int, data: StatusUpdate, user=Depends(require_social_media)):
|
||||||
|
updates = {k: v for k, v in data.model_dump().items() if v is not None}
|
||||||
|
if not updates:
|
||||||
|
raise HTTPException(400, "Keine Felder zum Aktualisieren.")
|
||||||
|
if "status" in updates and updates["status"] not in _STATUSES:
|
||||||
|
raise HTTPException(400, f"Ungültiger Status: {updates['status']}")
|
||||||
|
|
||||||
|
cols = ", ".join(f"{k}=?" for k in updates)
|
||||||
|
values = list(updates.values()) + [cid]
|
||||||
|
with db() as conn:
|
||||||
|
conn.execute(f"UPDATE social_content SET {cols} WHERE id=?", values)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# DELETE /api/social/content/{id}
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.delete("/content/{cid}", status_code=204)
|
||||||
|
async def delete_content(cid: int, user=Depends(require_social_media)):
|
||||||
|
with db() as conn:
|
||||||
|
conn.execute("DELETE FROM social_content WHERE id=?", (cid,))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/social/breeds — Rassen für Dropdown
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.get("/breeds")
|
||||||
|
async def social_breeds(user=Depends(require_social_media)):
|
||||||
|
with db() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, name FROM wiki_rassen WHERE ki_enriched=1 ORDER BY name ASC"
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
@ -180,6 +180,11 @@
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> Erste Hilfe
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> Erste Hilfe
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-item" data-page="social" id="sidebar-social"
|
||||||
|
style="display:none;color:var(--c-warning,#f59e0b)">
|
||||||
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#instagram-logo"></use></svg> Social Media
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-item" data-page="admin" id="sidebar-admin"
|
<div class="sidebar-item" data-page="admin" id="sidebar-admin"
|
||||||
style="display:none;color:var(--c-danger,#ef4444)">
|
style="display:none;color:var(--c-danger,#ef4444)">
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shield"></use></svg> Admin
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shield"></use></svg> Admin
|
||||||
|
|
@ -325,6 +330,10 @@
|
||||||
<div class="page-body page-container"></div>
|
<div class="page-body page-container"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="page" id="page-social">
|
||||||
|
<div class="page-body page-container"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="page" id="page-admin">
|
<section class="page" id="page-admin">
|
||||||
<div class="page-body page-container"></div>
|
<div class="page-body page-container"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
Router, State-Management, Navigation, Initialisierung.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const APP_VER = '324'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
const APP_VER = '325'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||||
|
|
||||||
const App = (() => {
|
const App = (() => {
|
||||||
|
|
||||||
|
|
@ -54,6 +54,7 @@ const App = (() => {
|
||||||
lost: { title: 'Verlorener Hund', module: null },
|
lost: { title: 'Verlorener Hund', module: null },
|
||||||
friends: { title: 'Freunde', module: null, requiresAuth: true },
|
friends: { title: 'Freunde', module: null, requiresAuth: true },
|
||||||
chat: { title: 'Nachrichten', module: null, requiresAuth: true },
|
chat: { title: 'Nachrichten', module: null, requiresAuth: true },
|
||||||
|
social: { title: 'Social Media', module: null, requiresAuth: true },
|
||||||
admin: { title: 'Admin', module: null, requiresAuth: true },
|
admin: { title: 'Admin', module: null, requiresAuth: true },
|
||||||
impressum: { title: 'Impressum', module: null },
|
impressum: { title: 'Impressum', module: null },
|
||||||
datenschutz: { title: 'Datenschutz', module: null },
|
datenschutz: { title: 'Datenschutz', module: null },
|
||||||
|
|
@ -421,6 +422,11 @@ const App = (() => {
|
||||||
|| state.user.is_moderator;
|
|| state.user.is_moderator;
|
||||||
adminItem.style.display = isMod ? '' : 'none';
|
adminItem.style.display = isMod ? '' : 'none';
|
||||||
}
|
}
|
||||||
|
const socialItem = document.getElementById('sidebar-social');
|
||||||
|
if (socialItem) {
|
||||||
|
const isSocial = state.user.is_social_media || state.user.rolle === 'admin';
|
||||||
|
socialItem.style.display = isSocial ? '' : 'none';
|
||||||
|
}
|
||||||
await _loadDogs();
|
await _loadDogs();
|
||||||
|
|
||||||
// Eingeloggter User ohne Hund → Onboarding-Wizard (einmalig)
|
// Eingeloggter User ohne Hund → Onboarding-Wizard (einmalig)
|
||||||
|
|
|
||||||
394
backend/static/js/pages/social.js
Normal file
394
backend/static/js/pages/social.js
Normal file
|
|
@ -0,0 +1,394 @@
|
||||||
|
/* BAN YARO — Social Media Manager */
|
||||||
|
window.Page_social = (() => {
|
||||||
|
|
||||||
|
let _el, _state;
|
||||||
|
let _breeds = [];
|
||||||
|
let _contents = [];
|
||||||
|
let _activeTab = 'archiv';
|
||||||
|
|
||||||
|
const _STATUS_LABEL = {
|
||||||
|
idea: 'Idee', draft: 'Entwurf', scheduled: 'Geplant',
|
||||||
|
published: 'Veröffentlicht', archived: 'Archiviert',
|
||||||
|
};
|
||||||
|
const _STATUS_COLOR = {
|
||||||
|
idea: 'var(--c-text-muted)', draft: 'var(--c-warning)',
|
||||||
|
scheduled: 'var(--c-primary)', published: 'var(--c-success)',
|
||||||
|
archived: 'var(--c-text-muted)',
|
||||||
|
};
|
||||||
|
const _FORMAT_ICON = { reel: 'film-strip', story: 'circle', post: 'image', carousel: 'images' };
|
||||||
|
const _PLATFORM_ICON = { tiktok: 'music-notes-plus', instagram: 'instagram-logo', both: 'share-network' };
|
||||||
|
|
||||||
|
async function init(el, state) {
|
||||||
|
_el = el; _state = state;
|
||||||
|
_el.innerHTML = UI.skeleton(3);
|
||||||
|
_breeds = await API.get('/social/breeds').catch(() => []);
|
||||||
|
await _render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() { if (_el) _render(); }
|
||||||
|
|
||||||
|
async function _render() {
|
||||||
|
_el.innerHTML = `
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;
|
||||||
|
margin-bottom:var(--space-4);flex-wrap:wrap;gap:var(--space-2)">
|
||||||
|
<h2 style="margin:0;font-size:var(--text-lg);font-weight:700">Social Media Manager</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div style="display:flex;gap:0;border-bottom:2px solid var(--c-border);
|
||||||
|
margin-bottom:var(--space-4);overflow-x:auto">
|
||||||
|
${['archiv','generator','bewerten'].map(t => `
|
||||||
|
<button class="sm-tab${_activeTab===t?' sm-tab--active':''}" data-tab="${t}"
|
||||||
|
style="padding:var(--space-2) var(--space-4);border:none;background:none;
|
||||||
|
cursor:pointer;font-size:var(--text-sm);white-space:nowrap;
|
||||||
|
border-bottom:2px solid ${_activeTab===t?'var(--c-primary)':'transparent'};
|
||||||
|
margin-bottom:-2px;font-weight:${_activeTab===t?'600':'400'};
|
||||||
|
color:${_activeTab===t?'var(--c-primary)':'var(--c-text-secondary)'}">
|
||||||
|
${{archiv:'Archiv',generator:'Idee generieren',bewerten:'Entwurf bewerten'}[t]}
|
||||||
|
</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="sm-content"></div>`;
|
||||||
|
|
||||||
|
_el.querySelectorAll('.sm-tab').forEach(btn =>
|
||||||
|
btn.addEventListener('click', () => { _activeTab = btn.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// ARCHIV
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
async function _renderArchiv(el) {
|
||||||
|
el.innerHTML = UI.skeleton(2);
|
||||||
|
const statusFilter = ['alle','idea','draft','scheduled','published','archived'];
|
||||||
|
let currentFilter = 'alle';
|
||||||
|
|
||||||
|
async function load(filter) {
|
||||||
|
currentFilter = filter;
|
||||||
|
const url = filter === 'alle' ? '/social/content' : `/social/content?status=${filter}`;
|
||||||
|
_contents = await API.get(url).catch(() => []);
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
el.innerHTML = `
|
||||||
|
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-3)">
|
||||||
|
${statusFilter.map(s => `
|
||||||
|
<button class="btn btn-sm ${currentFilter===s?'btn-primary':'btn-secondary'}"
|
||||||
|
data-filter="${s}" style="padding:2px 10px">
|
||||||
|
${{alle:'Alle',idea:'Ideen',draft:'Entwürfe',scheduled:'Geplant',
|
||||||
|
published:'Veröffentlicht',archived:'Archiv'}[s]}
|
||||||
|
</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
${_contents.length === 0
|
||||||
|
? UI.emptyState({icon:'instagram-logo', title:'Noch keine Inhalte',
|
||||||
|
text:'Erstelle deinen ersten Content-Vorschlag im Tab "Idee generieren".'})
|
||||||
|
: `<div style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||||
|
${_contents.map(_renderCard).join('')}
|
||||||
|
</div>`}`;
|
||||||
|
|
||||||
|
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';
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `
|
||||||
|
<div class="card" style="padding:var(--space-3)">
|
||||||
|
<div style="display:flex;align-items:flex-start;gap:var(--space-3)">
|
||||||
|
<div style="flex:1;min-width:0">
|
||||||
|
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;
|
||||||
|
margin-bottom:var(--space-1)">
|
||||||
|
<svg class="ph-icon" style="width:16px;height:16px;flex-shrink:0">
|
||||||
|
<use href="/icons/phosphor.svg#${_PLATFORM_ICON[c.platform]||'share-network'}"></use></svg>
|
||||||
|
<svg class="ph-icon" style="width:16px;height:16px;flex-shrink:0">
|
||||||
|
<use href="/icons/phosphor.svg#${_FORMAT_ICON[c.format]||'image'}"></use></svg>
|
||||||
|
<span style="font-size:var(--text-xs);padding:1px 6px;border-radius:4px;
|
||||||
|
background:var(--c-surface-2);color:${_STATUS_COLOR[c.status]}">
|
||||||
|
${_STATUS_LABEL[c.status]||c.status}</span>
|
||||||
|
${score ? `<span style="font-size:11px">${score}</span>` : ''}
|
||||||
|
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">
|
||||||
|
${c.created_at?.slice(0,10)||''}</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-weight:600;font-size:var(--text-sm);margin-bottom:var(--space-1)">
|
||||||
|
${_esc(c.topic)}</div>
|
||||||
|
${c.hook ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||||||
|
font-style:italic;margin-bottom:var(--space-1)">
|
||||||
|
🎣 ${_esc(c.hook)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:var(--space-1);flex-shrink:0">
|
||||||
|
<button class="btn btn-sm btn-secondary sm-expand-btn" data-id="${c.id}"
|
||||||
|
style="padding:2px 8px;font-size:var(--text-xs)">Details</button>
|
||||||
|
<button class="btn btn-sm btn-secondary sm-delete-btn" data-id="${c.id}"
|
||||||
|
style="padding:2px 8px;font-size:var(--text-xs);color:var(--c-danger)">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detail-Bereich (eingeklappt) -->
|
||||||
|
<div id="sm-detail-${c.id}" style="display:none;margin-top:var(--space-3);
|
||||||
|
border-top:1px solid var(--c-border);padding-top:var(--space-3)">
|
||||||
|
${c.caption ? `<div style="margin-bottom:var(--space-2)">
|
||||||
|
<div class="sm-label">Caption</div>
|
||||||
|
<div style="white-space:pre-wrap;font-size:var(--text-sm)">${_esc(c.caption)}</div>
|
||||||
|
</div>` : ''}
|
||||||
|
${c.hashtags ? `<div style="margin-bottom:var(--space-2)">
|
||||||
|
<div class="sm-label">Hashtags</div>
|
||||||
|
<div style="font-size:var(--text-xs);color:var(--c-primary)">
|
||||||
|
${c.hashtags.split(',').map(h=>`#${h.trim()}`).join(' ')}</div>
|
||||||
|
</div>` : ''}
|
||||||
|
${c.cta ? `<div style="margin-bottom:var(--space-2)">
|
||||||
|
<div class="sm-label">Call-to-Action</div>
|
||||||
|
<div style="font-size:var(--text-sm)">${_esc(c.cta)}</div>
|
||||||
|
</div>` : ''}
|
||||||
|
${c.visual_brief ? `<div style="margin-bottom:var(--space-2)">
|
||||||
|
<div class="sm-label">Visual Brief</div>
|
||||||
|
<div style="font-size:var(--text-sm)">${_esc(c.visual_brief)}</div>
|
||||||
|
</div>` : ''}
|
||||||
|
${c.image_prompt ? `<div style="margin-bottom:var(--space-2)">
|
||||||
|
<div class="sm-label">DALL-E / Midjourney Prompt</div>
|
||||||
|
<div style="font-size:var(--text-xs);background:var(--c-surface-2);
|
||||||
|
padding:var(--space-2);border-radius:6px;font-family:monospace">
|
||||||
|
${_esc(c.image_prompt)}</div>
|
||||||
|
</div>` : ''}
|
||||||
|
${c.canva_notes ? `<div style="margin-bottom:var(--space-2)">
|
||||||
|
<div class="sm-label">Canva-Tipps</div>
|
||||||
|
<div style="font-size:var(--text-sm)">${_esc(c.canva_notes)}</div>
|
||||||
|
</div>` : ''}
|
||||||
|
${c.script ? `<div style="margin-bottom:var(--space-2)">
|
||||||
|
<div class="sm-label">Video-Skript</div>
|
||||||
|
<div style="font-size:var(--text-sm);white-space:pre-wrap">${_esc(c.script)}</div>
|
||||||
|
</div>` : ''}
|
||||||
|
${unsplashUrl ? `<div style="margin-bottom:var(--space-2)">
|
||||||
|
<div class="sm-label">Stockfotos</div>
|
||||||
|
<a href="${unsplashUrl}" target="_blank" rel="noopener"
|
||||||
|
style="font-size:var(--text-sm);color:var(--c-primary)">
|
||||||
|
Unsplash: "${_esc(c.unsplash_query)}" →</a>
|
||||||
|
</div>` : ''}
|
||||||
|
${c.notes ? `<div style="margin-bottom:var(--space-2)">
|
||||||
|
<div class="sm-label">Notizen / Feedback</div>
|
||||||
|
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">${_esc(c.notes)}</div>
|
||||||
|
</div>` : ''}
|
||||||
|
${nextStatuses.length ? `<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2)">
|
||||||
|
${nextStatuses.map(s => `
|
||||||
|
<button class="btn btn-sm btn-secondary sm-status-btn"
|
||||||
|
data-id="${c.id}" data-status="${s}"
|
||||||
|
style="font-size:var(--text-xs)">
|
||||||
|
→ ${_STATUS_LABEL[s]}</button>`).join('')}
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// GENERATOR
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
function _renderGenerator(el) {
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="card" style="padding:var(--space-4)">
|
||||||
|
<div style="display:grid;gap:var(--space-3)">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Plattform</label>
|
||||||
|
<select id="sm-platform" class="input">
|
||||||
|
<option value="both">TikTok + Instagram</option>
|
||||||
|
<option value="instagram">Instagram</option>
|
||||||
|
<option value="tiktok">TikTok</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Format</label>
|
||||||
|
<select id="sm-format" class="input">
|
||||||
|
<option value="post">Post / Bild</option>
|
||||||
|
<option value="reel">Reel / Video</option>
|
||||||
|
<option value="story">Story</option>
|
||||||
|
<option value="carousel">Carousel</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Thema</label>
|
||||||
|
<input id="sm-topic" class="input" placeholder="z.B. 5 Tipps gegen Hitzestress im Sommer"
|
||||||
|
style="width:100%">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Rasse (optional)</label>
|
||||||
|
<select id="sm-breed" class="input">
|
||||||
|
<option value="">— keine spezifische Rasse —</option>
|
||||||
|
${_breeds.map(b => `<option value="${b.id}">${_esc(b.name)}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button id="sm-gen-btn" class="btn btn-primary">
|
||||||
|
${UI.icon('sparkle')} Content generieren
|
||||||
|
</button>
|
||||||
|
<div id="sm-gen-result"></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
_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 = '<div style="text-align:center;padding:var(--space-4);color:var(--c-text-muted)">KI denkt nach… (~15s)</div>';
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div style="background:var(--c-success-bg,#f0fdf4);border:1px solid var(--c-success);
|
||||||
|
border-radius:8px;padding:var(--space-3);margin-top:var(--space-2)">
|
||||||
|
✓ Gespeichert im Archiv (Score: ${'⭐'.repeat(data.ai_score||0)})
|
||||||
|
</div>
|
||||||
|
${_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 = `<div style="color:var(--c-danger)">${UI.icon('warning')} Fehler: ${_esc(e.message||String(e))}</div>`;
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = `${UI.icon('sparkle')} Content generieren`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// BEWERTEN
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
function _renderBewerten(el) {
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="card" style="padding:var(--space-4)">
|
||||||
|
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
|
||||||
|
Füge deinen eigenen Entwurf ein — die KI bewertet ihn, verbessert Caption,
|
||||||
|
Hashtags und gibt dir einen Visual Brief dazu.
|
||||||
|
</p>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);margin-bottom:var(--space-3)">
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Plattform</label>
|
||||||
|
<select id="sm-eval-platform" class="input">
|
||||||
|
<option value="instagram">Instagram</option>
|
||||||
|
<option value="tiktok">TikTok</option>
|
||||||
|
<option value="both">Beide</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Format</label>
|
||||||
|
<select id="sm-eval-format" class="input">
|
||||||
|
<option value="post">Post / Bild</option>
|
||||||
|
<option value="reel">Reel / Video</option>
|
||||||
|
<option value="story">Story</option>
|
||||||
|
<option value="carousel">Carousel</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea id="sm-eval-draft" class="input"
|
||||||
|
style="width:100%;min-height:120px;resize:vertical"
|
||||||
|
placeholder="Dein Caption-Entwurf, Idee oder Rohtext…"></textarea>
|
||||||
|
<button id="sm-eval-btn" class="btn btn-primary" style="margin-top:var(--space-3)">
|
||||||
|
${UI.icon('magnifying-glass')} Bewerten & verbessern
|
||||||
|
</button>
|
||||||
|
<div id="sm-eval-result" style="margin-top:var(--space-3)"></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
_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; }
|
||||||
|
|
||||||
|
const btn = _el.querySelector('#sm-eval-btn');
|
||||||
|
const res = _el.querySelector('#sm-eval-result');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Analysiere…';
|
||||||
|
res.innerHTML = '<div style="text-align:center;padding:var(--space-4);color:var(--c-text-muted)">KI analysiert… (~10s)</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await API.post('/social/evaluate', {
|
||||||
|
platform: _el.querySelector('#sm-eval-platform').value,
|
||||||
|
format: _el.querySelector('#sm-eval-format').value,
|
||||||
|
draft,
|
||||||
|
});
|
||||||
|
res.innerHTML = `
|
||||||
|
${data.notes ? `<div class="card" style="padding:var(--space-3);background:var(--c-surface-2);
|
||||||
|
margin-bottom:var(--space-3)">
|
||||||
|
<div class="sm-label">KI-Feedback</div>
|
||||||
|
<div style="font-size:var(--text-sm)">${_esc(data.notes)}</div>
|
||||||
|
</div>` : ''}
|
||||||
|
${_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';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
res.innerHTML = `<div style="color:var(--c-danger)">${UI.icon('warning')} Fehler: ${_esc(e.message||String(e))}</div>`;
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = `${UI.icon('magnifying-glass')} Bewerten & verbessern`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _esc(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g,'&').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;}`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
return { init, refresh };
|
||||||
|
})();
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v337';
|
const CACHE_VERSION = 'by-v338';
|
||||||
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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue