Social Media Manager: Route, DB, KI-Prompts, Frontend, Rolle; SW by-v338

This commit is contained in:
rene 2026-04-24 19:13:30 +02:00
parent d90d4f1eeb
commit 0df6d569c1
9 changed files with 784 additions and 6 deletions

View file

@ -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

View file

@ -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 (

View file

@ -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"])

View file

@ -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
View 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, 2045 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]

View file

@ -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>

View file

@ -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)

View 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,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// 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 };
})();

View file

@ -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