aktueller stand
This commit is contained in:
1
src/web/__init__.py
Normal file
1
src/web/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Web frontend package."""
|
||||
4
src/web/admin/__init__.py
Normal file
4
src/web/admin/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Admin panel module."""
|
||||
from src.web.admin.routes import admin_router
|
||||
|
||||
__all__ = ["admin_router"]
|
||||
32
src/web/admin/auth.py
Normal file
32
src/web/admin/auth.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Admin authentication (password-based)."""
|
||||
import hashlib
|
||||
import secrets
|
||||
from fastapi import Request, HTTPException
|
||||
|
||||
from src.config import settings
|
||||
|
||||
# Authentication
|
||||
WEB_PASSWORD = settings.web_password
|
||||
SESSION_SECRET = settings.session_secret or secrets.token_hex(32)
|
||||
AUTH_COOKIE_NAME = "linkedin_admin_auth"
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash password with session secret."""
|
||||
return hashlib.sha256(f"{password}{SESSION_SECRET}".encode()).hexdigest()
|
||||
|
||||
|
||||
def verify_auth(request: Request) -> bool:
|
||||
"""Check if request is authenticated for admin."""
|
||||
if not WEB_PASSWORD:
|
||||
return True # No password set, allow access
|
||||
cookie = request.cookies.get(AUTH_COOKIE_NAME)
|
||||
if not cookie:
|
||||
return False
|
||||
return cookie == hash_password(WEB_PASSWORD)
|
||||
|
||||
|
||||
async def require_auth(request: Request):
|
||||
"""Dependency to require admin authentication."""
|
||||
if not verify_auth(request):
|
||||
raise HTTPException(status_code=302, headers={"Location": "/admin/login"})
|
||||
693
src/web/admin/routes.py
Normal file
693
src/web/admin/routes.py
Normal file
@@ -0,0 +1,693 @@
|
||||
"""Admin panel routes (password-protected)."""
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Request, Form, BackgroundTasks, HTTPException
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
from loguru import logger
|
||||
|
||||
from src.config import settings
|
||||
from src.database import db
|
||||
from src.orchestrator import orchestrator
|
||||
from src.email_service import email_service
|
||||
from src.web.admin.auth import (
|
||||
WEB_PASSWORD, AUTH_COOKIE_NAME, hash_password, verify_auth
|
||||
)
|
||||
from src.web.user.auth import UserSession, set_user_session
|
||||
|
||||
# Router with /admin prefix
|
||||
admin_router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
# Templates
|
||||
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates" / "admin")
|
||||
|
||||
# Store for progress updates
|
||||
progress_store = {}
|
||||
|
||||
|
||||
async def get_customer_profile_picture(customer_id: UUID) -> Optional[str]:
|
||||
"""Get profile picture URL from customer's LinkedIn posts."""
|
||||
linkedin_posts = await db.get_linkedin_posts(customer_id)
|
||||
for lp in linkedin_posts:
|
||||
if lp.raw_data and isinstance(lp.raw_data, dict):
|
||||
author = lp.raw_data.get("author", {})
|
||||
if author and isinstance(author, dict):
|
||||
profile_picture_url = author.get("profile_picture")
|
||||
if profile_picture_url:
|
||||
return profile_picture_url
|
||||
return None
|
||||
|
||||
|
||||
# ==================== AUTH ROUTES ====================
|
||||
|
||||
@admin_router.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request, error: str = None):
|
||||
"""Admin login page."""
|
||||
if not WEB_PASSWORD:
|
||||
return RedirectResponse(url="/admin", status_code=302)
|
||||
if verify_auth(request):
|
||||
return RedirectResponse(url="/admin", status_code=302)
|
||||
return templates.TemplateResponse("login.html", {
|
||||
"request": request,
|
||||
"error": error
|
||||
})
|
||||
|
||||
|
||||
@admin_router.post("/login")
|
||||
async def login(request: Request, password: str = Form(...)):
|
||||
"""Handle admin login."""
|
||||
if password == WEB_PASSWORD:
|
||||
response = RedirectResponse(url="/admin", status_code=302)
|
||||
response.set_cookie(
|
||||
key=AUTH_COOKIE_NAME,
|
||||
value=hash_password(WEB_PASSWORD),
|
||||
httponly=True,
|
||||
max_age=60 * 60 * 24 * 7,
|
||||
samesite="lax"
|
||||
)
|
||||
return response
|
||||
return RedirectResponse(url="/admin/login?error=invalid", status_code=302)
|
||||
|
||||
|
||||
@admin_router.get("/logout")
|
||||
async def logout():
|
||||
"""Handle admin logout."""
|
||||
response = RedirectResponse(url="/admin/login", status_code=302)
|
||||
response.delete_cookie(AUTH_COOKIE_NAME)
|
||||
return response
|
||||
|
||||
|
||||
@admin_router.get("/impersonate/{customer_id}")
|
||||
async def impersonate_user(request: Request, customer_id: UUID):
|
||||
"""Login as a user without OAuth (for testing).
|
||||
|
||||
Creates a user session for the given customer and redirects to the user dashboard.
|
||||
Only accessible by authenticated admins.
|
||||
"""
|
||||
if not verify_auth(request):
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
|
||||
try:
|
||||
customer = await db.get_customer(customer_id)
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
|
||||
# Extract vanity name from LinkedIn URL if available
|
||||
linkedin_vanity = ""
|
||||
if customer.linkedin_url:
|
||||
import re
|
||||
match = re.search(r'linkedin\.com/in/([^/?]+)', customer.linkedin_url)
|
||||
if match:
|
||||
linkedin_vanity = match.group(1)
|
||||
|
||||
# Get profile picture
|
||||
profile_picture = await get_customer_profile_picture(customer_id)
|
||||
|
||||
# Create user session
|
||||
session = UserSession(
|
||||
customer_id=str(customer.id),
|
||||
customer_name=customer.name,
|
||||
linkedin_vanity_name=linkedin_vanity or customer.name.lower().replace(" ", "-"),
|
||||
linkedin_name=customer.name,
|
||||
linkedin_picture=profile_picture,
|
||||
email=customer.email
|
||||
)
|
||||
|
||||
# Redirect to user dashboard with session cookie
|
||||
response = RedirectResponse(url="/", status_code=302)
|
||||
set_user_session(response, session)
|
||||
return response
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error impersonating user: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== PAGES ====================
|
||||
|
||||
@admin_router.get("", response_class=HTMLResponse)
|
||||
@admin_router.get("/", response_class=HTMLResponse)
|
||||
async def home(request: Request):
|
||||
"""Admin dashboard."""
|
||||
if not verify_auth(request):
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
try:
|
||||
customers = await db.list_customers()
|
||||
total_posts = 0
|
||||
for customer in customers:
|
||||
posts = await db.get_generated_posts(customer.id)
|
||||
total_posts += len(posts)
|
||||
|
||||
return templates.TemplateResponse("dashboard.html", {
|
||||
"request": request,
|
||||
"page": "home",
|
||||
"customers_count": len(customers),
|
||||
"total_posts": total_posts
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading dashboard: {e}")
|
||||
return templates.TemplateResponse("dashboard.html", {
|
||||
"request": request,
|
||||
"page": "home",
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
|
||||
@admin_router.get("/customers/new", response_class=HTMLResponse)
|
||||
async def new_customer_page(request: Request):
|
||||
"""New customer setup page."""
|
||||
if not verify_auth(request):
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
return templates.TemplateResponse("new_customer.html", {
|
||||
"request": request,
|
||||
"page": "new_customer"
|
||||
})
|
||||
|
||||
|
||||
@admin_router.get("/research", response_class=HTMLResponse)
|
||||
async def research_page(request: Request):
|
||||
"""Research topics page."""
|
||||
if not verify_auth(request):
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
customers = await db.list_customers()
|
||||
return templates.TemplateResponse("research.html", {
|
||||
"request": request,
|
||||
"page": "research",
|
||||
"customers": customers
|
||||
})
|
||||
|
||||
|
||||
@admin_router.get("/create", response_class=HTMLResponse)
|
||||
async def create_post_page(request: Request):
|
||||
"""Create post page."""
|
||||
if not verify_auth(request):
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
customers = await db.list_customers()
|
||||
return templates.TemplateResponse("create_post.html", {
|
||||
"request": request,
|
||||
"page": "create",
|
||||
"customers": customers
|
||||
})
|
||||
|
||||
|
||||
@admin_router.get("/posts", response_class=HTMLResponse)
|
||||
async def posts_page(request: Request):
|
||||
"""View all posts page."""
|
||||
if not verify_auth(request):
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
try:
|
||||
customers = await db.list_customers()
|
||||
customers_with_posts = []
|
||||
|
||||
for customer in customers:
|
||||
posts = await db.get_generated_posts(customer.id)
|
||||
profile_picture = await get_customer_profile_picture(customer.id)
|
||||
customers_with_posts.append({
|
||||
"customer": customer,
|
||||
"posts": posts,
|
||||
"post_count": len(posts),
|
||||
"profile_picture": profile_picture
|
||||
})
|
||||
|
||||
return templates.TemplateResponse("posts.html", {
|
||||
"request": request,
|
||||
"page": "posts",
|
||||
"customers_with_posts": customers_with_posts,
|
||||
"total_posts": sum(c["post_count"] for c in customers_with_posts)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading posts: {e}")
|
||||
return templates.TemplateResponse("posts.html", {
|
||||
"request": request,
|
||||
"page": "posts",
|
||||
"customers_with_posts": [],
|
||||
"total_posts": 0,
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
|
||||
@admin_router.get("/posts/{post_id}", response_class=HTMLResponse)
|
||||
async def post_detail_page(request: Request, post_id: str):
|
||||
"""Detailed view of a single post."""
|
||||
if not verify_auth(request):
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
try:
|
||||
post = await db.get_generated_post(UUID(post_id))
|
||||
if not post:
|
||||
return RedirectResponse(url="/admin/posts", status_code=302)
|
||||
|
||||
customer = await db.get_customer(post.customer_id)
|
||||
linkedin_posts = await db.get_linkedin_posts(post.customer_id)
|
||||
reference_posts = [p.post_text for p in linkedin_posts if p.post_text and len(p.post_text) > 100][:10]
|
||||
|
||||
profile_picture_url = None
|
||||
for lp in linkedin_posts:
|
||||
if lp.raw_data and isinstance(lp.raw_data, dict):
|
||||
author = lp.raw_data.get("author", {})
|
||||
if author and isinstance(author, dict):
|
||||
profile_picture_url = author.get("profile_picture")
|
||||
if profile_picture_url:
|
||||
break
|
||||
|
||||
profile_analysis_record = await db.get_profile_analysis(post.customer_id)
|
||||
profile_analysis = profile_analysis_record.full_analysis if profile_analysis_record else None
|
||||
|
||||
post_type = None
|
||||
post_type_analysis = None
|
||||
if post.post_type_id:
|
||||
post_type = await db.get_post_type(post.post_type_id)
|
||||
if post_type and post_type.analysis:
|
||||
post_type_analysis = post_type.analysis
|
||||
|
||||
final_feedback = None
|
||||
if post.critic_feedback and len(post.critic_feedback) > 0:
|
||||
final_feedback = post.critic_feedback[-1]
|
||||
|
||||
return templates.TemplateResponse("post_detail.html", {
|
||||
"request": request,
|
||||
"page": "posts",
|
||||
"post": post,
|
||||
"customer": customer,
|
||||
"reference_posts": reference_posts,
|
||||
"profile_analysis": profile_analysis,
|
||||
"post_type": post_type,
|
||||
"post_type_analysis": post_type_analysis,
|
||||
"final_feedback": final_feedback,
|
||||
"profile_picture_url": profile_picture_url
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading post detail: {e}")
|
||||
return RedirectResponse(url="/admin/posts", status_code=302)
|
||||
|
||||
|
||||
@admin_router.get("/status", response_class=HTMLResponse)
|
||||
async def status_page(request: Request):
|
||||
"""Customer status page."""
|
||||
if not verify_auth(request):
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
try:
|
||||
customers = await db.list_customers()
|
||||
customer_statuses = []
|
||||
|
||||
for customer in customers:
|
||||
status = await orchestrator.get_customer_status(customer.id)
|
||||
profile_picture = await get_customer_profile_picture(customer.id)
|
||||
customer_statuses.append({
|
||||
"customer": customer,
|
||||
"status": status,
|
||||
"profile_picture": profile_picture
|
||||
})
|
||||
|
||||
return templates.TemplateResponse("status.html", {
|
||||
"request": request,
|
||||
"page": "status",
|
||||
"customer_statuses": customer_statuses
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading status: {e}")
|
||||
return templates.TemplateResponse("status.html", {
|
||||
"request": request,
|
||||
"page": "status",
|
||||
"customer_statuses": [],
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
|
||||
@admin_router.get("/scraped-posts", response_class=HTMLResponse)
|
||||
async def scraped_posts_page(request: Request):
|
||||
"""Manage scraped LinkedIn posts."""
|
||||
if not verify_auth(request):
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
customers = await db.list_customers()
|
||||
return templates.TemplateResponse("scraped_posts.html", {
|
||||
"request": request,
|
||||
"page": "scraped_posts",
|
||||
"customers": customers
|
||||
})
|
||||
|
||||
|
||||
# ==================== API ENDPOINTS ====================
|
||||
|
||||
@admin_router.post("/api/customers")
|
||||
async def create_customer(
|
||||
background_tasks: BackgroundTasks,
|
||||
name: str = Form(...),
|
||||
linkedin_url: str = Form(...),
|
||||
company_name: str = Form(None),
|
||||
email: str = Form(None),
|
||||
persona: str = Form(None),
|
||||
form_of_address: str = Form(None),
|
||||
style_guide: str = Form(None),
|
||||
post_types_json: str = Form(None)
|
||||
):
|
||||
"""Create a new customer and run initial setup."""
|
||||
task_id = f"setup_{name}_{asyncio.get_event_loop().time()}"
|
||||
progress_store[task_id] = {"status": "starting", "message": "Starte Setup...", "progress": 0}
|
||||
|
||||
customer_data = {
|
||||
"company_name": company_name,
|
||||
"email": email,
|
||||
"persona": persona,
|
||||
"form_of_address": form_of_address,
|
||||
"style_guide": style_guide,
|
||||
"topic_history": [],
|
||||
"example_posts": []
|
||||
}
|
||||
|
||||
post_types_data = None
|
||||
if post_types_json:
|
||||
try:
|
||||
post_types_data = json.loads(post_types_json)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Failed to parse post_types_json")
|
||||
|
||||
async def run_setup():
|
||||
try:
|
||||
progress_store[task_id] = {"status": "running", "message": "Erstelle Kunde...", "progress": 10}
|
||||
await asyncio.sleep(0.1)
|
||||
progress_store[task_id] = {"status": "running", "message": "Scrape LinkedIn Posts...", "progress": 30}
|
||||
|
||||
customer = await orchestrator.run_initial_setup(
|
||||
linkedin_url=linkedin_url,
|
||||
customer_name=name,
|
||||
customer_data=customer_data,
|
||||
post_types_data=post_types_data
|
||||
)
|
||||
|
||||
progress_store[task_id] = {
|
||||
"status": "completed",
|
||||
"message": "Setup abgeschlossen!",
|
||||
"progress": 100,
|
||||
"customer_id": str(customer.id)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception(f"Setup failed: {e}")
|
||||
progress_store[task_id] = {"status": "error", "message": str(e), "progress": 0}
|
||||
|
||||
background_tasks.add_task(run_setup)
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
@admin_router.get("/api/tasks/{task_id}")
|
||||
async def get_task_status(task_id: str):
|
||||
"""Get task progress."""
|
||||
return progress_store.get(task_id, {"status": "unknown", "message": "Task not found"})
|
||||
|
||||
|
||||
@admin_router.get("/api/customers/{customer_id}/post-types")
|
||||
async def get_customer_post_types(customer_id: str):
|
||||
"""Get post types for a customer."""
|
||||
try:
|
||||
post_types = await db.get_post_types(UUID(customer_id))
|
||||
return {
|
||||
"post_types": [
|
||||
{
|
||||
"id": str(pt.id),
|
||||
"name": pt.name,
|
||||
"description": pt.description,
|
||||
"identifying_hashtags": pt.identifying_hashtags,
|
||||
"identifying_keywords": pt.identifying_keywords,
|
||||
"semantic_properties": pt.semantic_properties,
|
||||
"has_analysis": pt.analysis is not None,
|
||||
"analyzed_post_count": pt.analyzed_post_count,
|
||||
"is_active": pt.is_active
|
||||
}
|
||||
for pt in post_types
|
||||
]
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading post types: {e}")
|
||||
return {"post_types": [], "error": str(e)}
|
||||
|
||||
|
||||
@admin_router.get("/api/customers/{customer_id}/linkedin-posts")
|
||||
async def get_customer_linkedin_posts(customer_id: str):
|
||||
"""Get all scraped LinkedIn posts for a customer."""
|
||||
try:
|
||||
posts = await db.get_linkedin_posts(UUID(customer_id))
|
||||
result_posts = []
|
||||
for post in posts:
|
||||
try:
|
||||
result_posts.append({
|
||||
"id": str(post.id),
|
||||
"post_text": post.post_text,
|
||||
"post_url": post.post_url,
|
||||
"posted_at": post.post_date.isoformat() if post.post_date else None,
|
||||
"engagement_score": (post.likes or 0) + (post.comments or 0) + (post.shares or 0),
|
||||
"likes": post.likes,
|
||||
"comments": post.comments,
|
||||
"shares": post.shares,
|
||||
"post_type_id": str(post.post_type_id) if post.post_type_id else None,
|
||||
"classification_method": post.classification_method,
|
||||
"classification_confidence": post.classification_confidence
|
||||
})
|
||||
except Exception as post_error:
|
||||
logger.error(f"Error processing post {post.id}: {post_error}")
|
||||
return {"posts": result_posts, "total": len(result_posts)}
|
||||
except Exception as e:
|
||||
logger.exception(f"Error loading LinkedIn posts: {e}")
|
||||
return {"posts": [], "total": 0, "error": str(e)}
|
||||
|
||||
|
||||
class ClassifyPostRequest(BaseModel):
|
||||
post_type_id: Optional[str] = None
|
||||
|
||||
|
||||
@admin_router.patch("/api/linkedin-posts/{post_id}/classify")
|
||||
async def classify_linkedin_post(post_id: str, request: ClassifyPostRequest):
|
||||
"""Manually classify a LinkedIn post."""
|
||||
try:
|
||||
if request.post_type_id:
|
||||
await db.update_post_classification(
|
||||
post_id=UUID(post_id),
|
||||
post_type_id=UUID(request.post_type_id),
|
||||
classification_method="manual",
|
||||
classification_confidence=1.0
|
||||
)
|
||||
else:
|
||||
await asyncio.to_thread(
|
||||
lambda: db.client.table("linkedin_posts").update({
|
||||
"post_type_id": None,
|
||||
"classification_method": None,
|
||||
"classification_confidence": None
|
||||
}).eq("id", post_id).execute()
|
||||
)
|
||||
return {"success": True, "post_id": post_id}
|
||||
except Exception as e:
|
||||
logger.error(f"Error classifying post: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@admin_router.post("/api/customers/{customer_id}/classify-posts")
|
||||
async def classify_customer_posts(customer_id: str, background_tasks: BackgroundTasks):
|
||||
"""Trigger post classification for a customer."""
|
||||
task_id = f"classify_{customer_id}_{asyncio.get_event_loop().time()}"
|
||||
progress_store[task_id] = {"status": "starting", "message": "Starte Klassifizierung...", "progress": 0}
|
||||
|
||||
async def run_classification():
|
||||
try:
|
||||
progress_store[task_id] = {"status": "running", "message": "Klassifiziere Posts...", "progress": 50}
|
||||
count = await orchestrator.classify_posts(UUID(customer_id))
|
||||
progress_store[task_id] = {
|
||||
"status": "completed",
|
||||
"message": f"{count} Posts klassifiziert",
|
||||
"progress": 100,
|
||||
"classified_count": count
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception(f"Classification failed: {e}")
|
||||
progress_store[task_id] = {"status": "error", "message": str(e), "progress": 0}
|
||||
|
||||
background_tasks.add_task(run_classification)
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
@admin_router.post("/api/customers/{customer_id}/analyze-post-types")
|
||||
async def analyze_customer_post_types(customer_id: str, background_tasks: BackgroundTasks):
|
||||
"""Trigger post type analysis for a customer."""
|
||||
task_id = f"analyze_{customer_id}_{asyncio.get_event_loop().time()}"
|
||||
progress_store[task_id] = {"status": "starting", "message": "Starte Analyse...", "progress": 0}
|
||||
|
||||
async def run_analysis():
|
||||
try:
|
||||
progress_store[task_id] = {"status": "running", "message": "Analysiere Post-Typen...", "progress": 50}
|
||||
results = await orchestrator.analyze_post_types(UUID(customer_id))
|
||||
analyzed_count = sum(1 for r in results.values() if r.get("sufficient_data"))
|
||||
progress_store[task_id] = {
|
||||
"status": "completed",
|
||||
"message": f"{analyzed_count} Post-Typen analysiert",
|
||||
"progress": 100,
|
||||
"results": results
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception(f"Analysis failed: {e}")
|
||||
progress_store[task_id] = {"status": "error", "message": str(e), "progress": 0}
|
||||
|
||||
background_tasks.add_task(run_analysis)
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
@admin_router.get("/api/customers/{customer_id}/topics")
|
||||
async def get_customer_topics(customer_id: str, include_used: bool = False, post_type_id: str = None):
|
||||
"""Get research topics for a customer."""
|
||||
try:
|
||||
if post_type_id:
|
||||
all_research = await db.get_all_research(UUID(customer_id), UUID(post_type_id))
|
||||
else:
|
||||
all_research = await db.get_all_research(UUID(customer_id))
|
||||
|
||||
used_topic_titles = set()
|
||||
if not include_used:
|
||||
generated_posts = await db.get_generated_posts(UUID(customer_id))
|
||||
for post in generated_posts:
|
||||
if post.topic_title:
|
||||
used_topic_titles.add(post.topic_title.lower().strip())
|
||||
|
||||
all_topics = []
|
||||
for research in all_research:
|
||||
if research.suggested_topics:
|
||||
for topic in research.suggested_topics:
|
||||
topic_title = topic.get("title", "").lower().strip()
|
||||
if topic_title in used_topic_titles:
|
||||
continue
|
||||
topic["research_id"] = str(research.id)
|
||||
topic["target_post_type_id"] = str(research.target_post_type_id) if research.target_post_type_id else None
|
||||
all_topics.append(topic)
|
||||
|
||||
return {"topics": all_topics, "used_count": len(used_topic_titles), "available_count": len(all_topics)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading topics: {e}")
|
||||
return {"topics": [], "error": str(e)}
|
||||
|
||||
|
||||
@admin_router.post("/api/research")
|
||||
async def start_research(background_tasks: BackgroundTasks, customer_id: str = Form(...), post_type_id: str = Form(None)):
|
||||
"""Start research for a customer."""
|
||||
task_id = f"research_{customer_id}_{asyncio.get_event_loop().time()}"
|
||||
progress_store[task_id] = {"status": "starting", "message": "Starte Recherche...", "progress": 0}
|
||||
|
||||
async def run_research():
|
||||
try:
|
||||
def progress_callback(message: str, step: int, total: int):
|
||||
progress_store[task_id] = {"status": "running", "message": message, "progress": int((step / total) * 100)}
|
||||
|
||||
topics = await orchestrator.research_new_topics(
|
||||
UUID(customer_id),
|
||||
progress_callback=progress_callback,
|
||||
post_type_id=UUID(post_type_id) if post_type_id else None
|
||||
)
|
||||
progress_store[task_id] = {"status": "completed", "message": f"{len(topics)} Topics gefunden!", "progress": 100, "topics": topics}
|
||||
except Exception as e:
|
||||
logger.exception(f"Research failed: {e}")
|
||||
progress_store[task_id] = {"status": "error", "message": str(e), "progress": 0}
|
||||
|
||||
background_tasks.add_task(run_research)
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
@admin_router.post("/api/posts")
|
||||
async def create_post(background_tasks: BackgroundTasks, customer_id: str = Form(...), topic_json: str = Form(...), post_type_id: str = Form(None)):
|
||||
"""Create a new post."""
|
||||
task_id = f"post_{customer_id}_{asyncio.get_event_loop().time()}"
|
||||
progress_store[task_id] = {"status": "starting", "message": "Starte Post-Erstellung...", "progress": 0}
|
||||
topic = json.loads(topic_json)
|
||||
|
||||
async def run_create_post():
|
||||
try:
|
||||
def progress_callback(message: str, iteration: int, max_iterations: int, score: int = None, versions: list = None, feedback_list: list = None):
|
||||
progress = int((iteration / max_iterations) * 100) if iteration > 0 else 5
|
||||
score_text = f" (Score: {score}/100)" if score else ""
|
||||
progress_store[task_id] = {
|
||||
"status": "running", "message": f"{message}{score_text}", "progress": progress,
|
||||
"iteration": iteration, "max_iterations": max_iterations,
|
||||
"versions": versions or [], "feedback_list": feedback_list or []
|
||||
}
|
||||
|
||||
result = await orchestrator.create_post(
|
||||
customer_id=UUID(customer_id), topic=topic, max_iterations=3,
|
||||
progress_callback=progress_callback,
|
||||
post_type_id=UUID(post_type_id) if post_type_id else None
|
||||
)
|
||||
progress_store[task_id] = {
|
||||
"status": "completed", "message": "Post erstellt!", "progress": 100,
|
||||
"result": {
|
||||
"post_id": str(result["post_id"]), "final_post": result["final_post"],
|
||||
"iterations": result["iterations"], "final_score": result["final_score"], "approved": result["approved"]
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception(f"Post creation failed: {e}")
|
||||
progress_store[task_id] = {"status": "error", "message": str(e), "progress": 0}
|
||||
|
||||
background_tasks.add_task(run_create_post)
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
@admin_router.get("/api/posts")
|
||||
async def get_all_posts():
|
||||
"""Get all posts as JSON."""
|
||||
customers = await db.list_customers()
|
||||
all_posts = []
|
||||
for customer in customers:
|
||||
posts = await db.get_generated_posts(customer.id)
|
||||
for post in posts:
|
||||
all_posts.append({
|
||||
"id": str(post.id), "customer_name": customer.name, "topic_title": post.topic_title,
|
||||
"content": post.post_content, "iterations": post.iterations, "status": post.status,
|
||||
"created_at": post.created_at.isoformat() if post.created_at else None
|
||||
})
|
||||
return {"posts": all_posts, "total": len(all_posts)}
|
||||
|
||||
|
||||
class EmailRequest(BaseModel):
|
||||
recipient: str
|
||||
post_id: str
|
||||
|
||||
|
||||
@admin_router.get("/api/email/config")
|
||||
async def get_email_config(request: Request):
|
||||
"""Check if email is configured."""
|
||||
if not verify_auth(request):
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
return {"configured": email_service.is_configured(), "default_recipient": settings.email_default_recipient or ""}
|
||||
|
||||
|
||||
@admin_router.post("/api/email/send")
|
||||
async def send_post_email(request: Request, email_request: EmailRequest):
|
||||
"""Send a post via email."""
|
||||
if not verify_auth(request):
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
if not email_service.is_configured():
|
||||
raise HTTPException(status_code=400, detail="E-Mail ist nicht konfiguriert.")
|
||||
|
||||
try:
|
||||
post = await db.get_generated_post(UUID(email_request.post_id))
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post nicht gefunden")
|
||||
|
||||
customer = await db.get_customer(post.customer_id)
|
||||
score = None
|
||||
if post.critic_feedback and len(post.critic_feedback) > 0:
|
||||
score = post.critic_feedback[-1].get("overall_score")
|
||||
|
||||
success = email_service.send_post(
|
||||
recipient=email_request.recipient, post_content=post.post_content,
|
||||
topic_title=post.topic_title or "LinkedIn Post",
|
||||
customer_name=customer.name if customer else "Unbekannt", score=score
|
||||
)
|
||||
if success:
|
||||
return {"success": True, "message": f"E-Mail wurde an {email_request.recipient} gesendet"}
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="E-Mail konnte nicht gesendet werden.")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending email: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler beim Senden: {str(e)}")
|
||||
39
src/web/app.py
Normal file
39
src/web/app.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""FastAPI web frontend for LinkedIn Post Creation System."""
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from src.config import settings
|
||||
from src.web.admin import admin_router
|
||||
|
||||
# Setup
|
||||
app = FastAPI(title="LinkedIn Post Creation System")
|
||||
|
||||
# Static files
|
||||
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
|
||||
|
||||
# Include admin router (always available)
|
||||
app.include_router(admin_router)
|
||||
|
||||
# Include user router if enabled
|
||||
if settings.user_frontend_enabled:
|
||||
from src.web.user import user_router
|
||||
app.include_router(user_router)
|
||||
else:
|
||||
# Root redirect only when user frontend is disabled
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Redirect root to admin frontend."""
|
||||
return RedirectResponse(url="/admin", status_code=302)
|
||||
|
||||
|
||||
def run_web():
|
||||
"""Run the web server."""
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_web()
|
||||
BIN
src/web/static/logo.png
Normal file
BIN
src/web/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
105
src/web/templates/admin/base.html
Normal file
105
src/web/templates/admin/base.html
Normal file
@@ -0,0 +1,105 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Admin - LinkedIn Posts{% endblock %}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'brand': {
|
||||
'bg': '#3d4848',
|
||||
'bg-light': '#4a5858',
|
||||
'bg-dark': '#2d3838',
|
||||
'highlight': '#ffc700',
|
||||
'highlight-dark': '#e6b300',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body { background-color: #3d4848; }
|
||||
.nav-link.active { background-color: #ffc700; color: #2d3838; }
|
||||
.nav-link.active svg { stroke: #2d3838; }
|
||||
.post-content { white-space: pre-wrap; word-wrap: break-word; }
|
||||
.btn-primary { background-color: #ffc700; color: #2d3838; }
|
||||
.btn-primary:hover { background-color: #e6b300; }
|
||||
.sidebar-bg { background-color: #2d3838; }
|
||||
.card-bg { background-color: #4a5858; border-color: #5a6868; }
|
||||
.input-bg { background-color: #3d4848; border-color: #5a6868; }
|
||||
.input-bg:focus { border-color: #ffc700; outline: none; }
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: #3d4848; }
|
||||
::-webkit-scrollbar-thumb { background: #5a6868; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #6a7878; }
|
||||
</style>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body class="text-gray-100 min-h-screen flex">
|
||||
<!-- Sidebar -->
|
||||
<aside class="w-64 sidebar-bg border-r border-gray-600 flex flex-col fixed h-full">
|
||||
<div class="p-4 border-b border-gray-600">
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<div>
|
||||
<img src="/static/logo.png" alt="Logo" class="h-15 w-auto">
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center mt-2">
|
||||
<span class="text-xs text-brand-highlight font-medium px-2 py-1 bg-brand-highlight/20 rounded">ADMIN</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 p-4 space-y-2">
|
||||
<a href="/admin" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'home' %}active{% endif %}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></svg>
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="/admin/customers/new" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'new_customer' %}active{% endif %}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"/></svg>
|
||||
Neuer Kunde
|
||||
</a>
|
||||
<a href="/admin/research" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'research' %}active{% endif %}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
Research Topics
|
||||
</a>
|
||||
<a href="/admin/create" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'create' %}active{% endif %}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
Post erstellen
|
||||
</a>
|
||||
<a href="/admin/posts" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'posts' %}active{% endif %}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
|
||||
Alle Posts
|
||||
</a>
|
||||
<a href="/admin/scraped-posts" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'scraped_posts' %}active{% endif %}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/></svg>
|
||||
Post-Typen
|
||||
</a>
|
||||
<a href="/admin/status" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'status' %}active{% endif %}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
|
||||
Status
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="p-4 border-t border-gray-600">
|
||||
<a href="/admin/logout" class="flex items-center gap-2 text-gray-400 hover:text-gray-200 text-sm transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
|
||||
Logout
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 ml-64">
|
||||
<div class="p-8">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
539
src/web/templates/admin/create_post.html
Normal file
539
src/web/templates/admin/create_post.html
Normal file
@@ -0,0 +1,539 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Post erstellen - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Post erstellen</h1>
|
||||
<p class="text-gray-400">Generiere einen neuen LinkedIn Post mit AI</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<!-- Left: Form -->
|
||||
<div>
|
||||
<form id="createPostForm" class="card-bg rounded-xl border p-6 space-y-6">
|
||||
<!-- Customer Selection -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Kunde auswählen</label>
|
||||
<select name="customer_id" id="customerSelect" required class="w-full input-bg border rounded-lg px-4 py-3 text-white">
|
||||
<option value="">-- Kunde wählen --</option>
|
||||
{% for customer in customers %}
|
||||
<option value="{{ customer.id }}">{{ customer.name }} - {{ customer.company_name or 'Kein Unternehmen' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Post Type Selection -->
|
||||
<div id="postTypeSelectionArea" class="hidden">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Post-Typ auswählen (optional)</label>
|
||||
<div id="postTypeCards" class="flex flex-wrap gap-2 mb-2">
|
||||
<!-- Post type cards will be loaded here -->
|
||||
</div>
|
||||
<input type="hidden" id="selectedPostTypeId" value="">
|
||||
</div>
|
||||
|
||||
<!-- Topic Selection -->
|
||||
<div id="topicSelectionArea" class="hidden">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Topic auswählen</label>
|
||||
<div id="topicsList" class="space-y-2 max-h-64 overflow-y-auto">
|
||||
<p class="text-gray-500">Lade Topics...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Topic -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||
<span>Oder eigenes Topic eingeben</span>
|
||||
</label>
|
||||
<div class="space-y-3">
|
||||
<input type="text" id="customTopicTitle" placeholder="Topic Titel" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
<textarea id="customTopicFact" rows="3" placeholder="Fakt / Kernaussage zum Topic..." class="w-full input-bg border rounded-lg px-4 py-2 text-white"></textarea>
|
||||
<input type="text" id="customTopicSource" placeholder="Quelle (optional)" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Area -->
|
||||
<div id="progressArea" class="hidden">
|
||||
<div class="bg-brand-bg rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span id="progressMessage" class="text-gray-300">Starte Post-Erstellung...</span>
|
||||
<span id="progressPercent" class="text-gray-400">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-brand-bg-dark rounded-full h-2">
|
||||
<div id="progressBar" class="bg-brand-highlight h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
<div id="iterationInfo" class="mt-2 text-sm text-gray-400"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="submitBtn" class="w-full btn-primary font-medium py-3 rounded-lg transition-colors flex items-center justify-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
Post generieren
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% if not customers %}
|
||||
<div class="mt-4 bg-yellow-900/30 border border-yellow-600 rounded-lg p-4">
|
||||
<p class="text-yellow-300">Noch keine Kunden vorhanden. <a href="/admin/customers/new" class="underline">Erstelle zuerst einen Kunden</a>.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Right: Result -->
|
||||
<div>
|
||||
<div id="resultArea" class="card-bg rounded-xl border p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Generierter Post</h3>
|
||||
|
||||
<!-- Live Versions Display -->
|
||||
<div id="liveVersions" class="hidden space-y-4 mb-6">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-sm text-gray-400">Live-Vorschau der Iterationen:</span>
|
||||
</div>
|
||||
<div id="versionsContainer" class="space-y-4"></div>
|
||||
</div>
|
||||
|
||||
<div id="postResult">
|
||||
<p class="text-gray-400">Wähle einen Kunden und ein Topic, dann klicke auf "Post generieren"...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const form = document.getElementById('createPostForm');
|
||||
const customerSelect = document.getElementById('customerSelect');
|
||||
const topicSelectionArea = document.getElementById('topicSelectionArea');
|
||||
const topicsList = document.getElementById('topicsList');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const progressArea = document.getElementById('progressArea');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressMessage = document.getElementById('progressMessage');
|
||||
const progressPercent = document.getElementById('progressPercent');
|
||||
const iterationInfo = document.getElementById('iterationInfo');
|
||||
const postResult = document.getElementById('postResult');
|
||||
const liveVersions = document.getElementById('liveVersions');
|
||||
const versionsContainer = document.getElementById('versionsContainer');
|
||||
const postTypeSelectionArea = document.getElementById('postTypeSelectionArea');
|
||||
const postTypeCards = document.getElementById('postTypeCards');
|
||||
const selectedPostTypeIdInput = document.getElementById('selectedPostTypeId');
|
||||
|
||||
let selectedTopic = null;
|
||||
let currentVersionIndex = 0;
|
||||
let currentPostTypes = [];
|
||||
let currentTopics = [];
|
||||
|
||||
function renderVersions(versions, feedbackList) {
|
||||
if (!versions || versions.length === 0) {
|
||||
liveVersions.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
liveVersions.classList.remove('hidden');
|
||||
|
||||
// Build version tabs and content
|
||||
let html = `
|
||||
<div class="flex gap-2 mb-4 flex-wrap">
|
||||
${versions.map((_, i) => `
|
||||
<button onclick="showVersion(${i})" id="versionTab${i}"
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors
|
||||
${i === currentVersionIndex ? 'bg-brand-highlight text-brand-bg-dark' : 'bg-brand-bg text-gray-300 hover:bg-brand-bg-light'}">
|
||||
V${i + 1}
|
||||
${feedbackList[i] ? `<span class="ml-1 text-xs opacity-75">(${feedbackList[i].overall_score || '?'})</span>` : ''}
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Show current version
|
||||
const currentVersion = versions[currentVersionIndex];
|
||||
const currentFeedback = feedbackList[currentVersionIndex];
|
||||
|
||||
html += `
|
||||
<div class="grid grid-cols-1 ${currentFeedback ? 'lg:grid-cols-2' : ''} gap-4">
|
||||
<div class="bg-brand-bg/50 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium text-gray-300">Version ${currentVersionIndex + 1}</span>
|
||||
${currentFeedback ? `
|
||||
<span class="px-2 py-0.5 text-xs rounded ${currentFeedback.approved ? 'bg-green-600/30 text-green-300' : 'bg-yellow-600/30 text-yellow-300'}">
|
||||
${currentFeedback.approved ? 'Approved' : `Score: ${currentFeedback.overall_score}/100`}
|
||||
</span>
|
||||
` : '<span class="text-xs text-gray-500">Wird bewertet...</span>'}
|
||||
</div>
|
||||
<pre class="whitespace-pre-wrap text-gray-200 font-sans text-sm max-h-96 overflow-y-auto">${currentVersion}</pre>
|
||||
</div>
|
||||
${currentFeedback ? `
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4 border border-brand-bg-light">
|
||||
<span class="text-sm font-medium text-gray-300 block mb-2">Kritik</span>
|
||||
<p class="text-sm text-gray-400 mb-3">${currentFeedback.feedback || 'Keine Kritik'}</p>
|
||||
${currentFeedback.improvements && currentFeedback.improvements.length > 0 ? `
|
||||
<div class="mt-2">
|
||||
<span class="text-xs font-medium text-gray-400">Verbesserungen:</span>
|
||||
<ul class="mt-1 space-y-1">
|
||||
${currentFeedback.improvements.map(imp => `
|
||||
<li class="text-xs text-gray-500 flex items-start gap-1">
|
||||
<span class="text-yellow-500">•</span> ${imp}
|
||||
</li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
${currentFeedback.scores ? `
|
||||
<div class="mt-3 pt-3 border-t border-brand-bg-light">
|
||||
<div class="grid grid-cols-3 gap-2 text-xs">
|
||||
<div class="text-center">
|
||||
<div class="text-gray-500">Authentizität</div>
|
||||
<div class="font-medium text-gray-300">${currentFeedback.scores.authenticity_and_style || '?'}/40</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-gray-500">Content</div>
|
||||
<div class="font-medium text-gray-300">${currentFeedback.scores.content_quality || '?'}/35</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-gray-500">Technik</div>
|
||||
<div class="font-medium text-gray-300">${currentFeedback.scores.technical_execution || '?'}/25</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
versionsContainer.innerHTML = html;
|
||||
}
|
||||
|
||||
function showVersion(index) {
|
||||
currentVersionIndex = index;
|
||||
// Get cached versions from progress store
|
||||
const cachedData = window.lastProgressData;
|
||||
if (cachedData) {
|
||||
renderVersions(cachedData.versions, cachedData.feedback_list);
|
||||
}
|
||||
}
|
||||
|
||||
// Load topics and post types when customer is selected
|
||||
customerSelect.addEventListener('change', async () => {
|
||||
const customerId = customerSelect.value;
|
||||
selectedPostTypeIdInput.value = '';
|
||||
|
||||
if (!customerId) {
|
||||
topicSelectionArea.classList.add('hidden');
|
||||
postTypeSelectionArea.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
topicSelectionArea.classList.remove('hidden');
|
||||
topicsList.innerHTML = '<p class="text-gray-500">Lade Topics...</p>';
|
||||
|
||||
// Load post types
|
||||
try {
|
||||
const ptResponse = await fetch(`/admin/api/customers/${customerId}/post-types`);
|
||||
const ptData = await ptResponse.json();
|
||||
|
||||
if (ptData.post_types && ptData.post_types.length > 0) {
|
||||
currentPostTypes = ptData.post_types;
|
||||
postTypeSelectionArea.classList.remove('hidden');
|
||||
|
||||
postTypeCards.innerHTML = `
|
||||
<button type="button" onclick="selectPostTypeForCreate('')" id="ptc_all"
|
||||
class="px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-highlight/20 border-brand-highlight text-white">
|
||||
Alle Typen
|
||||
</button>
|
||||
` + ptData.post_types.map(pt => `
|
||||
<button type="button" onclick="selectPostTypeForCreate('${pt.id}')" id="ptc_${pt.id}"
|
||||
class="px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white">
|
||||
${pt.name}
|
||||
${pt.has_analysis ? '<span class="ml-1 text-green-400 text-xs">*</span>' : ''}
|
||||
</button>
|
||||
`).join('');
|
||||
} else {
|
||||
postTypeSelectionArea.classList.add('hidden');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load post types:', error);
|
||||
postTypeSelectionArea.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Load topics
|
||||
try {
|
||||
const response = await fetch(`/admin/api/customers/${customerId}/topics`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.topics && data.topics.length > 0) {
|
||||
renderTopicsList(data);
|
||||
} else {
|
||||
// No topics available - show helpful message
|
||||
let message = '';
|
||||
if (data.used_count > 0) {
|
||||
message = `<div class="text-center py-4">
|
||||
<p class="text-gray-400 mb-2">Alle ${data.used_count} Topics wurden bereits verwendet.</p>
|
||||
<a href="/admin/research" class="text-brand-highlight hover:underline">Neue Topics recherchieren</a>
|
||||
<p class="text-gray-500 text-sm mt-2">oder gib unten ein eigenes Topic ein.</p>
|
||||
</div>`;
|
||||
} else {
|
||||
message = `<div class="text-center py-4">
|
||||
<p class="text-gray-400 mb-2">Keine Topics gefunden.</p>
|
||||
<a href="/admin/research" class="text-brand-highlight hover:underline">Recherche starten</a>
|
||||
<p class="text-gray-500 text-sm mt-2">oder gib unten ein eigenes Topic ein.</p>
|
||||
</div>`;
|
||||
}
|
||||
topicsList.innerHTML = message;
|
||||
}
|
||||
} catch (error) {
|
||||
topicsList.innerHTML = `<p class="text-red-400">Fehler beim Laden: ${error.message}</p>`;
|
||||
}
|
||||
});
|
||||
|
||||
// Clear selected topic when custom topic is entered
|
||||
['customTopicTitle', 'customTopicFact', 'customTopicSource'].forEach(id => {
|
||||
document.getElementById(id).addEventListener('input', () => {
|
||||
selectedTopic = null;
|
||||
document.querySelectorAll('input[name="topic"]').forEach(radio => radio.checked = false);
|
||||
});
|
||||
});
|
||||
|
||||
function selectPostTypeForCreate(typeId) {
|
||||
selectedPostTypeIdInput.value = typeId;
|
||||
|
||||
// Update card styles
|
||||
document.querySelectorAll('[id^="ptc_"]').forEach(card => {
|
||||
if (card.id === `ptc_${typeId}` || (typeId === '' && card.id === 'ptc_all')) {
|
||||
card.className = 'px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-highlight/20 border-brand-highlight text-white';
|
||||
} else {
|
||||
card.className = 'px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white';
|
||||
}
|
||||
});
|
||||
|
||||
// Optionally reload topics filtered by post type
|
||||
const customerId = customerSelect.value;
|
||||
if (customerId) {
|
||||
loadTopicsForPostType(customerId, typeId);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTopicsForPostType(customerId, postTypeId) {
|
||||
topicsList.innerHTML = '<p class="text-gray-500">Lade Topics...</p>';
|
||||
|
||||
try {
|
||||
let url = `/api/customers/${customerId}/topics`;
|
||||
if (postTypeId) {
|
||||
url += `?post_type_id=${postTypeId}`;
|
||||
}
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.topics && data.topics.length > 0) {
|
||||
renderTopicsList(data);
|
||||
} else {
|
||||
let message = '';
|
||||
if (data.used_count > 0) {
|
||||
message = `<div class="text-center py-4">
|
||||
<p class="text-gray-400 mb-2">Alle ${data.used_count} Topics wurden bereits verwendet.</p>
|
||||
<a href="/admin/research" class="text-brand-highlight hover:underline">Neue Topics recherchieren</a>
|
||||
<p class="text-gray-500 text-sm mt-2">oder gib unten ein eigenes Topic ein.</p>
|
||||
</div>`;
|
||||
} else {
|
||||
message = `<div class="text-center py-4">
|
||||
<p class="text-gray-400 mb-2">Keine Topics gefunden${postTypeId ? ' für diesen Post-Typ' : ''}.</p>
|
||||
<a href="/admin/research" class="text-brand-highlight hover:underline">Recherche starten</a>
|
||||
<p class="text-gray-500 text-sm mt-2">oder gib unten ein eigenes Topic ein.</p>
|
||||
</div>`;
|
||||
}
|
||||
topicsList.innerHTML = message;
|
||||
}
|
||||
} catch (error) {
|
||||
topicsList.innerHTML = `<p class="text-red-400">Fehler beim Laden: ${error.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTopicsList(data) {
|
||||
// Store topics in global array for safe access
|
||||
currentTopics = data.topics;
|
||||
|
||||
// Reset selected topic when list is re-rendered
|
||||
selectedTopic = null;
|
||||
|
||||
let statsHtml = '';
|
||||
if (data.used_count > 0) {
|
||||
statsHtml = `<p class="text-xs text-gray-500 mb-3">${data.available_count} verfügbar · ${data.used_count} bereits verwendet</p>`;
|
||||
}
|
||||
|
||||
topicsList.innerHTML = statsHtml + data.topics.map((topic, i) => `
|
||||
<label class="flex items-start gap-3 p-3 bg-brand-bg/50 rounded-lg cursor-pointer hover:bg-brand-bg transition-colors border border-transparent hover:border-brand-highlight/30">
|
||||
<input type="radio" name="topic" value="${i}" class="mt-1 text-brand-highlight" data-topic-index="${i}">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span class="inline-block px-2 py-0.5 text-xs font-medium bg-brand-highlight/20 text-brand-highlight rounded">${escapeHtml(topic.category || 'Topic')}</span>
|
||||
${topic.target_post_type_id ? `<span class="text-xs text-gray-500">Typ-spezifisch</span>` : ''}
|
||||
${topic.source ? `<span class="text-xs text-gray-500">🔗 ${escapeHtml(topic.source.substring(0, 30))}${topic.source.length > 30 ? '...' : ''}</span>` : ''}
|
||||
</div>
|
||||
<p class="font-medium text-white">${escapeHtml(topic.title)}</p>
|
||||
${topic.angle ? `<p class="text-xs text-brand-highlight/80 mt-1">→ ${escapeHtml(topic.angle)}</p>` : ''}
|
||||
${topic.hook_idea ? `<p class="text-sm text-gray-300 mt-2 italic border-l-2 border-brand-highlight/30 pl-2">"${escapeHtml(topic.hook_idea.substring(0, 120))}${topic.hook_idea.length > 120 ? '...' : ''}"</p>` : ''}
|
||||
${topic.key_facts && topic.key_facts.length > 0 ? `
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
${topic.key_facts.slice(0, 2).map(f => `<span class="text-xs bg-brand-bg-dark px-2 py-0.5 rounded text-gray-400">📊 ${escapeHtml(f.substring(0, 40))}${f.length > 40 ? '...' : ''}</span>`).join('')}
|
||||
</div>
|
||||
` : (topic.fact ? `<p class="text-sm text-gray-400 mt-1">${escapeHtml(topic.fact.substring(0, 100))}...</p>` : '')}
|
||||
${topic.why_this_person ? `<p class="text-xs text-gray-500 mt-2">💡 ${escapeHtml(topic.why_this_person.substring(0, 80))}${topic.why_this_person.length > 80 ? '...' : ''}</p>` : ''}
|
||||
</div>
|
||||
</label>
|
||||
`).join('');
|
||||
|
||||
// Add event listeners to radio buttons
|
||||
document.querySelectorAll('input[name="topic"]').forEach(radio => {
|
||||
radio.addEventListener('change', () => {
|
||||
const index = parseInt(radio.dataset.topicIndex, 10);
|
||||
selectedTopic = currentTopics[index];
|
||||
// Clear custom topic fields
|
||||
document.getElementById('customTopicTitle').value = '';
|
||||
document.getElementById('customTopicFact').value = '';
|
||||
document.getElementById('customTopicSource').value = '';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to escape HTML special characters
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const customerId = customerSelect.value;
|
||||
if (!customerId) {
|
||||
alert('Bitte wähle einen Kunden aus.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get topic (either selected or custom)
|
||||
let topic;
|
||||
const customTitle = document.getElementById('customTopicTitle').value.trim();
|
||||
const customFact = document.getElementById('customTopicFact').value.trim();
|
||||
|
||||
if (customTitle && customFact) {
|
||||
topic = {
|
||||
title: customTitle,
|
||||
fact: customFact,
|
||||
source: document.getElementById('customTopicSource').value.trim() || null,
|
||||
category: 'Custom'
|
||||
};
|
||||
} else if (selectedTopic) {
|
||||
topic = selectedTopic;
|
||||
} else {
|
||||
alert('Bitte wähle ein Topic aus oder gib ein eigenes ein.');
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg> Generiert...';
|
||||
progressArea.classList.remove('hidden');
|
||||
postResult.innerHTML = '<p class="text-gray-400">Post wird generiert...</p>';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('customer_id', customerId);
|
||||
formData.append('topic_json', JSON.stringify(topic));
|
||||
if (selectedPostTypeIdInput.value) {
|
||||
formData.append('post_type_id', selectedPostTypeIdInput.value);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/api/posts', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
const taskId = data.task_id;
|
||||
currentVersionIndex = 0;
|
||||
window.lastProgressData = null;
|
||||
|
||||
const pollInterval = setInterval(async () => {
|
||||
const statusResponse = await fetch(`/admin/api/tasks/${taskId}`);
|
||||
const status = await statusResponse.json();
|
||||
|
||||
progressBar.style.width = `${status.progress}%`;
|
||||
progressPercent.textContent = `${status.progress}%`;
|
||||
progressMessage.textContent = status.message;
|
||||
|
||||
if (status.iteration !== undefined) {
|
||||
iterationInfo.textContent = `Iteration ${status.iteration}/${status.max_iterations}`;
|
||||
}
|
||||
|
||||
// Update live versions display
|
||||
if (status.versions && status.versions.length > 0) {
|
||||
window.lastProgressData = status;
|
||||
// Auto-select latest version
|
||||
if (status.versions.length > currentVersionIndex + 1) {
|
||||
currentVersionIndex = status.versions.length - 1;
|
||||
}
|
||||
renderVersions(status.versions, status.feedback_list || []);
|
||||
postResult.innerHTML = '<p class="text-gray-400">Siehe Live-Vorschau oben...</p>';
|
||||
}
|
||||
|
||||
if (status.status === 'completed') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> Post generieren';
|
||||
|
||||
// Keep live versions visible but update header
|
||||
const result = status.result;
|
||||
|
||||
postResult.innerHTML = `
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2 text-sm flex-wrap">
|
||||
<span class="px-2 py-1 rounded ${result.approved ? 'bg-green-600/30 text-green-300' : 'bg-yellow-600/30 text-yellow-300'}">
|
||||
${result.approved ? 'Approved' : 'Review needed'}
|
||||
</span>
|
||||
<span class="text-gray-400">Score: ${result.final_score}/100</span>
|
||||
<span class="text-gray-400">Iterations: ${result.iterations}</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-400 mb-2">Finaler Post:</div>
|
||||
<div class="bg-brand-bg/50 rounded-lg p-4">
|
||||
<pre class="whitespace-pre-wrap text-gray-200 font-sans">${result.final_post}</pre>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="copyPost()" class="px-4 py-2 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-sm text-white transition-colors">
|
||||
In Zwischenablage kopieren
|
||||
</button>
|
||||
<button onclick="toggleVersions()" class="px-4 py-2 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-sm text-white transition-colors">
|
||||
Versionen ${liveVersions.classList.contains('hidden') ? 'anzeigen' : 'ausblenden'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (status.status === 'error') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> Post generieren';
|
||||
postResult.innerHTML = `<p class="text-red-400">Fehler: ${status.message}</p>`;
|
||||
}
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> Post generieren';
|
||||
postResult.innerHTML = `<p class="text-red-400">Fehler: ${error.message}</p>`;
|
||||
}
|
||||
});
|
||||
|
||||
function copyPost() {
|
||||
const postText = document.querySelector('#postResult pre').textContent;
|
||||
navigator.clipboard.writeText(postText).then(() => {
|
||||
alert('Post in Zwischenablage kopiert!');
|
||||
});
|
||||
}
|
||||
|
||||
function toggleVersions() {
|
||||
liveVersions.classList.toggle('hidden');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
97
src/web/templates/admin/dashboard.html
Normal file
97
src/web/templates/admin/dashboard.html
Normal file
@@ -0,0 +1,97 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Dashboard</h1>
|
||||
<p class="text-gray-400">Willkommen zum LinkedIn Post Creation System</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||
<strong>Error:</strong> {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div class="card-bg rounded-xl border p-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-400 text-sm">Kunden</p>
|
||||
<p class="text-2xl font-bold text-white">{{ customers_count or 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-bg rounded-xl border p-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-green-600/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-400 text-sm">Generierte Posts</p>
|
||||
<p class="text-2xl font-bold text-white">{{ total_posts or 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-bg rounded-xl border p-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-400 text-sm">AI Agents</p>
|
||||
<p class="text-2xl font-bold text-white">5</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="card-bg rounded-xl border p-6">
|
||||
<h2 class="text-xl font-semibold text-white mb-4">Schnellaktionen</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<a href="/admin/customers/new" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||
<div class="w-10 h-10 btn-primary rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-white">Neuer Kunde</p>
|
||||
<p class="text-sm text-gray-400">Setup starten</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/admin/research" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||
<div class="w-10 h-10 btn-primary rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-white">Research</p>
|
||||
<p class="text-sm text-gray-400">Topics finden</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/admin/create" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||
<div class="w-10 h-10 btn-primary rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-white">Post erstellen</p>
|
||||
<p class="text-sm text-gray-400">Content generieren</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/admin/posts" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||
<div class="w-10 h-10 btn-primary rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-white">Alle Posts</p>
|
||||
<p class="text-sm text-gray-400">Übersicht anzeigen</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
72
src/web/templates/admin/login.html
Normal file
72
src/web/templates/admin/login.html
Normal file
@@ -0,0 +1,72 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - LinkedIn Posts</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'brand': {
|
||||
'bg': '#3d4848',
|
||||
'bg-light': '#4a5858',
|
||||
'bg-dark': '#2d3838',
|
||||
'highlight': '#ffc700',
|
||||
'highlight-dark': '#e6b300',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body { background-color: #3d4848; }
|
||||
.btn-primary { background-color: #ffc700; color: #2d3838; }
|
||||
.btn-primary:hover { background-color: #e6b300; }
|
||||
.card-bg { background-color: #4a5858; border-color: #5a6868; }
|
||||
.input-bg { background-color: #3d4848; border-color: #5a6868; }
|
||||
.input-bg:focus { border-color: #ffc700; outline: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="text-gray-100 min-h-screen flex items-center justify-center">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="card-bg rounded-xl border p-8">
|
||||
<div class="text-center mb-8">
|
||||
<img src="/static/logo.png" alt="Logo" class="h-16 w-auto mx-auto mb-4">
|
||||
<h1 class="text-2xl font-bold text-white mb-2">LinkedIn Posts</h1>
|
||||
<p class="text-gray-400">Admin Panel</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||
Falsches Passwort. Bitte versuche es erneut.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="/admin/login" class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
autofocus
|
||||
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
|
||||
placeholder="Passwort eingeben..."
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full btn-primary font-medium py-3 rounded-lg transition-colors"
|
||||
>
|
||||
Anmelden
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
274
src/web/templates/admin/new_customer.html
Normal file
274
src/web/templates/admin/new_customer.html
Normal file
@@ -0,0 +1,274 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Neuer Kunde - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Neuer Kunde</h1>
|
||||
<p class="text-gray-400">Richte einen neuen Kunden ein und starte das initiale Setup</p>
|
||||
</div>
|
||||
|
||||
<div class="max-w-2xl">
|
||||
<form id="customerForm" class="card-bg rounded-xl border p-6 space-y-6">
|
||||
<!-- Basic Info -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Basis-Informationen</h3>
|
||||
<div class="grid gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Name *</label>
|
||||
<input type="text" name="name" required class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">LinkedIn URL *</label>
|
||||
<input type="url" name="linkedin_url" required placeholder="https://www.linkedin.com/in/username" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Firma</label>
|
||||
<input type="text" name="company_name" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">E-Mail</label>
|
||||
<input type="email" name="email" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Persona -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Persona & Stil</h3>
|
||||
<div class="grid gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Persona</label>
|
||||
<textarea name="persona" rows="3" placeholder="Beschreibe die Expertise, Positionierung und den Charakter der Person..." class="w-full input-bg border rounded-lg px-4 py-2 text-white"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Ansprache</label>
|
||||
<input type="text" name="form_of_address" placeholder="z.B. Duzen (Du/Euch) oder Siezen (Sie)" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Style Guide</label>
|
||||
<textarea name="style_guide" rows="3" placeholder="Beschreibe den Schreibstil, Tonalität und Richtlinien..." class="w-full input-bg border rounded-lg px-4 py-2 text-white"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Post Types -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-white">Post-Typen</h3>
|
||||
<button type="button" id="addPostTypeBtn" class="text-sm text-brand-highlight hover:underline">+ Post-Typ hinzufügen</button>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400 mb-4">Definiere verschiedene Arten von Posts (z.B. "Thought Leader", "Case Study", "How-To"). Diese werden zur Kategorisierung und typ-spezifischen Analyse verwendet.</p>
|
||||
|
||||
<div id="postTypesContainer" class="space-y-4">
|
||||
<!-- Post type entries will be added here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Area -->
|
||||
<div id="progressArea" class="hidden">
|
||||
<div class="bg-brand-bg rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span id="progressMessage" class="text-gray-300">Starte Setup...</span>
|
||||
<span id="progressPercent" class="text-gray-400">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-brand-bg-dark rounded-full h-2">
|
||||
<div id="progressBar" class="bg-brand-highlight h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result Area -->
|
||||
<div id="resultArea" class="hidden">
|
||||
<div id="successResult" class="hidden bg-green-900/30 border border-green-500 rounded-lg p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
<span class="text-green-300">Setup erfolgreich abgeschlossen!</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="errorResult" class="hidden bg-red-900/30 border border-red-500 rounded-lg p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
<span id="errorMessage" class="text-red-300">Fehler beim Setup</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="flex gap-4">
|
||||
<button type="submit" id="submitBtn" class="flex-1 btn-primary font-medium py-3 rounded-lg transition-colors">
|
||||
Setup starten
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const form = document.getElementById('customerForm');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const progressArea = document.getElementById('progressArea');
|
||||
const resultArea = document.getElementById('resultArea');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressMessage = document.getElementById('progressMessage');
|
||||
const progressPercent = document.getElementById('progressPercent');
|
||||
const postTypesContainer = document.getElementById('postTypesContainer');
|
||||
const addPostTypeBtn = document.getElementById('addPostTypeBtn');
|
||||
|
||||
let postTypeIndex = 0;
|
||||
|
||||
function createPostTypeEntry() {
|
||||
const index = postTypeIndex++;
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'bg-brand-bg rounded-lg p-4 border border-brand-bg-light';
|
||||
entry.id = `postType_${index}`;
|
||||
entry.innerHTML = `
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<span class="text-sm font-medium text-gray-300">Post-Typ ${index + 1}</span>
|
||||
<button type="button" onclick="removePostType(${index})" class="text-red-400 hover:text-red-300 text-sm">Entfernen</button>
|
||||
</div>
|
||||
<div class="grid gap-3">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Name *</label>
|
||||
<input type="text" data-pt-field="name" data-pt-index="${index}" required placeholder="z.B. Thought Leader" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Beschreibung</label>
|
||||
<input type="text" data-pt-field="description" data-pt-index="${index}" placeholder="Kurze Beschreibung" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Identifizierende Hashtags (kommagetrennt)</label>
|
||||
<input type="text" data-pt-field="hashtags" data-pt-index="${index}" placeholder="#ThoughtLeader, #Insight, #Leadership" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Keywords (kommagetrennt)</label>
|
||||
<input type="text" data-pt-field="keywords" data-pt-index="${index}" placeholder="Erfahrung, Learnings, Meinung" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
||||
</div>
|
||||
<details class="mt-2">
|
||||
<summary class="text-xs text-gray-400 cursor-pointer hover:text-gray-300">Erweiterte Eigenschaften</summary>
|
||||
<div class="mt-3 grid gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Zweck</label>
|
||||
<input type="text" data-pt-field="purpose" data-pt-index="${index}" placeholder="z.B. Expertise zeigen, Meinungsführerschaft etablieren" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Typische Tonalität</label>
|
||||
<input type="text" data-pt-field="tone" data-pt-index="${index}" placeholder="z.B. reflektiert, provokativ, inspirierend" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Zielgruppe</label>
|
||||
<input type="text" data-pt-field="target_audience" data-pt-index="${index}" placeholder="z.B. Führungskräfte, Entscheider" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
postTypesContainer.appendChild(entry);
|
||||
}
|
||||
|
||||
function removePostType(index) {
|
||||
const entry = document.getElementById(`postType_${index}`);
|
||||
if (entry) {
|
||||
entry.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function collectPostTypes() {
|
||||
const postTypes = [];
|
||||
const entries = postTypesContainer.querySelectorAll('[id^="postType_"]');
|
||||
|
||||
entries.forEach(entry => {
|
||||
const index = entry.id.split('_')[1];
|
||||
const name = entry.querySelector(`[data-pt-field="name"][data-pt-index="${index}"]`)?.value?.trim();
|
||||
|
||||
if (name) {
|
||||
const hashtagsRaw = entry.querySelector(`[data-pt-field="hashtags"][data-pt-index="${index}"]`)?.value || '';
|
||||
const keywordsRaw = entry.querySelector(`[data-pt-field="keywords"][data-pt-index="${index}"]`)?.value || '';
|
||||
|
||||
postTypes.push({
|
||||
name: name,
|
||||
description: entry.querySelector(`[data-pt-field="description"][data-pt-index="${index}"]`)?.value?.trim() || null,
|
||||
identifying_hashtags: hashtagsRaw.split(',').map(h => h.trim()).filter(h => h),
|
||||
identifying_keywords: keywordsRaw.split(',').map(k => k.trim()).filter(k => k),
|
||||
semantic_properties: {
|
||||
purpose: entry.querySelector(`[data-pt-field="purpose"][data-pt-index="${index}"]`)?.value?.trim() || null,
|
||||
typical_tone: entry.querySelector(`[data-pt-field="tone"][data-pt-index="${index}"]`)?.value?.trim() || null,
|
||||
target_audience: entry.querySelector(`[data-pt-field="target_audience"][data-pt-index="${index}"]`)?.value?.trim() || null
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return postTypes;
|
||||
}
|
||||
|
||||
addPostTypeBtn.addEventListener('click', createPostTypeEntry);
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Wird gestartet...';
|
||||
progressArea.classList.remove('hidden');
|
||||
resultArea.classList.add('hidden');
|
||||
|
||||
const formData = new FormData(form);
|
||||
|
||||
// Add post types as JSON
|
||||
const postTypes = collectPostTypes();
|
||||
if (postTypes.length > 0) {
|
||||
formData.append('post_types_json', JSON.stringify(postTypes));
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/api/customers', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
// Poll for progress
|
||||
const taskId = data.task_id;
|
||||
const pollInterval = setInterval(async () => {
|
||||
const statusResponse = await fetch(`/admin/api/tasks/${taskId}`);
|
||||
const status = await statusResponse.json();
|
||||
|
||||
progressBar.style.width = `${status.progress}%`;
|
||||
progressPercent.textContent = `${status.progress}%`;
|
||||
progressMessage.textContent = status.message;
|
||||
|
||||
if (status.status === 'completed') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
resultArea.classList.remove('hidden');
|
||||
document.getElementById('successResult').classList.remove('hidden');
|
||||
submitBtn.textContent = 'Setup starten';
|
||||
submitBtn.disabled = false;
|
||||
form.reset();
|
||||
postTypesContainer.innerHTML = '';
|
||||
postTypeIndex = 0;
|
||||
} else if (status.status === 'error') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
resultArea.classList.remove('hidden');
|
||||
document.getElementById('errorResult').classList.remove('hidden');
|
||||
document.getElementById('errorMessage').textContent = status.message;
|
||||
submitBtn.textContent = 'Setup starten';
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
progressArea.classList.add('hidden');
|
||||
resultArea.classList.remove('hidden');
|
||||
document.getElementById('errorResult').classList.remove('hidden');
|
||||
document.getElementById('errorMessage').textContent = error.message;
|
||||
submitBtn.textContent = 'Setup starten';
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
1481
src/web/templates/admin/post_detail.html
Normal file
1481
src/web/templates/admin/post_detail.html
Normal file
File diff suppressed because it is too large
Load Diff
152
src/web/templates/admin/posts.html
Normal file
152
src/web/templates/admin/posts.html
Normal file
@@ -0,0 +1,152 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Alle Posts - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.post-card {
|
||||
background: linear-gradient(135deg, rgba(61, 72, 72, 0.3) 0%, rgba(45, 56, 56, 0.4) 100%);
|
||||
border: 1px solid rgba(61, 72, 72, 0.6);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.post-card:hover {
|
||||
border-color: rgba(255, 199, 0, 0.3);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.customer-header {
|
||||
background: linear-gradient(90deg, rgba(255, 199, 0, 0.1) 0%, transparent 100%);
|
||||
}
|
||||
.score-ring {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.score-high { background: rgba(34, 197, 94, 0.2); border: 2px solid rgba(34, 197, 94, 0.5); color: #86efac; }
|
||||
.score-medium { background: rgba(234, 179, 8, 0.2); border: 2px solid rgba(234, 179, 8, 0.5); color: #fde047; }
|
||||
.score-low { background: rgba(239, 68, 68, 0.2); border: 2px solid rgba(239, 68, 68, 0.5); color: #fca5a5; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Alle Posts</h1>
|
||||
<p class="text-gray-400">{{ total_posts }} generierte Posts</p>
|
||||
</div>
|
||||
<a href="/admin/create" class="px-4 py-2.5 btn-primary rounded-lg font-medium flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||
Neuer Post
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||
<strong>Error:</strong> {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if customers_with_posts %}
|
||||
<div class="space-y-8">
|
||||
{% for item in customers_with_posts %}
|
||||
{% if item.posts %}
|
||||
<div class="card-bg rounded-xl border overflow-hidden">
|
||||
<!-- Customer Header -->
|
||||
<div class="customer-header px-6 py-4 border-b border-brand-bg-light">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-xl flex items-center justify-center shadow-lg overflow-hidden {{ 'bg-brand-highlight' if not item.profile_picture else '' }}">
|
||||
{% if item.profile_picture %}
|
||||
<img src="{{ item.profile_picture }}" alt="{{ item.customer.name }}" class="w-full h-full object-cover" loading="lazy" referrerpolicy="no-referrer">
|
||||
{% else %}
|
||||
<span class="text-brand-bg-dark font-bold text-lg">{{ item.customer.name[0] | upper }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-white text-lg">{{ item.customer.name }}</h3>
|
||||
<p class="text-sm text-gray-400">{{ item.customer.company_name or 'Kein Unternehmen' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="px-4 py-1.5 bg-brand-bg rounded-full text-sm text-gray-300 font-medium">
|
||||
{{ item.post_count }} Post{{ 's' if item.post_count != 1 else '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Posts Grid -->
|
||||
<div class="p-4">
|
||||
<div class="grid gap-3">
|
||||
{% for post in item.posts %}
|
||||
<a href="/admin/posts/{{ post.id }}" class="post-card rounded-xl p-4 block group">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Score Circle -->
|
||||
{% if post.critic_feedback and post.critic_feedback | length > 0 %}
|
||||
{% set score = post.critic_feedback[-1].overall_score %}
|
||||
<div class="score-ring flex-shrink-0 {{ 'score-high' if score >= 85 else 'score-medium' if score >= 70 else 'score-low' }}">
|
||||
{{ score }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="score-ring flex-shrink-0 bg-brand-bg-dark border-2 border-brand-bg-light text-gray-500">
|
||||
—
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<h4 class="font-medium text-white group-hover:text-brand-highlight transition-colors truncate">
|
||||
{{ post.topic_title or 'Untitled' }}
|
||||
</h4>
|
||||
<span class="flex-shrink-0 px-2 py-0.5 text-xs rounded font-medium {{ 'bg-green-600/20 text-green-400 border border-green-600/30' if post.status == 'approved' else 'bg-yellow-600/20 text-yellow-400 border border-yellow-600/30' }}">
|
||||
{{ post.status | capitalize }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 mt-1.5 text-sm text-gray-500">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||
{{ post.created_at.strftime('%d.%m.%Y') if post.created_at else 'N/A' }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{{ post.created_at.strftime('%H:%M') if post.created_at else '' }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
{{ post.iterations }} Iteration{{ 's' if post.iterations != 1 else '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow -->
|
||||
<svg class="w-5 h-5 text-gray-600 group-hover:text-brand-highlight transition-colors flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-bg rounded-xl border p-12 text-center">
|
||||
<div class="w-20 h-20 bg-brand-bg rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<svg class="w-10 h-10 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Noch keine Posts</h3>
|
||||
<p class="text-gray-400 mb-6 max-w-md mx-auto">Erstelle deinen ersten LinkedIn Post mit KI-Unterstützung.</p>
|
||||
<a href="/admin/create" class="inline-flex items-center gap-2 px-6 py-3 btn-primary font-medium rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||
Post erstellen
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
215
src/web/templates/admin/research.html
Normal file
215
src/web/templates/admin/research.html
Normal file
@@ -0,0 +1,215 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Research Topics - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Research Topics</h1>
|
||||
<p class="text-gray-400">Recherchiere neue Content-Themen mit Perplexity AI</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<!-- Left: Form -->
|
||||
<div>
|
||||
<form id="researchForm" class="card-bg rounded-xl border p-6">
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Kunde auswählen</label>
|
||||
<select name="customer_id" id="customerSelect" required class="w-full input-bg border rounded-lg px-4 py-3 text-white">
|
||||
<option value="">-- Kunde wählen --</option>
|
||||
{% for customer in customers %}
|
||||
<option value="{{ customer.id }}">{{ customer.name }} - {{ customer.company_name or 'Kein Unternehmen' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Post Type Selection -->
|
||||
<div id="postTypeArea" class="mb-6 hidden">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Post-Typ (optional)</label>
|
||||
<div id="postTypeCards" class="grid grid-cols-2 gap-2 mb-2">
|
||||
<!-- Post type cards will be loaded here -->
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">Wähle einen Post-Typ für gezielte Recherche oder lasse leer für allgemeine Recherche.</p>
|
||||
<input type="hidden" name="post_type_id" id="selectedPostTypeId" value="">
|
||||
</div>
|
||||
|
||||
<!-- Progress Area -->
|
||||
<div id="progressArea" class="hidden mb-6">
|
||||
<div class="bg-brand-bg rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span id="progressMessage" class="text-gray-300">Starte Recherche...</span>
|
||||
<span id="progressPercent" class="text-gray-400">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-brand-bg-dark rounded-full h-2">
|
||||
<div id="progressBar" class="bg-brand-highlight h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="submitBtn" class="w-full btn-primary font-medium py-3 rounded-lg transition-colors flex items-center justify-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
Research starten
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% if not customers %}
|
||||
<div class="mt-4 bg-yellow-900/30 border border-yellow-600 rounded-lg p-4">
|
||||
<p class="text-yellow-300">Noch keine Kunden vorhanden. <a href="/admin/customers/new" class="underline">Erstelle zuerst einen Kunden</a>.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Right: Results -->
|
||||
<div>
|
||||
<div id="resultsArea" class="card-bg rounded-xl border p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Gefundene Topics</h3>
|
||||
<div id="topicsList" class="space-y-4">
|
||||
<p class="text-gray-400">Starte eine Recherche um Topics zu finden...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const form = document.getElementById('researchForm');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const progressArea = document.getElementById('progressArea');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressMessage = document.getElementById('progressMessage');
|
||||
const progressPercent = document.getElementById('progressPercent');
|
||||
const topicsList = document.getElementById('topicsList');
|
||||
const customerSelect = document.getElementById('customerSelect');
|
||||
const postTypeArea = document.getElementById('postTypeArea');
|
||||
const postTypeCards = document.getElementById('postTypeCards');
|
||||
const selectedPostTypeId = document.getElementById('selectedPostTypeId');
|
||||
|
||||
let currentPostTypes = [];
|
||||
|
||||
// Load post types when customer is selected
|
||||
customerSelect.addEventListener('change', async () => {
|
||||
const customerId = customerSelect.value;
|
||||
selectedPostTypeId.value = '';
|
||||
|
||||
if (!customerId) {
|
||||
postTypeArea.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/api/customers/${customerId}/post-types`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.post_types && data.post_types.length > 0) {
|
||||
currentPostTypes = data.post_types;
|
||||
postTypeArea.classList.remove('hidden');
|
||||
|
||||
postTypeCards.innerHTML = `
|
||||
<button type="button" onclick="selectPostType('')" id="pt_all"
|
||||
class="p-3 rounded-lg border text-left transition-colors bg-brand-highlight/20 border-brand-highlight text-white">
|
||||
<div class="font-medium text-sm">Alle Typen</div>
|
||||
<div class="text-xs text-gray-400 mt-1">Allgemeine Recherche</div>
|
||||
</button>
|
||||
` + data.post_types.map(pt => `
|
||||
<button type="button" onclick="selectPostType('${pt.id}')" id="pt_${pt.id}"
|
||||
class="p-3 rounded-lg border text-left transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white">
|
||||
<div class="font-medium text-sm">${pt.name}</div>
|
||||
<div class="text-xs text-gray-400 mt-1">${pt.analyzed_post_count || 0} Posts analysiert</div>
|
||||
${pt.has_analysis ? '<span class="text-xs text-green-400">Analyse</span>' : ''}
|
||||
</button>
|
||||
`).join('');
|
||||
} else {
|
||||
postTypeArea.classList.add('hidden');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load post types:', error);
|
||||
postTypeArea.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
function selectPostType(typeId) {
|
||||
selectedPostTypeId.value = typeId;
|
||||
|
||||
// Update card styles
|
||||
document.querySelectorAll('[id^="pt_"]').forEach(card => {
|
||||
if (card.id === `pt_${typeId}` || (typeId === '' && card.id === 'pt_all')) {
|
||||
card.className = 'p-3 rounded-lg border text-left transition-colors bg-brand-highlight/20 border-brand-highlight text-white';
|
||||
} else {
|
||||
card.className = 'p-3 rounded-lg border text-left transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const customerId = customerSelect.value;
|
||||
if (!customerId) return;
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg> Recherchiert...';
|
||||
progressArea.classList.remove('hidden');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('customer_id', customerId);
|
||||
if (selectedPostTypeId.value) {
|
||||
formData.append('post_type_id', selectedPostTypeId.value);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/api/research', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
const taskId = data.task_id;
|
||||
const pollInterval = setInterval(async () => {
|
||||
const statusResponse = await fetch(`/admin/api/tasks/${taskId}`);
|
||||
const status = await statusResponse.json();
|
||||
|
||||
progressBar.style.width = `${status.progress}%`;
|
||||
progressPercent.textContent = `${status.progress}%`;
|
||||
progressMessage.textContent = status.message;
|
||||
|
||||
if (status.status === 'completed') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> Research starten';
|
||||
|
||||
// Display topics
|
||||
if (status.topics && status.topics.length > 0) {
|
||||
topicsList.innerHTML = status.topics.map((topic, i) => `
|
||||
<div class="bg-brand-bg rounded-lg p-4 border border-brand-bg-light">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<span class="inline-block px-2 py-1 text-xs font-medium bg-brand-highlight/20 text-brand-highlight rounded mb-2">${topic.category || 'Topic'}</span>
|
||||
<h4 class="font-semibold text-white">${topic.title}</h4>
|
||||
${topic.angle ? `<p class="text-sm text-brand-highlight/80 mt-1">↳ ${topic.angle}</p>` : ''}
|
||||
${topic.hook_idea ? `<p class="text-sm text-gray-300 mt-2 italic border-l-2 border-brand-highlight/30 pl-2">"${topic.hook_idea.substring(0, 150)}..."</p>` : ''}
|
||||
<p class="text-gray-400 text-sm mt-2">${topic.fact ? topic.fact.substring(0, 200) + '...' : ''}</p>
|
||||
${topic.source ? `<p class="text-gray-500 text-xs mt-2">Quelle: ${topic.source}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
topicsList.innerHTML = '<p class="text-gray-400">Keine Topics gefunden.</p>';
|
||||
}
|
||||
} else if (status.status === 'error') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> Research starten';
|
||||
topicsList.innerHTML = `<p class="text-red-400">Fehler: ${status.message}</p>`;
|
||||
}
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> Research starten';
|
||||
topicsList.innerHTML = `<p class="text-red-400">Fehler: ${error.message}</p>`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
571
src/web/templates/admin/scraped_posts.html
Normal file
571
src/web/templates/admin/scraped_posts.html
Normal file
@@ -0,0 +1,571 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Gescrapte Posts - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.post-card {
|
||||
background: linear-gradient(135deg, rgba(61, 72, 72, 0.3) 0%, rgba(45, 56, 56, 0.4) 100%);
|
||||
border: 1px solid rgba(61, 72, 72, 0.6);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.post-card:hover {
|
||||
border-color: rgba(255, 199, 0, 0.3);
|
||||
}
|
||||
.post-card.selected {
|
||||
border-color: rgba(255, 199, 0, 0.6);
|
||||
background: linear-gradient(135deg, rgba(255, 199, 0, 0.05) 0%, rgba(45, 56, 56, 0.4) 100%);
|
||||
}
|
||||
.type-badge {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.type-badge:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
.type-badge.active {
|
||||
background-color: rgba(255, 199, 0, 0.2);
|
||||
border-color: #ffc700;
|
||||
}
|
||||
.post-content-preview {
|
||||
max-height: 150px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.post-content-preview::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 40px;
|
||||
background: linear-gradient(transparent, rgba(45, 56, 56, 0.9));
|
||||
}
|
||||
.post-content-expanded {
|
||||
max-height: none;
|
||||
}
|
||||
.post-content-expanded::after {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Gescrapte Posts verwalten</h1>
|
||||
<p class="text-gray-400">Posts manuell kategorisieren und Post-Typ-Analyse triggern</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Customer Selection -->
|
||||
<div class="card-bg rounded-xl border p-6 mb-6">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<div class="flex-1 min-w-64">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Kunde auswählen</label>
|
||||
<select id="customerSelect" class="w-full input-bg border rounded-lg px-4 py-3 text-white">
|
||||
<option value="">-- Kunde wählen --</option>
|
||||
{% for customer in customers %}
|
||||
<option value="{{ customer.id }}">{{ customer.name }} - {{ customer.company_name or 'Kein Unternehmen' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button id="classifyAllBtn" class="hidden px-4 py-3 bg-brand-bg hover:bg-brand-bg-light border border-brand-bg-light rounded-lg text-white transition-colors">
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
Auto-Klassifizieren
|
||||
</span>
|
||||
</button>
|
||||
<button id="analyzeTypesBtn" class="hidden px-4 py-3 btn-primary rounded-lg font-medium transition-colors">
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
|
||||
Post-Typen analysieren
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Area -->
|
||||
<div id="progressArea" class="hidden card-bg rounded-xl border p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span id="progressMessage" class="text-gray-300">Arbeite...</span>
|
||||
<span id="progressPercent" class="text-gray-400">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-brand-bg-dark rounded-full h-2">
|
||||
<div id="progressBar" class="bg-brand-highlight h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats & Post Types -->
|
||||
<div id="statsArea" class="hidden mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
||||
<div class="card-bg rounded-xl border p-4">
|
||||
<div class="text-2xl font-bold text-white" id="totalPostsCount">0</div>
|
||||
<div class="text-sm text-gray-400">Gesamt Posts</div>
|
||||
</div>
|
||||
<div class="card-bg rounded-xl border p-4">
|
||||
<div class="text-2xl font-bold text-green-400" id="classifiedCount">0</div>
|
||||
<div class="text-sm text-gray-400">Klassifiziert</div>
|
||||
</div>
|
||||
<div class="card-bg rounded-xl border p-4">
|
||||
<div class="text-2xl font-bold text-yellow-400" id="unclassifiedCount">0</div>
|
||||
<div class="text-sm text-gray-400">Nicht klassifiziert</div>
|
||||
</div>
|
||||
<div class="card-bg rounded-xl border p-4">
|
||||
<div class="text-2xl font-bold text-brand-highlight" id="postTypesCount">0</div>
|
||||
<div class="text-sm text-gray-400">Post-Typen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Post Type Filter -->
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<button onclick="filterByType(null)" id="filter_all" class="type-badge px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-highlight/20 border-brand-highlight text-white">
|
||||
Alle
|
||||
</button>
|
||||
<button onclick="filterByType('unclassified')" id="filter_unclassified" class="type-badge px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white">
|
||||
Nicht klassifiziert
|
||||
</button>
|
||||
<div id="postTypeFilters" class="contents">
|
||||
<!-- Post type filter buttons will be added here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Posts List -->
|
||||
<div id="postsArea" class="hidden">
|
||||
<div id="postsList" class="space-y-4">
|
||||
<p class="text-gray-400">Lade Posts...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div id="emptyState" class="hidden card-bg rounded-xl border p-12 text-center">
|
||||
<div class="w-20 h-20 bg-brand-bg rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<svg class="w-10 h-10 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Keine gescrapten Posts</h3>
|
||||
<p class="text-gray-400 mb-6 max-w-md mx-auto">Für diesen Kunden wurden noch keine LinkedIn Posts gescrapet.</p>
|
||||
</div>
|
||||
|
||||
{% if not customers %}
|
||||
<div class="bg-yellow-900/30 border border-yellow-600 rounded-lg p-4">
|
||||
<p class="text-yellow-300">Noch keine Kunden vorhanden. <a href="/admin/customers/new" class="underline">Erstelle zuerst einen Kunden</a>.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Post Detail Modal -->
|
||||
<div id="postModal" class="fixed inset-0 bg-black/70 hidden items-center justify-center z-50 p-4">
|
||||
<div class="bg-brand-bg-dark rounded-xl border border-brand-bg-light max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col shadow-2xl">
|
||||
<div class="p-4 border-b border-brand-bg-light flex items-center justify-between bg-brand-bg">
|
||||
<h3 class="text-lg font-semibold text-white">Post Details</h3>
|
||||
<button onclick="closeModal()" class="text-gray-400 hover:text-white p-1 hover:bg-brand-bg-light rounded">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6 overflow-y-auto flex-1">
|
||||
<div id="modalContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const customerSelect = document.getElementById('customerSelect');
|
||||
const classifyAllBtn = document.getElementById('classifyAllBtn');
|
||||
const analyzeTypesBtn = document.getElementById('analyzeTypesBtn');
|
||||
const progressArea = document.getElementById('progressArea');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressMessage = document.getElementById('progressMessage');
|
||||
const progressPercent = document.getElementById('progressPercent');
|
||||
const statsArea = document.getElementById('statsArea');
|
||||
const postsArea = document.getElementById('postsArea');
|
||||
const postsList = document.getElementById('postsList');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
const postTypeFilters = document.getElementById('postTypeFilters');
|
||||
const postModal = document.getElementById('postModal');
|
||||
const modalContent = document.getElementById('modalContent');
|
||||
|
||||
let currentPosts = [];
|
||||
let currentPostTypes = [];
|
||||
let currentFilter = null;
|
||||
|
||||
customerSelect.addEventListener('change', async () => {
|
||||
const customerId = customerSelect.value;
|
||||
|
||||
if (!customerId) {
|
||||
statsArea.classList.add('hidden');
|
||||
postsArea.classList.add('hidden');
|
||||
emptyState.classList.add('hidden');
|
||||
classifyAllBtn.classList.add('hidden');
|
||||
analyzeTypesBtn.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
await loadCustomerData(customerId);
|
||||
});
|
||||
|
||||
async function loadCustomerData(customerId) {
|
||||
// Load post types
|
||||
try {
|
||||
const ptResponse = await fetch(`/admin/api/customers/${customerId}/post-types`);
|
||||
const ptData = await ptResponse.json();
|
||||
currentPostTypes = ptData.post_types || [];
|
||||
|
||||
// Update post type filters
|
||||
postTypeFilters.innerHTML = currentPostTypes.map(pt => `
|
||||
<button onclick="filterByType('${pt.id}')" id="filter_${pt.id}"
|
||||
class="type-badge px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white">
|
||||
${escapeHtml(pt.name)}
|
||||
<span class="ml-1 text-xs text-gray-400">(${pt.analyzed_post_count || 0})</span>
|
||||
${pt.has_analysis ? '<span class="ml-1 text-green-400">*</span>' : ''}
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
document.getElementById('postTypesCount').textContent = currentPostTypes.length;
|
||||
} catch (error) {
|
||||
console.error('Failed to load post types:', error);
|
||||
}
|
||||
|
||||
// Load posts
|
||||
try {
|
||||
const response = await fetch(`/admin/api/customers/${customerId}/linkedin-posts`);
|
||||
const data = await response.json();
|
||||
|
||||
console.log('API Response:', data);
|
||||
|
||||
if (data.error) {
|
||||
console.error('API Error:', data.error);
|
||||
postsList.innerHTML = `<p class="text-red-400">API Fehler: ${escapeHtml(data.error)}</p>`;
|
||||
postsArea.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
currentPosts = data.posts || [];
|
||||
console.log(`Loaded ${currentPosts.length} posts`);
|
||||
|
||||
if (currentPosts.length === 0) {
|
||||
statsArea.classList.add('hidden');
|
||||
postsArea.classList.add('hidden');
|
||||
emptyState.classList.remove('hidden');
|
||||
classifyAllBtn.classList.add('hidden');
|
||||
analyzeTypesBtn.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update stats
|
||||
const classified = currentPosts.filter(p => p.post_type_id).length;
|
||||
const unclassified = currentPosts.length - classified;
|
||||
|
||||
document.getElementById('totalPostsCount').textContent = currentPosts.length;
|
||||
document.getElementById('classifiedCount').textContent = classified;
|
||||
document.getElementById('unclassifiedCount').textContent = unclassified;
|
||||
|
||||
statsArea.classList.remove('hidden');
|
||||
postsArea.classList.remove('hidden');
|
||||
emptyState.classList.add('hidden');
|
||||
classifyAllBtn.classList.remove('hidden');
|
||||
analyzeTypesBtn.classList.remove('hidden');
|
||||
|
||||
currentFilter = null;
|
||||
filterByType(null);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load posts:', error);
|
||||
postsList.innerHTML = `<p class="text-red-400">Fehler beim Laden: ${error.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function filterByType(typeId) {
|
||||
currentFilter = typeId;
|
||||
|
||||
// Update filter button styles
|
||||
document.querySelectorAll('.type-badge').forEach(btn => {
|
||||
const btnId = btn.id.replace('filter_', '');
|
||||
const isActive = (typeId === null && btnId === 'all') ||
|
||||
(typeId === 'unclassified' && btnId === 'unclassified') ||
|
||||
(btnId === typeId);
|
||||
|
||||
if (isActive) {
|
||||
btn.classList.add('active', 'bg-brand-highlight/20', 'border-brand-highlight');
|
||||
btn.classList.remove('bg-brand-bg', 'border-brand-bg-light');
|
||||
} else {
|
||||
btn.classList.remove('active', 'bg-brand-highlight/20', 'border-brand-highlight');
|
||||
btn.classList.add('bg-brand-bg', 'border-brand-bg-light');
|
||||
}
|
||||
});
|
||||
|
||||
// Filter posts
|
||||
let filteredPosts = currentPosts;
|
||||
if (typeId === 'unclassified') {
|
||||
filteredPosts = currentPosts.filter(p => !p.post_type_id);
|
||||
} else if (typeId) {
|
||||
filteredPosts = currentPosts.filter(p => p.post_type_id === typeId);
|
||||
}
|
||||
|
||||
renderPosts(filteredPosts);
|
||||
}
|
||||
|
||||
function renderPosts(posts) {
|
||||
if (posts.length === 0) {
|
||||
postsList.innerHTML = '<p class="text-gray-400 text-center py-8">Keine Posts in dieser Kategorie.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
postsList.innerHTML = posts.map((post, index) => {
|
||||
const postType = currentPostTypes.find(pt => pt.id === post.post_type_id);
|
||||
const postText = post.post_text || '';
|
||||
const previewText = postText.substring(0, 300);
|
||||
|
||||
return `
|
||||
<div class="post-card rounded-xl p-4 cursor-pointer" data-post-id="${post.id}" onclick="openPostModal('${post.id}')">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-2 mb-3 flex-wrap">
|
||||
${postType ? `
|
||||
<span class="px-2 py-1 text-xs font-medium bg-brand-highlight/20 text-brand-highlight rounded">
|
||||
${escapeHtml(postType.name)}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">${post.classification_method || 'unknown'} (${Math.round((post.classification_confidence || 0) * 100)}%)</span>
|
||||
` : `
|
||||
<span class="px-2 py-1 text-xs font-medium bg-gray-600/30 text-gray-400 rounded">
|
||||
Nicht klassifiziert
|
||||
</span>
|
||||
`}
|
||||
${post.posted_at ? `
|
||||
<span class="text-xs text-gray-500">${new Date(post.posted_at).toLocaleDateString('de-DE')}</span>
|
||||
` : ''}
|
||||
${post.engagement_score ? `
|
||||
<span class="text-xs text-gray-500">Engagement: ${post.engagement_score}</span>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Content Preview -->
|
||||
<div class="post-content-preview text-gray-300 text-sm whitespace-pre-wrap mb-3">
|
||||
${escapeHtml(previewText)}${postText.length > 300 ? '...' : ''}
|
||||
</div>
|
||||
|
||||
<!-- Click hint & Type badges -->
|
||||
<div class="flex items-center justify-between flex-wrap gap-2" onclick="event.stopPropagation()">
|
||||
<span class="text-xs text-gray-500 italic">Klicken für Vollansicht</span>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
${currentPostTypes.map(pt => `
|
||||
<button onclick="event.stopPropagation(); classifyPost('${post.id}', '${pt.id}')"
|
||||
class="px-2 py-1 text-xs rounded transition-colors ${post.post_type_id === pt.id ? 'bg-brand-highlight/30 text-brand-highlight border border-brand-highlight' : 'bg-brand-bg hover:bg-brand-bg-light text-gray-300 border border-brand-bg-light'}">
|
||||
${escapeHtml(pt.name)}
|
||||
</button>
|
||||
`).join('')}
|
||||
${post.post_type_id ? `
|
||||
<button onclick="event.stopPropagation(); classifyPost('${post.id}', null)" class="px-2 py-1 text-xs rounded bg-red-900/30 hover:bg-red-900/50 text-red-300 border border-red-900/50">
|
||||
✕
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function classifyPost(postId, postTypeId) {
|
||||
const customerId = customerSelect.value;
|
||||
if (!customerId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/api/linkedin-posts/${postId}/classify`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ post_type_id: postTypeId })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to classify post');
|
||||
}
|
||||
|
||||
// Update local data
|
||||
const post = currentPosts.find(p => p.id === postId);
|
||||
if (post) {
|
||||
post.post_type_id = postTypeId;
|
||||
post.classification_method = 'manual';
|
||||
post.classification_confidence = 1.0;
|
||||
}
|
||||
|
||||
// Update stats
|
||||
const classified = currentPosts.filter(p => p.post_type_id).length;
|
||||
document.getElementById('classifiedCount').textContent = classified;
|
||||
document.getElementById('unclassifiedCount').textContent = currentPosts.length - classified;
|
||||
|
||||
// Re-render
|
||||
filterByType(currentFilter);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to classify post:', error);
|
||||
alert('Fehler beim Klassifizieren: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function openPostModal(postId) {
|
||||
const post = currentPosts.find(p => p.id === postId);
|
||||
if (!post) return;
|
||||
|
||||
const postType = currentPostTypes.find(pt => pt.id === post.post_type_id);
|
||||
|
||||
modalContent.innerHTML = `
|
||||
<div class="mb-4 flex items-center gap-3 flex-wrap">
|
||||
${postType ? `
|
||||
<span class="px-3 py-1.5 text-sm font-medium bg-brand-highlight/20 text-brand-highlight rounded-lg">
|
||||
${escapeHtml(postType.name)}
|
||||
</span>
|
||||
` : `
|
||||
<span class="px-3 py-1.5 text-sm font-medium bg-gray-600/30 text-gray-400 rounded-lg">
|
||||
Nicht klassifiziert
|
||||
</span>
|
||||
`}
|
||||
${post.posted_at ? `
|
||||
<span class="text-sm text-gray-500">${new Date(post.posted_at).toLocaleDateString('de-DE')}</span>
|
||||
` : ''}
|
||||
${post.engagement_score ? `
|
||||
<span class="text-sm text-gray-500">Engagement: ${post.engagement_score}</span>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="bg-brand-bg rounded-xl p-6 mb-6 border border-brand-bg-light max-h-[50vh] overflow-y-auto">
|
||||
<div class="whitespace-pre-wrap text-gray-200 font-sans text-base leading-relaxed">${escapeHtml(post.post_text || '')}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 flex-wrap border-t border-brand-bg-light pt-4">
|
||||
<span class="text-sm text-gray-400 font-medium">Typ zuweisen:</span>
|
||||
${currentPostTypes.map(pt => `
|
||||
<button onclick="classifyPost('${post.id}', '${pt.id}'); closeModal();"
|
||||
class="px-4 py-2 text-sm rounded-lg transition-colors ${post.post_type_id === pt.id ? 'bg-brand-highlight text-brand-bg-dark font-medium' : 'bg-brand-bg hover:bg-brand-bg-light text-gray-300 border border-brand-bg-light'}">
|
||||
${escapeHtml(pt.name)}
|
||||
</button>
|
||||
`).join('')}
|
||||
${post.post_type_id ? `
|
||||
<button onclick="classifyPost('${post.id}', null); closeModal();" class="px-4 py-2 text-sm rounded-lg bg-red-900/30 hover:bg-red-900/50 text-red-300 border border-red-900/50">
|
||||
Klassifizierung entfernen
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
postModal.classList.remove('hidden');
|
||||
postModal.classList.add('flex');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
postModal.classList.add('hidden');
|
||||
postModal.classList.remove('flex');
|
||||
}
|
||||
|
||||
// Close modal on backdrop click
|
||||
postModal.addEventListener('click', (e) => {
|
||||
if (e.target === postModal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal on Escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !postModal.classList.contains('hidden')) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-classify button
|
||||
classifyAllBtn.addEventListener('click', async () => {
|
||||
const customerId = customerSelect.value;
|
||||
if (!customerId) return;
|
||||
|
||||
classifyAllBtn.disabled = true;
|
||||
progressArea.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/api/customers/${customerId}/classify-posts`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
const taskId = data.task_id;
|
||||
await pollTask(taskId, async () => {
|
||||
await loadCustomerData(customerId);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Classification failed:', error);
|
||||
alert('Fehler bei der Klassifizierung: ' + error.message);
|
||||
} finally {
|
||||
classifyAllBtn.disabled = false;
|
||||
progressArea.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Analyze post types button
|
||||
analyzeTypesBtn.addEventListener('click', async () => {
|
||||
const customerId = customerSelect.value;
|
||||
if (!customerId) return;
|
||||
|
||||
analyzeTypesBtn.disabled = true;
|
||||
progressArea.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/api/customers/${customerId}/analyze-post-types`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
const taskId = data.task_id;
|
||||
await pollTask(taskId, async () => {
|
||||
await loadCustomerData(customerId);
|
||||
alert('Post-Typ-Analyse abgeschlossen! Die Analysen wurden aktualisiert.');
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Analysis failed:', error);
|
||||
alert('Fehler bei der Analyse: ' + error.message);
|
||||
} finally {
|
||||
analyzeTypesBtn.disabled = false;
|
||||
progressArea.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
async function pollTask(taskId, onComplete) {
|
||||
return new Promise((resolve) => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const statusResponse = await fetch(`/admin/api/tasks/${taskId}`);
|
||||
const status = await statusResponse.json();
|
||||
|
||||
progressBar.style.width = `${status.progress}%`;
|
||||
progressPercent.textContent = `${status.progress}%`;
|
||||
progressMessage.textContent = status.message;
|
||||
|
||||
if (status.status === 'completed') {
|
||||
clearInterval(interval);
|
||||
await onComplete();
|
||||
resolve();
|
||||
} else if (status.status === 'error') {
|
||||
clearInterval(interval);
|
||||
alert('Fehler: ' + status.message);
|
||||
resolve();
|
||||
}
|
||||
} catch (error) {
|
||||
clearInterval(interval);
|
||||
console.error('Polling error:', error);
|
||||
resolve();
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
159
src/web/templates/admin/status.html
Normal file
159
src/web/templates/admin/status.html
Normal file
@@ -0,0 +1,159 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Status - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Status</h1>
|
||||
<p class="text-gray-400">Übersicht über alle Kunden und deren Setup-Status</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||
<strong>Error:</strong> {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if customer_statuses %}
|
||||
<div class="grid gap-6">
|
||||
{% for item in customer_statuses %}
|
||||
<div class="card-bg rounded-xl border overflow-hidden">
|
||||
<!-- Customer Header -->
|
||||
<div class="px-6 py-4 border-b border-brand-bg-light">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-full flex items-center justify-center overflow-hidden {{ 'bg-brand-highlight' if not item.profile_picture else '' }}">
|
||||
{% if item.profile_picture %}
|
||||
<img src="{{ item.profile_picture }}" alt="{{ item.customer.name }}" class="w-full h-full object-cover" loading="lazy" referrerpolicy="no-referrer">
|
||||
{% else %}
|
||||
<span class="text-brand-bg-dark font-bold text-lg">{{ item.customer.name[0] | upper }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-white text-lg">{{ item.customer.name }}</h3>
|
||||
<p class="text-sm text-gray-400">{{ item.customer.company_name or 'Kein Unternehmen' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{% if item.status.ready_for_posts %}
|
||||
<span class="px-3 py-1.5 bg-green-600/30 text-green-300 rounded-lg text-sm font-medium flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
Bereit für Posts
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-3 py-1.5 bg-yellow-600/30 text-yellow-300 rounded-lg text-sm font-medium flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
||||
Setup unvollständig
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Grid -->
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<!-- Scraped Posts -->
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
{% if item.status.has_scraped_posts %}
|
||||
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
{% else %}
|
||||
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
{% endif %}
|
||||
<span class="text-sm text-gray-400">Scraped Posts</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-white">{{ item.status.scraped_posts_count }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Profile Analysis -->
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
{% if item.status.has_profile_analysis %}
|
||||
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
{% else %}
|
||||
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
{% endif %}
|
||||
<span class="text-sm text-gray-400">Profil Analyse</span>
|
||||
</div>
|
||||
<p class="text-lg font-semibold text-white">{{ 'Vorhanden' if item.status.has_profile_analysis else 'Fehlt' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Research Topics -->
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
{% if item.status.research_count > 0 %}
|
||||
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
{% else %}
|
||||
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
{% endif %}
|
||||
<span class="text-sm text-gray-400">Research Topics</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-white">{{ item.status.research_count }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Generated Posts -->
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||
<span class="text-sm text-gray-400">Generierte Posts</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-white">{{ item.status.posts_count }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Missing Items -->
|
||||
{% if item.status.missing_items %}
|
||||
<div class="bg-yellow-900/20 border border-yellow-600/50 rounded-lg p-4">
|
||||
<h4 class="font-medium text-yellow-300 mb-2 flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
||||
Fehlende Elemente
|
||||
</h4>
|
||||
<ul class="space-y-1">
|
||||
{% for item_missing in item.status.missing_items %}
|
||||
<li class="text-yellow-200/80 text-sm flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
{{ item_missing }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="flex flex-wrap gap-3 mt-4">
|
||||
{% if not item.status.has_profile_analysis %}
|
||||
<a href="/admin/customers/new" class="px-4 py-2 btn-primary rounded-lg text-sm transition-colors">
|
||||
Setup wiederholen
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if item.status.research_count == 0 %}
|
||||
<a href="/admin/research" class="px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg text-sm text-white transition-colors">
|
||||
Recherche starten
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if item.status.ready_for_posts %}
|
||||
<a href="/admin/create" class="px-4 py-2 btn-primary rounded-lg text-sm transition-colors">
|
||||
Post erstellen
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="/admin/impersonate/{{ item.customer.id }}" class="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg text-sm text-white transition-colors flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
|
||||
Als User einloggen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-bg rounded-xl border p-12 text-center">
|
||||
<svg class="w-16 h-16 text-gray-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Noch keine Kunden</h3>
|
||||
<p class="text-gray-400 mb-6">Erstelle deinen ersten Kunden, um den Status zu sehen.</p>
|
||||
<a href="/admin/customers/new" class="inline-flex items-center gap-2 px-6 py-3 btn-primary font-medium rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||
Neuer Kunde
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
103
src/web/templates/base.html
Normal file
103
src/web/templates/base.html
Normal file
@@ -0,0 +1,103 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}LinkedIn Posts{% endblock %}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'brand': {
|
||||
'bg': '#3d4848',
|
||||
'bg-light': '#4a5858',
|
||||
'bg-dark': '#2d3838',
|
||||
'highlight': '#ffc700',
|
||||
'highlight-dark': '#e6b300',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body { background-color: #3d4848; }
|
||||
.nav-link.active { background-color: #ffc700; color: #2d3838; }
|
||||
.nav-link.active svg { stroke: #2d3838; }
|
||||
.post-content { white-space: pre-wrap; word-wrap: break-word; }
|
||||
.btn-primary { background-color: #ffc700; color: #2d3838; }
|
||||
.btn-primary:hover { background-color: #e6b300; }
|
||||
.sidebar-bg { background-color: #2d3838; }
|
||||
.card-bg { background-color: #4a5858; border-color: #5a6868; }
|
||||
.input-bg { background-color: #3d4848; border-color: #5a6868; }
|
||||
.input-bg:focus { border-color: #ffc700; outline: none; }
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: #3d4848; }
|
||||
::-webkit-scrollbar-thumb { background: #5a6868; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #6a7878; }
|
||||
</style>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body class="text-gray-100 min-h-screen flex">
|
||||
<!-- Sidebar -->
|
||||
<aside class="w-64 sidebar-bg border-r border-gray-600 flex flex-col fixed h-full">
|
||||
<div class="p-4 border-b border-gray-600">
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<div>
|
||||
<img src="/static/logo.png" alt="Logo" class="h-15 w-auto">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 p-4 space-y-2">
|
||||
<a href="/" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'home' %}active{% endif %}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></svg>
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="/customers/new" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'new_customer' %}active{% endif %}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"/></svg>
|
||||
Neuer Kunde
|
||||
</a>
|
||||
<a href="/research" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'research' %}active{% endif %}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
Research Topics
|
||||
</a>
|
||||
<a href="/create" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'create' %}active{% endif %}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
Post erstellen
|
||||
</a>
|
||||
<a href="/posts" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'posts' %}active{% endif %}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
|
||||
Alle Posts
|
||||
</a>
|
||||
<a href="/scraped-posts" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'scraped_posts' %}active{% endif %}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/></svg>
|
||||
Post-Typen
|
||||
</a>
|
||||
<a href="/status" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'status' %}active{% endif %}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
|
||||
Status
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="p-4 border-t border-gray-600">
|
||||
<a href="/logout" class="flex items-center gap-2 text-gray-400 hover:text-gray-200 text-sm transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
|
||||
Logout
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 ml-64">
|
||||
<div class="p-8">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
539
src/web/templates/create_post.html
Normal file
539
src/web/templates/create_post.html
Normal file
@@ -0,0 +1,539 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Post erstellen - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Post erstellen</h1>
|
||||
<p class="text-gray-400">Generiere einen neuen LinkedIn Post mit AI</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<!-- Left: Form -->
|
||||
<div>
|
||||
<form id="createPostForm" class="card-bg rounded-xl border p-6 space-y-6">
|
||||
<!-- Customer Selection -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Kunde auswählen</label>
|
||||
<select name="customer_id" id="customerSelect" required class="w-full input-bg border rounded-lg px-4 py-3 text-white">
|
||||
<option value="">-- Kunde wählen --</option>
|
||||
{% for customer in customers %}
|
||||
<option value="{{ customer.id }}">{{ customer.name }} - {{ customer.company_name or 'Kein Unternehmen' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Post Type Selection -->
|
||||
<div id="postTypeSelectionArea" class="hidden">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Post-Typ auswählen (optional)</label>
|
||||
<div id="postTypeCards" class="flex flex-wrap gap-2 mb-2">
|
||||
<!-- Post type cards will be loaded here -->
|
||||
</div>
|
||||
<input type="hidden" id="selectedPostTypeId" value="">
|
||||
</div>
|
||||
|
||||
<!-- Topic Selection -->
|
||||
<div id="topicSelectionArea" class="hidden">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Topic auswählen</label>
|
||||
<div id="topicsList" class="space-y-2 max-h-64 overflow-y-auto">
|
||||
<p class="text-gray-500">Lade Topics...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Topic -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||
<span>Oder eigenes Topic eingeben</span>
|
||||
</label>
|
||||
<div class="space-y-3">
|
||||
<input type="text" id="customTopicTitle" placeholder="Topic Titel" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
<textarea id="customTopicFact" rows="3" placeholder="Fakt / Kernaussage zum Topic..." class="w-full input-bg border rounded-lg px-4 py-2 text-white"></textarea>
|
||||
<input type="text" id="customTopicSource" placeholder="Quelle (optional)" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Area -->
|
||||
<div id="progressArea" class="hidden">
|
||||
<div class="bg-brand-bg rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span id="progressMessage" class="text-gray-300">Starte Post-Erstellung...</span>
|
||||
<span id="progressPercent" class="text-gray-400">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-brand-bg-dark rounded-full h-2">
|
||||
<div id="progressBar" class="bg-brand-highlight h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
<div id="iterationInfo" class="mt-2 text-sm text-gray-400"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="submitBtn" class="w-full btn-primary font-medium py-3 rounded-lg transition-colors flex items-center justify-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
Post generieren
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% if not customers %}
|
||||
<div class="mt-4 bg-yellow-900/30 border border-yellow-600 rounded-lg p-4">
|
||||
<p class="text-yellow-300">Noch keine Kunden vorhanden. <a href="/customers/new" class="underline">Erstelle zuerst einen Kunden</a>.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Right: Result -->
|
||||
<div>
|
||||
<div id="resultArea" class="card-bg rounded-xl border p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Generierter Post</h3>
|
||||
|
||||
<!-- Live Versions Display -->
|
||||
<div id="liveVersions" class="hidden space-y-4 mb-6">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-sm text-gray-400">Live-Vorschau der Iterationen:</span>
|
||||
</div>
|
||||
<div id="versionsContainer" class="space-y-4"></div>
|
||||
</div>
|
||||
|
||||
<div id="postResult">
|
||||
<p class="text-gray-400">Wähle einen Kunden und ein Topic, dann klicke auf "Post generieren"...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const form = document.getElementById('createPostForm');
|
||||
const customerSelect = document.getElementById('customerSelect');
|
||||
const topicSelectionArea = document.getElementById('topicSelectionArea');
|
||||
const topicsList = document.getElementById('topicsList');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const progressArea = document.getElementById('progressArea');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressMessage = document.getElementById('progressMessage');
|
||||
const progressPercent = document.getElementById('progressPercent');
|
||||
const iterationInfo = document.getElementById('iterationInfo');
|
||||
const postResult = document.getElementById('postResult');
|
||||
const liveVersions = document.getElementById('liveVersions');
|
||||
const versionsContainer = document.getElementById('versionsContainer');
|
||||
const postTypeSelectionArea = document.getElementById('postTypeSelectionArea');
|
||||
const postTypeCards = document.getElementById('postTypeCards');
|
||||
const selectedPostTypeIdInput = document.getElementById('selectedPostTypeId');
|
||||
|
||||
let selectedTopic = null;
|
||||
let currentVersionIndex = 0;
|
||||
let currentPostTypes = [];
|
||||
let currentTopics = [];
|
||||
|
||||
function renderVersions(versions, feedbackList) {
|
||||
if (!versions || versions.length === 0) {
|
||||
liveVersions.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
liveVersions.classList.remove('hidden');
|
||||
|
||||
// Build version tabs and content
|
||||
let html = `
|
||||
<div class="flex gap-2 mb-4 flex-wrap">
|
||||
${versions.map((_, i) => `
|
||||
<button onclick="showVersion(${i})" id="versionTab${i}"
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors
|
||||
${i === currentVersionIndex ? 'bg-brand-highlight text-brand-bg-dark' : 'bg-brand-bg text-gray-300 hover:bg-brand-bg-light'}">
|
||||
V${i + 1}
|
||||
${feedbackList[i] ? `<span class="ml-1 text-xs opacity-75">(${feedbackList[i].overall_score || '?'})</span>` : ''}
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Show current version
|
||||
const currentVersion = versions[currentVersionIndex];
|
||||
const currentFeedback = feedbackList[currentVersionIndex];
|
||||
|
||||
html += `
|
||||
<div class="grid grid-cols-1 ${currentFeedback ? 'lg:grid-cols-2' : ''} gap-4">
|
||||
<div class="bg-brand-bg/50 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium text-gray-300">Version ${currentVersionIndex + 1}</span>
|
||||
${currentFeedback ? `
|
||||
<span class="px-2 py-0.5 text-xs rounded ${currentFeedback.approved ? 'bg-green-600/30 text-green-300' : 'bg-yellow-600/30 text-yellow-300'}">
|
||||
${currentFeedback.approved ? 'Approved' : `Score: ${currentFeedback.overall_score}/100`}
|
||||
</span>
|
||||
` : '<span class="text-xs text-gray-500">Wird bewertet...</span>'}
|
||||
</div>
|
||||
<pre class="whitespace-pre-wrap text-gray-200 font-sans text-sm max-h-96 overflow-y-auto">${currentVersion}</pre>
|
||||
</div>
|
||||
${currentFeedback ? `
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4 border border-brand-bg-light">
|
||||
<span class="text-sm font-medium text-gray-300 block mb-2">Kritik</span>
|
||||
<p class="text-sm text-gray-400 mb-3">${currentFeedback.feedback || 'Keine Kritik'}</p>
|
||||
${currentFeedback.improvements && currentFeedback.improvements.length > 0 ? `
|
||||
<div class="mt-2">
|
||||
<span class="text-xs font-medium text-gray-400">Verbesserungen:</span>
|
||||
<ul class="mt-1 space-y-1">
|
||||
${currentFeedback.improvements.map(imp => `
|
||||
<li class="text-xs text-gray-500 flex items-start gap-1">
|
||||
<span class="text-yellow-500">•</span> ${imp}
|
||||
</li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
${currentFeedback.scores ? `
|
||||
<div class="mt-3 pt-3 border-t border-brand-bg-light">
|
||||
<div class="grid grid-cols-3 gap-2 text-xs">
|
||||
<div class="text-center">
|
||||
<div class="text-gray-500">Authentizität</div>
|
||||
<div class="font-medium text-gray-300">${currentFeedback.scores.authenticity_and_style || '?'}/40</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-gray-500">Content</div>
|
||||
<div class="font-medium text-gray-300">${currentFeedback.scores.content_quality || '?'}/35</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-gray-500">Technik</div>
|
||||
<div class="font-medium text-gray-300">${currentFeedback.scores.technical_execution || '?'}/25</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
versionsContainer.innerHTML = html;
|
||||
}
|
||||
|
||||
function showVersion(index) {
|
||||
currentVersionIndex = index;
|
||||
// Get cached versions from progress store
|
||||
const cachedData = window.lastProgressData;
|
||||
if (cachedData) {
|
||||
renderVersions(cachedData.versions, cachedData.feedback_list);
|
||||
}
|
||||
}
|
||||
|
||||
// Load topics and post types when customer is selected
|
||||
customerSelect.addEventListener('change', async () => {
|
||||
const customerId = customerSelect.value;
|
||||
selectedPostTypeIdInput.value = '';
|
||||
|
||||
if (!customerId) {
|
||||
topicSelectionArea.classList.add('hidden');
|
||||
postTypeSelectionArea.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
topicSelectionArea.classList.remove('hidden');
|
||||
topicsList.innerHTML = '<p class="text-gray-500">Lade Topics...</p>';
|
||||
|
||||
// Load post types
|
||||
try {
|
||||
const ptResponse = await fetch(`/api/customers/${customerId}/post-types`);
|
||||
const ptData = await ptResponse.json();
|
||||
|
||||
if (ptData.post_types && ptData.post_types.length > 0) {
|
||||
currentPostTypes = ptData.post_types;
|
||||
postTypeSelectionArea.classList.remove('hidden');
|
||||
|
||||
postTypeCards.innerHTML = `
|
||||
<button type="button" onclick="selectPostTypeForCreate('')" id="ptc_all"
|
||||
class="px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-highlight/20 border-brand-highlight text-white">
|
||||
Alle Typen
|
||||
</button>
|
||||
` + ptData.post_types.map(pt => `
|
||||
<button type="button" onclick="selectPostTypeForCreate('${pt.id}')" id="ptc_${pt.id}"
|
||||
class="px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white">
|
||||
${pt.name}
|
||||
${pt.has_analysis ? '<span class="ml-1 text-green-400 text-xs">*</span>' : ''}
|
||||
</button>
|
||||
`).join('');
|
||||
} else {
|
||||
postTypeSelectionArea.classList.add('hidden');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load post types:', error);
|
||||
postTypeSelectionArea.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Load topics
|
||||
try {
|
||||
const response = await fetch(`/api/customers/${customerId}/topics`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.topics && data.topics.length > 0) {
|
||||
renderTopicsList(data);
|
||||
} else {
|
||||
// No topics available - show helpful message
|
||||
let message = '';
|
||||
if (data.used_count > 0) {
|
||||
message = `<div class="text-center py-4">
|
||||
<p class="text-gray-400 mb-2">Alle ${data.used_count} Topics wurden bereits verwendet.</p>
|
||||
<a href="/research" class="text-brand-highlight hover:underline">Neue Topics recherchieren</a>
|
||||
<p class="text-gray-500 text-sm mt-2">oder gib unten ein eigenes Topic ein.</p>
|
||||
</div>`;
|
||||
} else {
|
||||
message = `<div class="text-center py-4">
|
||||
<p class="text-gray-400 mb-2">Keine Topics gefunden.</p>
|
||||
<a href="/research" class="text-brand-highlight hover:underline">Recherche starten</a>
|
||||
<p class="text-gray-500 text-sm mt-2">oder gib unten ein eigenes Topic ein.</p>
|
||||
</div>`;
|
||||
}
|
||||
topicsList.innerHTML = message;
|
||||
}
|
||||
} catch (error) {
|
||||
topicsList.innerHTML = `<p class="text-red-400">Fehler beim Laden: ${error.message}</p>`;
|
||||
}
|
||||
});
|
||||
|
||||
// Clear selected topic when custom topic is entered
|
||||
['customTopicTitle', 'customTopicFact', 'customTopicSource'].forEach(id => {
|
||||
document.getElementById(id).addEventListener('input', () => {
|
||||
selectedTopic = null;
|
||||
document.querySelectorAll('input[name="topic"]').forEach(radio => radio.checked = false);
|
||||
});
|
||||
});
|
||||
|
||||
function selectPostTypeForCreate(typeId) {
|
||||
selectedPostTypeIdInput.value = typeId;
|
||||
|
||||
// Update card styles
|
||||
document.querySelectorAll('[id^="ptc_"]').forEach(card => {
|
||||
if (card.id === `ptc_${typeId}` || (typeId === '' && card.id === 'ptc_all')) {
|
||||
card.className = 'px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-highlight/20 border-brand-highlight text-white';
|
||||
} else {
|
||||
card.className = 'px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white';
|
||||
}
|
||||
});
|
||||
|
||||
// Optionally reload topics filtered by post type
|
||||
const customerId = customerSelect.value;
|
||||
if (customerId) {
|
||||
loadTopicsForPostType(customerId, typeId);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTopicsForPostType(customerId, postTypeId) {
|
||||
topicsList.innerHTML = '<p class="text-gray-500">Lade Topics...</p>';
|
||||
|
||||
try {
|
||||
let url = `/api/customers/${customerId}/topics`;
|
||||
if (postTypeId) {
|
||||
url += `?post_type_id=${postTypeId}`;
|
||||
}
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.topics && data.topics.length > 0) {
|
||||
renderTopicsList(data);
|
||||
} else {
|
||||
let message = '';
|
||||
if (data.used_count > 0) {
|
||||
message = `<div class="text-center py-4">
|
||||
<p class="text-gray-400 mb-2">Alle ${data.used_count} Topics wurden bereits verwendet.</p>
|
||||
<a href="/research" class="text-brand-highlight hover:underline">Neue Topics recherchieren</a>
|
||||
<p class="text-gray-500 text-sm mt-2">oder gib unten ein eigenes Topic ein.</p>
|
||||
</div>`;
|
||||
} else {
|
||||
message = `<div class="text-center py-4">
|
||||
<p class="text-gray-400 mb-2">Keine Topics gefunden${postTypeId ? ' für diesen Post-Typ' : ''}.</p>
|
||||
<a href="/research" class="text-brand-highlight hover:underline">Recherche starten</a>
|
||||
<p class="text-gray-500 text-sm mt-2">oder gib unten ein eigenes Topic ein.</p>
|
||||
</div>`;
|
||||
}
|
||||
topicsList.innerHTML = message;
|
||||
}
|
||||
} catch (error) {
|
||||
topicsList.innerHTML = `<p class="text-red-400">Fehler beim Laden: ${error.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTopicsList(data) {
|
||||
// Store topics in global array for safe access
|
||||
currentTopics = data.topics;
|
||||
|
||||
// Reset selected topic when list is re-rendered
|
||||
selectedTopic = null;
|
||||
|
||||
let statsHtml = '';
|
||||
if (data.used_count > 0) {
|
||||
statsHtml = `<p class="text-xs text-gray-500 mb-3">${data.available_count} verfügbar · ${data.used_count} bereits verwendet</p>`;
|
||||
}
|
||||
|
||||
topicsList.innerHTML = statsHtml + data.topics.map((topic, i) => `
|
||||
<label class="flex items-start gap-3 p-3 bg-brand-bg/50 rounded-lg cursor-pointer hover:bg-brand-bg transition-colors border border-transparent hover:border-brand-highlight/30">
|
||||
<input type="radio" name="topic" value="${i}" class="mt-1 text-brand-highlight" data-topic-index="${i}">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span class="inline-block px-2 py-0.5 text-xs font-medium bg-brand-highlight/20 text-brand-highlight rounded">${escapeHtml(topic.category || 'Topic')}</span>
|
||||
${topic.target_post_type_id ? `<span class="text-xs text-gray-500">Typ-spezifisch</span>` : ''}
|
||||
${topic.source ? `<span class="text-xs text-gray-500">🔗 ${escapeHtml(topic.source.substring(0, 30))}${topic.source.length > 30 ? '...' : ''}</span>` : ''}
|
||||
</div>
|
||||
<p class="font-medium text-white">${escapeHtml(topic.title)}</p>
|
||||
${topic.angle ? `<p class="text-xs text-brand-highlight/80 mt-1">→ ${escapeHtml(topic.angle)}</p>` : ''}
|
||||
${topic.hook_idea ? `<p class="text-sm text-gray-300 mt-2 italic border-l-2 border-brand-highlight/30 pl-2">"${escapeHtml(topic.hook_idea.substring(0, 120))}${topic.hook_idea.length > 120 ? '...' : ''}"</p>` : ''}
|
||||
${topic.key_facts && topic.key_facts.length > 0 ? `
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
${topic.key_facts.slice(0, 2).map(f => `<span class="text-xs bg-brand-bg-dark px-2 py-0.5 rounded text-gray-400">📊 ${escapeHtml(f.substring(0, 40))}${f.length > 40 ? '...' : ''}</span>`).join('')}
|
||||
</div>
|
||||
` : (topic.fact ? `<p class="text-sm text-gray-400 mt-1">${escapeHtml(topic.fact.substring(0, 100))}...</p>` : '')}
|
||||
${topic.why_this_person ? `<p class="text-xs text-gray-500 mt-2">💡 ${escapeHtml(topic.why_this_person.substring(0, 80))}${topic.why_this_person.length > 80 ? '...' : ''}</p>` : ''}
|
||||
</div>
|
||||
</label>
|
||||
`).join('');
|
||||
|
||||
// Add event listeners to radio buttons
|
||||
document.querySelectorAll('input[name="topic"]').forEach(radio => {
|
||||
radio.addEventListener('change', () => {
|
||||
const index = parseInt(radio.dataset.topicIndex, 10);
|
||||
selectedTopic = currentTopics[index];
|
||||
// Clear custom topic fields
|
||||
document.getElementById('customTopicTitle').value = '';
|
||||
document.getElementById('customTopicFact').value = '';
|
||||
document.getElementById('customTopicSource').value = '';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to escape HTML special characters
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const customerId = customerSelect.value;
|
||||
if (!customerId) {
|
||||
alert('Bitte wähle einen Kunden aus.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get topic (either selected or custom)
|
||||
let topic;
|
||||
const customTitle = document.getElementById('customTopicTitle').value.trim();
|
||||
const customFact = document.getElementById('customTopicFact').value.trim();
|
||||
|
||||
if (customTitle && customFact) {
|
||||
topic = {
|
||||
title: customTitle,
|
||||
fact: customFact,
|
||||
source: document.getElementById('customTopicSource').value.trim() || null,
|
||||
category: 'Custom'
|
||||
};
|
||||
} else if (selectedTopic) {
|
||||
topic = selectedTopic;
|
||||
} else {
|
||||
alert('Bitte wähle ein Topic aus oder gib ein eigenes ein.');
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg> Generiert...';
|
||||
progressArea.classList.remove('hidden');
|
||||
postResult.innerHTML = '<p class="text-gray-400">Post wird generiert...</p>';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('customer_id', customerId);
|
||||
formData.append('topic_json', JSON.stringify(topic));
|
||||
if (selectedPostTypeIdInput.value) {
|
||||
formData.append('post_type_id', selectedPostTypeIdInput.value);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/posts', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
const taskId = data.task_id;
|
||||
currentVersionIndex = 0;
|
||||
window.lastProgressData = null;
|
||||
|
||||
const pollInterval = setInterval(async () => {
|
||||
const statusResponse = await fetch(`/api/tasks/${taskId}`);
|
||||
const status = await statusResponse.json();
|
||||
|
||||
progressBar.style.width = `${status.progress}%`;
|
||||
progressPercent.textContent = `${status.progress}%`;
|
||||
progressMessage.textContent = status.message;
|
||||
|
||||
if (status.iteration !== undefined) {
|
||||
iterationInfo.textContent = `Iteration ${status.iteration}/${status.max_iterations}`;
|
||||
}
|
||||
|
||||
// Update live versions display
|
||||
if (status.versions && status.versions.length > 0) {
|
||||
window.lastProgressData = status;
|
||||
// Auto-select latest version
|
||||
if (status.versions.length > currentVersionIndex + 1) {
|
||||
currentVersionIndex = status.versions.length - 1;
|
||||
}
|
||||
renderVersions(status.versions, status.feedback_list || []);
|
||||
postResult.innerHTML = '<p class="text-gray-400">Siehe Live-Vorschau oben...</p>';
|
||||
}
|
||||
|
||||
if (status.status === 'completed') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> Post generieren';
|
||||
|
||||
// Keep live versions visible but update header
|
||||
const result = status.result;
|
||||
|
||||
postResult.innerHTML = `
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2 text-sm flex-wrap">
|
||||
<span class="px-2 py-1 rounded ${result.approved ? 'bg-green-600/30 text-green-300' : 'bg-yellow-600/30 text-yellow-300'}">
|
||||
${result.approved ? 'Approved' : 'Review needed'}
|
||||
</span>
|
||||
<span class="text-gray-400">Score: ${result.final_score}/100</span>
|
||||
<span class="text-gray-400">Iterations: ${result.iterations}</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-400 mb-2">Finaler Post:</div>
|
||||
<div class="bg-brand-bg/50 rounded-lg p-4">
|
||||
<pre class="whitespace-pre-wrap text-gray-200 font-sans">${result.final_post}</pre>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="copyPost()" class="px-4 py-2 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-sm text-white transition-colors">
|
||||
In Zwischenablage kopieren
|
||||
</button>
|
||||
<button onclick="toggleVersions()" class="px-4 py-2 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-sm text-white transition-colors">
|
||||
Versionen ${liveVersions.classList.contains('hidden') ? 'anzeigen' : 'ausblenden'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (status.status === 'error') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> Post generieren';
|
||||
postResult.innerHTML = `<p class="text-red-400">Fehler: ${status.message}</p>`;
|
||||
}
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> Post generieren';
|
||||
postResult.innerHTML = `<p class="text-red-400">Fehler: ${error.message}</p>`;
|
||||
}
|
||||
});
|
||||
|
||||
function copyPost() {
|
||||
const postText = document.querySelector('#postResult pre').textContent;
|
||||
navigator.clipboard.writeText(postText).then(() => {
|
||||
alert('Post in Zwischenablage kopiert!');
|
||||
});
|
||||
}
|
||||
|
||||
function toggleVersions() {
|
||||
liveVersions.classList.toggle('hidden');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
97
src/web/templates/dashboard.html
Normal file
97
src/web/templates/dashboard.html
Normal file
@@ -0,0 +1,97 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Dashboard</h1>
|
||||
<p class="text-gray-400">Willkommen zum LinkedIn Post Creation System</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||
<strong>Error:</strong> {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div class="card-bg rounded-xl border p-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-400 text-sm">Kunden</p>
|
||||
<p class="text-2xl font-bold text-white">{{ customers_count or 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-bg rounded-xl border p-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-green-600/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-400 text-sm">Generierte Posts</p>
|
||||
<p class="text-2xl font-bold text-white">{{ total_posts or 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-bg rounded-xl border p-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-400 text-sm">AI Agents</p>
|
||||
<p class="text-2xl font-bold text-white">5</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="card-bg rounded-xl border p-6">
|
||||
<h2 class="text-xl font-semibold text-white mb-4">Schnellaktionen</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<a href="/customers/new" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||
<div class="w-10 h-10 btn-primary rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-white">Neuer Kunde</p>
|
||||
<p class="text-sm text-gray-400">Setup starten</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/research" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||
<div class="w-10 h-10 btn-primary rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-white">Research</p>
|
||||
<p class="text-sm text-gray-400">Topics finden</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/create" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||
<div class="w-10 h-10 btn-primary rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-white">Post erstellen</p>
|
||||
<p class="text-sm text-gray-400">Content generieren</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/posts" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||
<div class="w-10 h-10 btn-primary rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-white">Alle Posts</p>
|
||||
<p class="text-sm text-gray-400">Übersicht anzeigen</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
72
src/web/templates/login.html
Normal file
72
src/web/templates/login.html
Normal file
@@ -0,0 +1,72 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - LinkedIn Posts</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'brand': {
|
||||
'bg': '#3d4848',
|
||||
'bg-light': '#4a5858',
|
||||
'bg-dark': '#2d3838',
|
||||
'highlight': '#ffc700',
|
||||
'highlight-dark': '#e6b300',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body { background-color: #3d4848; }
|
||||
.btn-primary { background-color: #ffc700; color: #2d3838; }
|
||||
.btn-primary:hover { background-color: #e6b300; }
|
||||
.card-bg { background-color: #4a5858; border-color: #5a6868; }
|
||||
.input-bg { background-color: #3d4848; border-color: #5a6868; }
|
||||
.input-bg:focus { border-color: #ffc700; outline: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="text-gray-100 min-h-screen flex items-center justify-center">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="card-bg rounded-xl border p-8">
|
||||
<div class="text-center mb-8">
|
||||
<img src="/static/logo.png" alt="Logo" class="h-16 w-auto mx-auto mb-4">
|
||||
<h1 class="text-2xl font-bold text-white mb-2">LinkedIn Posts</h1>
|
||||
<p class="text-gray-400">AI Workflow System</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||
Falsches Passwort. Bitte versuche es erneut.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="/login" class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
autofocus
|
||||
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
|
||||
placeholder="Passwort eingeben..."
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full btn-primary font-medium py-3 rounded-lg transition-colors"
|
||||
>
|
||||
Anmelden
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
274
src/web/templates/new_customer.html
Normal file
274
src/web/templates/new_customer.html
Normal file
@@ -0,0 +1,274 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Neuer Kunde - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Neuer Kunde</h1>
|
||||
<p class="text-gray-400">Richte einen neuen Kunden ein und starte das initiale Setup</p>
|
||||
</div>
|
||||
|
||||
<div class="max-w-2xl">
|
||||
<form id="customerForm" class="card-bg rounded-xl border p-6 space-y-6">
|
||||
<!-- Basic Info -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Basis-Informationen</h3>
|
||||
<div class="grid gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Name *</label>
|
||||
<input type="text" name="name" required class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">LinkedIn URL *</label>
|
||||
<input type="url" name="linkedin_url" required placeholder="https://www.linkedin.com/in/username" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Firma</label>
|
||||
<input type="text" name="company_name" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">E-Mail</label>
|
||||
<input type="email" name="email" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Persona -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Persona & Stil</h3>
|
||||
<div class="grid gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Persona</label>
|
||||
<textarea name="persona" rows="3" placeholder="Beschreibe die Expertise, Positionierung und den Charakter der Person..." class="w-full input-bg border rounded-lg px-4 py-2 text-white"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Ansprache</label>
|
||||
<input type="text" name="form_of_address" placeholder="z.B. Duzen (Du/Euch) oder Siezen (Sie)" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Style Guide</label>
|
||||
<textarea name="style_guide" rows="3" placeholder="Beschreibe den Schreibstil, Tonalität und Richtlinien..." class="w-full input-bg border rounded-lg px-4 py-2 text-white"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Post Types -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-white">Post-Typen</h3>
|
||||
<button type="button" id="addPostTypeBtn" class="text-sm text-brand-highlight hover:underline">+ Post-Typ hinzufügen</button>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400 mb-4">Definiere verschiedene Arten von Posts (z.B. "Thought Leader", "Case Study", "How-To"). Diese werden zur Kategorisierung und typ-spezifischen Analyse verwendet.</p>
|
||||
|
||||
<div id="postTypesContainer" class="space-y-4">
|
||||
<!-- Post type entries will be added here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Area -->
|
||||
<div id="progressArea" class="hidden">
|
||||
<div class="bg-brand-bg rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span id="progressMessage" class="text-gray-300">Starte Setup...</span>
|
||||
<span id="progressPercent" class="text-gray-400">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-brand-bg-dark rounded-full h-2">
|
||||
<div id="progressBar" class="bg-brand-highlight h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result Area -->
|
||||
<div id="resultArea" class="hidden">
|
||||
<div id="successResult" class="hidden bg-green-900/30 border border-green-500 rounded-lg p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
<span class="text-green-300">Setup erfolgreich abgeschlossen!</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="errorResult" class="hidden bg-red-900/30 border border-red-500 rounded-lg p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
<span id="errorMessage" class="text-red-300">Fehler beim Setup</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="flex gap-4">
|
||||
<button type="submit" id="submitBtn" class="flex-1 btn-primary font-medium py-3 rounded-lg transition-colors">
|
||||
Setup starten
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const form = document.getElementById('customerForm');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const progressArea = document.getElementById('progressArea');
|
||||
const resultArea = document.getElementById('resultArea');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressMessage = document.getElementById('progressMessage');
|
||||
const progressPercent = document.getElementById('progressPercent');
|
||||
const postTypesContainer = document.getElementById('postTypesContainer');
|
||||
const addPostTypeBtn = document.getElementById('addPostTypeBtn');
|
||||
|
||||
let postTypeIndex = 0;
|
||||
|
||||
function createPostTypeEntry() {
|
||||
const index = postTypeIndex++;
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'bg-brand-bg rounded-lg p-4 border border-brand-bg-light';
|
||||
entry.id = `postType_${index}`;
|
||||
entry.innerHTML = `
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<span class="text-sm font-medium text-gray-300">Post-Typ ${index + 1}</span>
|
||||
<button type="button" onclick="removePostType(${index})" class="text-red-400 hover:text-red-300 text-sm">Entfernen</button>
|
||||
</div>
|
||||
<div class="grid gap-3">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Name *</label>
|
||||
<input type="text" data-pt-field="name" data-pt-index="${index}" required placeholder="z.B. Thought Leader" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Beschreibung</label>
|
||||
<input type="text" data-pt-field="description" data-pt-index="${index}" placeholder="Kurze Beschreibung" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Identifizierende Hashtags (kommagetrennt)</label>
|
||||
<input type="text" data-pt-field="hashtags" data-pt-index="${index}" placeholder="#ThoughtLeader, #Insight, #Leadership" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Keywords (kommagetrennt)</label>
|
||||
<input type="text" data-pt-field="keywords" data-pt-index="${index}" placeholder="Erfahrung, Learnings, Meinung" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
||||
</div>
|
||||
<details class="mt-2">
|
||||
<summary class="text-xs text-gray-400 cursor-pointer hover:text-gray-300">Erweiterte Eigenschaften</summary>
|
||||
<div class="mt-3 grid gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Zweck</label>
|
||||
<input type="text" data-pt-field="purpose" data-pt-index="${index}" placeholder="z.B. Expertise zeigen, Meinungsführerschaft etablieren" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Typische Tonalität</label>
|
||||
<input type="text" data-pt-field="tone" data-pt-index="${index}" placeholder="z.B. reflektiert, provokativ, inspirierend" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Zielgruppe</label>
|
||||
<input type="text" data-pt-field="target_audience" data-pt-index="${index}" placeholder="z.B. Führungskräfte, Entscheider" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
postTypesContainer.appendChild(entry);
|
||||
}
|
||||
|
||||
function removePostType(index) {
|
||||
const entry = document.getElementById(`postType_${index}`);
|
||||
if (entry) {
|
||||
entry.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function collectPostTypes() {
|
||||
const postTypes = [];
|
||||
const entries = postTypesContainer.querySelectorAll('[id^="postType_"]');
|
||||
|
||||
entries.forEach(entry => {
|
||||
const index = entry.id.split('_')[1];
|
||||
const name = entry.querySelector(`[data-pt-field="name"][data-pt-index="${index}"]`)?.value?.trim();
|
||||
|
||||
if (name) {
|
||||
const hashtagsRaw = entry.querySelector(`[data-pt-field="hashtags"][data-pt-index="${index}"]`)?.value || '';
|
||||
const keywordsRaw = entry.querySelector(`[data-pt-field="keywords"][data-pt-index="${index}"]`)?.value || '';
|
||||
|
||||
postTypes.push({
|
||||
name: name,
|
||||
description: entry.querySelector(`[data-pt-field="description"][data-pt-index="${index}"]`)?.value?.trim() || null,
|
||||
identifying_hashtags: hashtagsRaw.split(',').map(h => h.trim()).filter(h => h),
|
||||
identifying_keywords: keywordsRaw.split(',').map(k => k.trim()).filter(k => k),
|
||||
semantic_properties: {
|
||||
purpose: entry.querySelector(`[data-pt-field="purpose"][data-pt-index="${index}"]`)?.value?.trim() || null,
|
||||
typical_tone: entry.querySelector(`[data-pt-field="tone"][data-pt-index="${index}"]`)?.value?.trim() || null,
|
||||
target_audience: entry.querySelector(`[data-pt-field="target_audience"][data-pt-index="${index}"]`)?.value?.trim() || null
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return postTypes;
|
||||
}
|
||||
|
||||
addPostTypeBtn.addEventListener('click', createPostTypeEntry);
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Wird gestartet...';
|
||||
progressArea.classList.remove('hidden');
|
||||
resultArea.classList.add('hidden');
|
||||
|
||||
const formData = new FormData(form);
|
||||
|
||||
// Add post types as JSON
|
||||
const postTypes = collectPostTypes();
|
||||
if (postTypes.length > 0) {
|
||||
formData.append('post_types_json', JSON.stringify(postTypes));
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/customers', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
// Poll for progress
|
||||
const taskId = data.task_id;
|
||||
const pollInterval = setInterval(async () => {
|
||||
const statusResponse = await fetch(`/api/tasks/${taskId}`);
|
||||
const status = await statusResponse.json();
|
||||
|
||||
progressBar.style.width = `${status.progress}%`;
|
||||
progressPercent.textContent = `${status.progress}%`;
|
||||
progressMessage.textContent = status.message;
|
||||
|
||||
if (status.status === 'completed') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
resultArea.classList.remove('hidden');
|
||||
document.getElementById('successResult').classList.remove('hidden');
|
||||
submitBtn.textContent = 'Setup starten';
|
||||
submitBtn.disabled = false;
|
||||
form.reset();
|
||||
postTypesContainer.innerHTML = '';
|
||||
postTypeIndex = 0;
|
||||
} else if (status.status === 'error') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
resultArea.classList.remove('hidden');
|
||||
document.getElementById('errorResult').classList.remove('hidden');
|
||||
document.getElementById('errorMessage').textContent = status.message;
|
||||
submitBtn.textContent = 'Setup starten';
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
progressArea.classList.add('hidden');
|
||||
resultArea.classList.remove('hidden');
|
||||
document.getElementById('errorResult').classList.remove('hidden');
|
||||
document.getElementById('errorMessage').textContent = error.message;
|
||||
submitBtn.textContent = 'Setup starten';
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
1481
src/web/templates/post_detail.html
Normal file
1481
src/web/templates/post_detail.html
Normal file
File diff suppressed because it is too large
Load Diff
152
src/web/templates/posts.html
Normal file
152
src/web/templates/posts.html
Normal file
@@ -0,0 +1,152 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Alle Posts - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.post-card {
|
||||
background: linear-gradient(135deg, rgba(61, 72, 72, 0.3) 0%, rgba(45, 56, 56, 0.4) 100%);
|
||||
border: 1px solid rgba(61, 72, 72, 0.6);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.post-card:hover {
|
||||
border-color: rgba(255, 199, 0, 0.3);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.customer-header {
|
||||
background: linear-gradient(90deg, rgba(255, 199, 0, 0.1) 0%, transparent 100%);
|
||||
}
|
||||
.score-ring {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.score-high { background: rgba(34, 197, 94, 0.2); border: 2px solid rgba(34, 197, 94, 0.5); color: #86efac; }
|
||||
.score-medium { background: rgba(234, 179, 8, 0.2); border: 2px solid rgba(234, 179, 8, 0.5); color: #fde047; }
|
||||
.score-low { background: rgba(239, 68, 68, 0.2); border: 2px solid rgba(239, 68, 68, 0.5); color: #fca5a5; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Alle Posts</h1>
|
||||
<p class="text-gray-400">{{ total_posts }} generierte Posts</p>
|
||||
</div>
|
||||
<a href="/create" class="px-4 py-2.5 btn-primary rounded-lg font-medium flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||
Neuer Post
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||
<strong>Error:</strong> {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if customers_with_posts %}
|
||||
<div class="space-y-8">
|
||||
{% for item in customers_with_posts %}
|
||||
{% if item.posts %}
|
||||
<div class="card-bg rounded-xl border overflow-hidden">
|
||||
<!-- Customer Header -->
|
||||
<div class="customer-header px-6 py-4 border-b border-brand-bg-light">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-xl flex items-center justify-center shadow-lg overflow-hidden {{ 'bg-brand-highlight' if not item.profile_picture else '' }}">
|
||||
{% if item.profile_picture %}
|
||||
<img src="{{ item.profile_picture }}" alt="{{ item.customer.name }}" class="w-full h-full object-cover" loading="lazy" referrerpolicy="no-referrer">
|
||||
{% else %}
|
||||
<span class="text-brand-bg-dark font-bold text-lg">{{ item.customer.name[0] | upper }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-white text-lg">{{ item.customer.name }}</h3>
|
||||
<p class="text-sm text-gray-400">{{ item.customer.company_name or 'Kein Unternehmen' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="px-4 py-1.5 bg-brand-bg rounded-full text-sm text-gray-300 font-medium">
|
||||
{{ item.post_count }} Post{{ 's' if item.post_count != 1 else '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Posts Grid -->
|
||||
<div class="p-4">
|
||||
<div class="grid gap-3">
|
||||
{% for post in item.posts %}
|
||||
<a href="/posts/{{ post.id }}" class="post-card rounded-xl p-4 block group">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Score Circle -->
|
||||
{% if post.critic_feedback and post.critic_feedback | length > 0 %}
|
||||
{% set score = post.critic_feedback[-1].overall_score %}
|
||||
<div class="score-ring flex-shrink-0 {{ 'score-high' if score >= 85 else 'score-medium' if score >= 70 else 'score-low' }}">
|
||||
{{ score }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="score-ring flex-shrink-0 bg-brand-bg-dark border-2 border-brand-bg-light text-gray-500">
|
||||
—
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<h4 class="font-medium text-white group-hover:text-brand-highlight transition-colors truncate">
|
||||
{{ post.topic_title or 'Untitled' }}
|
||||
</h4>
|
||||
<span class="flex-shrink-0 px-2 py-0.5 text-xs rounded font-medium {{ 'bg-green-600/20 text-green-400 border border-green-600/30' if post.status == 'approved' else 'bg-yellow-600/20 text-yellow-400 border border-yellow-600/30' }}">
|
||||
{{ post.status | capitalize }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 mt-1.5 text-sm text-gray-500">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||
{{ post.created_at.strftime('%d.%m.%Y') if post.created_at else 'N/A' }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{{ post.created_at.strftime('%H:%M') if post.created_at else '' }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
{{ post.iterations }} Iteration{{ 's' if post.iterations != 1 else '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow -->
|
||||
<svg class="w-5 h-5 text-gray-600 group-hover:text-brand-highlight transition-colors flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-bg rounded-xl border p-12 text-center">
|
||||
<div class="w-20 h-20 bg-brand-bg rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<svg class="w-10 h-10 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Noch keine Posts</h3>
|
||||
<p class="text-gray-400 mb-6 max-w-md mx-auto">Erstelle deinen ersten LinkedIn Post mit KI-Unterstützung.</p>
|
||||
<a href="/create" class="inline-flex items-center gap-2 px-6 py-3 btn-primary font-medium rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||
Post erstellen
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
215
src/web/templates/research.html
Normal file
215
src/web/templates/research.html
Normal file
@@ -0,0 +1,215 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Research Topics - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Research Topics</h1>
|
||||
<p class="text-gray-400">Recherchiere neue Content-Themen mit Perplexity AI</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<!-- Left: Form -->
|
||||
<div>
|
||||
<form id="researchForm" class="card-bg rounded-xl border p-6">
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Kunde auswählen</label>
|
||||
<select name="customer_id" id="customerSelect" required class="w-full input-bg border rounded-lg px-4 py-3 text-white">
|
||||
<option value="">-- Kunde wählen --</option>
|
||||
{% for customer in customers %}
|
||||
<option value="{{ customer.id }}">{{ customer.name }} - {{ customer.company_name or 'Kein Unternehmen' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Post Type Selection -->
|
||||
<div id="postTypeArea" class="mb-6 hidden">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Post-Typ (optional)</label>
|
||||
<div id="postTypeCards" class="grid grid-cols-2 gap-2 mb-2">
|
||||
<!-- Post type cards will be loaded here -->
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">Wähle einen Post-Typ für gezielte Recherche oder lasse leer für allgemeine Recherche.</p>
|
||||
<input type="hidden" name="post_type_id" id="selectedPostTypeId" value="">
|
||||
</div>
|
||||
|
||||
<!-- Progress Area -->
|
||||
<div id="progressArea" class="hidden mb-6">
|
||||
<div class="bg-brand-bg rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span id="progressMessage" class="text-gray-300">Starte Recherche...</span>
|
||||
<span id="progressPercent" class="text-gray-400">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-brand-bg-dark rounded-full h-2">
|
||||
<div id="progressBar" class="bg-brand-highlight h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="submitBtn" class="w-full btn-primary font-medium py-3 rounded-lg transition-colors flex items-center justify-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
Research starten
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% if not customers %}
|
||||
<div class="mt-4 bg-yellow-900/30 border border-yellow-600 rounded-lg p-4">
|
||||
<p class="text-yellow-300">Noch keine Kunden vorhanden. <a href="/customers/new" class="underline">Erstelle zuerst einen Kunden</a>.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Right: Results -->
|
||||
<div>
|
||||
<div id="resultsArea" class="card-bg rounded-xl border p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Gefundene Topics</h3>
|
||||
<div id="topicsList" class="space-y-4">
|
||||
<p class="text-gray-400">Starte eine Recherche um Topics zu finden...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const form = document.getElementById('researchForm');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const progressArea = document.getElementById('progressArea');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressMessage = document.getElementById('progressMessage');
|
||||
const progressPercent = document.getElementById('progressPercent');
|
||||
const topicsList = document.getElementById('topicsList');
|
||||
const customerSelect = document.getElementById('customerSelect');
|
||||
const postTypeArea = document.getElementById('postTypeArea');
|
||||
const postTypeCards = document.getElementById('postTypeCards');
|
||||
const selectedPostTypeId = document.getElementById('selectedPostTypeId');
|
||||
|
||||
let currentPostTypes = [];
|
||||
|
||||
// Load post types when customer is selected
|
||||
customerSelect.addEventListener('change', async () => {
|
||||
const customerId = customerSelect.value;
|
||||
selectedPostTypeId.value = '';
|
||||
|
||||
if (!customerId) {
|
||||
postTypeArea.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/customers/${customerId}/post-types`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.post_types && data.post_types.length > 0) {
|
||||
currentPostTypes = data.post_types;
|
||||
postTypeArea.classList.remove('hidden');
|
||||
|
||||
postTypeCards.innerHTML = `
|
||||
<button type="button" onclick="selectPostType('')" id="pt_all"
|
||||
class="p-3 rounded-lg border text-left transition-colors bg-brand-highlight/20 border-brand-highlight text-white">
|
||||
<div class="font-medium text-sm">Alle Typen</div>
|
||||
<div class="text-xs text-gray-400 mt-1">Allgemeine Recherche</div>
|
||||
</button>
|
||||
` + data.post_types.map(pt => `
|
||||
<button type="button" onclick="selectPostType('${pt.id}')" id="pt_${pt.id}"
|
||||
class="p-3 rounded-lg border text-left transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white">
|
||||
<div class="font-medium text-sm">${pt.name}</div>
|
||||
<div class="text-xs text-gray-400 mt-1">${pt.analyzed_post_count || 0} Posts analysiert</div>
|
||||
${pt.has_analysis ? '<span class="text-xs text-green-400">Analyse</span>' : ''}
|
||||
</button>
|
||||
`).join('');
|
||||
} else {
|
||||
postTypeArea.classList.add('hidden');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load post types:', error);
|
||||
postTypeArea.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
function selectPostType(typeId) {
|
||||
selectedPostTypeId.value = typeId;
|
||||
|
||||
// Update card styles
|
||||
document.querySelectorAll('[id^="pt_"]').forEach(card => {
|
||||
if (card.id === `pt_${typeId}` || (typeId === '' && card.id === 'pt_all')) {
|
||||
card.className = 'p-3 rounded-lg border text-left transition-colors bg-brand-highlight/20 border-brand-highlight text-white';
|
||||
} else {
|
||||
card.className = 'p-3 rounded-lg border text-left transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const customerId = customerSelect.value;
|
||||
if (!customerId) return;
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg> Recherchiert...';
|
||||
progressArea.classList.remove('hidden');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('customer_id', customerId);
|
||||
if (selectedPostTypeId.value) {
|
||||
formData.append('post_type_id', selectedPostTypeId.value);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/research', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
const taskId = data.task_id;
|
||||
const pollInterval = setInterval(async () => {
|
||||
const statusResponse = await fetch(`/api/tasks/${taskId}`);
|
||||
const status = await statusResponse.json();
|
||||
|
||||
progressBar.style.width = `${status.progress}%`;
|
||||
progressPercent.textContent = `${status.progress}%`;
|
||||
progressMessage.textContent = status.message;
|
||||
|
||||
if (status.status === 'completed') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> Research starten';
|
||||
|
||||
// Display topics
|
||||
if (status.topics && status.topics.length > 0) {
|
||||
topicsList.innerHTML = status.topics.map((topic, i) => `
|
||||
<div class="bg-brand-bg rounded-lg p-4 border border-brand-bg-light">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<span class="inline-block px-2 py-1 text-xs font-medium bg-brand-highlight/20 text-brand-highlight rounded mb-2">${topic.category || 'Topic'}</span>
|
||||
<h4 class="font-semibold text-white">${topic.title}</h4>
|
||||
${topic.angle ? `<p class="text-sm text-brand-highlight/80 mt-1">↳ ${topic.angle}</p>` : ''}
|
||||
${topic.hook_idea ? `<p class="text-sm text-gray-300 mt-2 italic border-l-2 border-brand-highlight/30 pl-2">"${topic.hook_idea.substring(0, 150)}..."</p>` : ''}
|
||||
<p class="text-gray-400 text-sm mt-2">${topic.fact ? topic.fact.substring(0, 200) + '...' : ''}</p>
|
||||
${topic.source ? `<p class="text-gray-500 text-xs mt-2">Quelle: ${topic.source}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
topicsList.innerHTML = '<p class="text-gray-400">Keine Topics gefunden.</p>';
|
||||
}
|
||||
} else if (status.status === 'error') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> Research starten';
|
||||
topicsList.innerHTML = `<p class="text-red-400">Fehler: ${status.message}</p>`;
|
||||
}
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> Research starten';
|
||||
topicsList.innerHTML = `<p class="text-red-400">Fehler: ${error.message}</p>`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
571
src/web/templates/scraped_posts.html
Normal file
571
src/web/templates/scraped_posts.html
Normal file
@@ -0,0 +1,571 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Gescrapte Posts - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.post-card {
|
||||
background: linear-gradient(135deg, rgba(61, 72, 72, 0.3) 0%, rgba(45, 56, 56, 0.4) 100%);
|
||||
border: 1px solid rgba(61, 72, 72, 0.6);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.post-card:hover {
|
||||
border-color: rgba(255, 199, 0, 0.3);
|
||||
}
|
||||
.post-card.selected {
|
||||
border-color: rgba(255, 199, 0, 0.6);
|
||||
background: linear-gradient(135deg, rgba(255, 199, 0, 0.05) 0%, rgba(45, 56, 56, 0.4) 100%);
|
||||
}
|
||||
.type-badge {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.type-badge:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
.type-badge.active {
|
||||
background-color: rgba(255, 199, 0, 0.2);
|
||||
border-color: #ffc700;
|
||||
}
|
||||
.post-content-preview {
|
||||
max-height: 150px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.post-content-preview::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 40px;
|
||||
background: linear-gradient(transparent, rgba(45, 56, 56, 0.9));
|
||||
}
|
||||
.post-content-expanded {
|
||||
max-height: none;
|
||||
}
|
||||
.post-content-expanded::after {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Gescrapte Posts verwalten</h1>
|
||||
<p class="text-gray-400">Posts manuell kategorisieren und Post-Typ-Analyse triggern</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Customer Selection -->
|
||||
<div class="card-bg rounded-xl border p-6 mb-6">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<div class="flex-1 min-w-64">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Kunde auswählen</label>
|
||||
<select id="customerSelect" class="w-full input-bg border rounded-lg px-4 py-3 text-white">
|
||||
<option value="">-- Kunde wählen --</option>
|
||||
{% for customer in customers %}
|
||||
<option value="{{ customer.id }}">{{ customer.name }} - {{ customer.company_name or 'Kein Unternehmen' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button id="classifyAllBtn" class="hidden px-4 py-3 bg-brand-bg hover:bg-brand-bg-light border border-brand-bg-light rounded-lg text-white transition-colors">
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
Auto-Klassifizieren
|
||||
</span>
|
||||
</button>
|
||||
<button id="analyzeTypesBtn" class="hidden px-4 py-3 btn-primary rounded-lg font-medium transition-colors">
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
|
||||
Post-Typen analysieren
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Area -->
|
||||
<div id="progressArea" class="hidden card-bg rounded-xl border p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span id="progressMessage" class="text-gray-300">Arbeite...</span>
|
||||
<span id="progressPercent" class="text-gray-400">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-brand-bg-dark rounded-full h-2">
|
||||
<div id="progressBar" class="bg-brand-highlight h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats & Post Types -->
|
||||
<div id="statsArea" class="hidden mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
||||
<div class="card-bg rounded-xl border p-4">
|
||||
<div class="text-2xl font-bold text-white" id="totalPostsCount">0</div>
|
||||
<div class="text-sm text-gray-400">Gesamt Posts</div>
|
||||
</div>
|
||||
<div class="card-bg rounded-xl border p-4">
|
||||
<div class="text-2xl font-bold text-green-400" id="classifiedCount">0</div>
|
||||
<div class="text-sm text-gray-400">Klassifiziert</div>
|
||||
</div>
|
||||
<div class="card-bg rounded-xl border p-4">
|
||||
<div class="text-2xl font-bold text-yellow-400" id="unclassifiedCount">0</div>
|
||||
<div class="text-sm text-gray-400">Nicht klassifiziert</div>
|
||||
</div>
|
||||
<div class="card-bg rounded-xl border p-4">
|
||||
<div class="text-2xl font-bold text-brand-highlight" id="postTypesCount">0</div>
|
||||
<div class="text-sm text-gray-400">Post-Typen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Post Type Filter -->
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<button onclick="filterByType(null)" id="filter_all" class="type-badge px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-highlight/20 border-brand-highlight text-white">
|
||||
Alle
|
||||
</button>
|
||||
<button onclick="filterByType('unclassified')" id="filter_unclassified" class="type-badge px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white">
|
||||
Nicht klassifiziert
|
||||
</button>
|
||||
<div id="postTypeFilters" class="contents">
|
||||
<!-- Post type filter buttons will be added here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Posts List -->
|
||||
<div id="postsArea" class="hidden">
|
||||
<div id="postsList" class="space-y-4">
|
||||
<p class="text-gray-400">Lade Posts...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div id="emptyState" class="hidden card-bg rounded-xl border p-12 text-center">
|
||||
<div class="w-20 h-20 bg-brand-bg rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<svg class="w-10 h-10 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Keine gescrapten Posts</h3>
|
||||
<p class="text-gray-400 mb-6 max-w-md mx-auto">Für diesen Kunden wurden noch keine LinkedIn Posts gescrapet.</p>
|
||||
</div>
|
||||
|
||||
{% if not customers %}
|
||||
<div class="bg-yellow-900/30 border border-yellow-600 rounded-lg p-4">
|
||||
<p class="text-yellow-300">Noch keine Kunden vorhanden. <a href="/customers/new" class="underline">Erstelle zuerst einen Kunden</a>.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Post Detail Modal -->
|
||||
<div id="postModal" class="fixed inset-0 bg-black/70 hidden items-center justify-center z-50 p-4">
|
||||
<div class="bg-brand-bg-dark rounded-xl border border-brand-bg-light max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col shadow-2xl">
|
||||
<div class="p-4 border-b border-brand-bg-light flex items-center justify-between bg-brand-bg">
|
||||
<h3 class="text-lg font-semibold text-white">Post Details</h3>
|
||||
<button onclick="closeModal()" class="text-gray-400 hover:text-white p-1 hover:bg-brand-bg-light rounded">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6 overflow-y-auto flex-1">
|
||||
<div id="modalContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const customerSelect = document.getElementById('customerSelect');
|
||||
const classifyAllBtn = document.getElementById('classifyAllBtn');
|
||||
const analyzeTypesBtn = document.getElementById('analyzeTypesBtn');
|
||||
const progressArea = document.getElementById('progressArea');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressMessage = document.getElementById('progressMessage');
|
||||
const progressPercent = document.getElementById('progressPercent');
|
||||
const statsArea = document.getElementById('statsArea');
|
||||
const postsArea = document.getElementById('postsArea');
|
||||
const postsList = document.getElementById('postsList');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
const postTypeFilters = document.getElementById('postTypeFilters');
|
||||
const postModal = document.getElementById('postModal');
|
||||
const modalContent = document.getElementById('modalContent');
|
||||
|
||||
let currentPosts = [];
|
||||
let currentPostTypes = [];
|
||||
let currentFilter = null;
|
||||
|
||||
customerSelect.addEventListener('change', async () => {
|
||||
const customerId = customerSelect.value;
|
||||
|
||||
if (!customerId) {
|
||||
statsArea.classList.add('hidden');
|
||||
postsArea.classList.add('hidden');
|
||||
emptyState.classList.add('hidden');
|
||||
classifyAllBtn.classList.add('hidden');
|
||||
analyzeTypesBtn.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
await loadCustomerData(customerId);
|
||||
});
|
||||
|
||||
async function loadCustomerData(customerId) {
|
||||
// Load post types
|
||||
try {
|
||||
const ptResponse = await fetch(`/api/customers/${customerId}/post-types`);
|
||||
const ptData = await ptResponse.json();
|
||||
currentPostTypes = ptData.post_types || [];
|
||||
|
||||
// Update post type filters
|
||||
postTypeFilters.innerHTML = currentPostTypes.map(pt => `
|
||||
<button onclick="filterByType('${pt.id}')" id="filter_${pt.id}"
|
||||
class="type-badge px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white">
|
||||
${escapeHtml(pt.name)}
|
||||
<span class="ml-1 text-xs text-gray-400">(${pt.analyzed_post_count || 0})</span>
|
||||
${pt.has_analysis ? '<span class="ml-1 text-green-400">*</span>' : ''}
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
document.getElementById('postTypesCount').textContent = currentPostTypes.length;
|
||||
} catch (error) {
|
||||
console.error('Failed to load post types:', error);
|
||||
}
|
||||
|
||||
// Load posts
|
||||
try {
|
||||
const response = await fetch(`/api/customers/${customerId}/linkedin-posts`);
|
||||
const data = await response.json();
|
||||
|
||||
console.log('API Response:', data);
|
||||
|
||||
if (data.error) {
|
||||
console.error('API Error:', data.error);
|
||||
postsList.innerHTML = `<p class="text-red-400">API Fehler: ${escapeHtml(data.error)}</p>`;
|
||||
postsArea.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
currentPosts = data.posts || [];
|
||||
console.log(`Loaded ${currentPosts.length} posts`);
|
||||
|
||||
if (currentPosts.length === 0) {
|
||||
statsArea.classList.add('hidden');
|
||||
postsArea.classList.add('hidden');
|
||||
emptyState.classList.remove('hidden');
|
||||
classifyAllBtn.classList.add('hidden');
|
||||
analyzeTypesBtn.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update stats
|
||||
const classified = currentPosts.filter(p => p.post_type_id).length;
|
||||
const unclassified = currentPosts.length - classified;
|
||||
|
||||
document.getElementById('totalPostsCount').textContent = currentPosts.length;
|
||||
document.getElementById('classifiedCount').textContent = classified;
|
||||
document.getElementById('unclassifiedCount').textContent = unclassified;
|
||||
|
||||
statsArea.classList.remove('hidden');
|
||||
postsArea.classList.remove('hidden');
|
||||
emptyState.classList.add('hidden');
|
||||
classifyAllBtn.classList.remove('hidden');
|
||||
analyzeTypesBtn.classList.remove('hidden');
|
||||
|
||||
currentFilter = null;
|
||||
filterByType(null);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load posts:', error);
|
||||
postsList.innerHTML = `<p class="text-red-400">Fehler beim Laden: ${error.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function filterByType(typeId) {
|
||||
currentFilter = typeId;
|
||||
|
||||
// Update filter button styles
|
||||
document.querySelectorAll('.type-badge').forEach(btn => {
|
||||
const btnId = btn.id.replace('filter_', '');
|
||||
const isActive = (typeId === null && btnId === 'all') ||
|
||||
(typeId === 'unclassified' && btnId === 'unclassified') ||
|
||||
(btnId === typeId);
|
||||
|
||||
if (isActive) {
|
||||
btn.classList.add('active', 'bg-brand-highlight/20', 'border-brand-highlight');
|
||||
btn.classList.remove('bg-brand-bg', 'border-brand-bg-light');
|
||||
} else {
|
||||
btn.classList.remove('active', 'bg-brand-highlight/20', 'border-brand-highlight');
|
||||
btn.classList.add('bg-brand-bg', 'border-brand-bg-light');
|
||||
}
|
||||
});
|
||||
|
||||
// Filter posts
|
||||
let filteredPosts = currentPosts;
|
||||
if (typeId === 'unclassified') {
|
||||
filteredPosts = currentPosts.filter(p => !p.post_type_id);
|
||||
} else if (typeId) {
|
||||
filteredPosts = currentPosts.filter(p => p.post_type_id === typeId);
|
||||
}
|
||||
|
||||
renderPosts(filteredPosts);
|
||||
}
|
||||
|
||||
function renderPosts(posts) {
|
||||
if (posts.length === 0) {
|
||||
postsList.innerHTML = '<p class="text-gray-400 text-center py-8">Keine Posts in dieser Kategorie.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
postsList.innerHTML = posts.map((post, index) => {
|
||||
const postType = currentPostTypes.find(pt => pt.id === post.post_type_id);
|
||||
const postText = post.post_text || '';
|
||||
const previewText = postText.substring(0, 300);
|
||||
|
||||
return `
|
||||
<div class="post-card rounded-xl p-4 cursor-pointer" data-post-id="${post.id}" onclick="openPostModal('${post.id}')">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-2 mb-3 flex-wrap">
|
||||
${postType ? `
|
||||
<span class="px-2 py-1 text-xs font-medium bg-brand-highlight/20 text-brand-highlight rounded">
|
||||
${escapeHtml(postType.name)}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">${post.classification_method || 'unknown'} (${Math.round((post.classification_confidence || 0) * 100)}%)</span>
|
||||
` : `
|
||||
<span class="px-2 py-1 text-xs font-medium bg-gray-600/30 text-gray-400 rounded">
|
||||
Nicht klassifiziert
|
||||
</span>
|
||||
`}
|
||||
${post.posted_at ? `
|
||||
<span class="text-xs text-gray-500">${new Date(post.posted_at).toLocaleDateString('de-DE')}</span>
|
||||
` : ''}
|
||||
${post.engagement_score ? `
|
||||
<span class="text-xs text-gray-500">Engagement: ${post.engagement_score}</span>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Content Preview -->
|
||||
<div class="post-content-preview text-gray-300 text-sm whitespace-pre-wrap mb-3">
|
||||
${escapeHtml(previewText)}${postText.length > 300 ? '...' : ''}
|
||||
</div>
|
||||
|
||||
<!-- Click hint & Type badges -->
|
||||
<div class="flex items-center justify-between flex-wrap gap-2" onclick="event.stopPropagation()">
|
||||
<span class="text-xs text-gray-500 italic">Klicken für Vollansicht</span>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
${currentPostTypes.map(pt => `
|
||||
<button onclick="event.stopPropagation(); classifyPost('${post.id}', '${pt.id}')"
|
||||
class="px-2 py-1 text-xs rounded transition-colors ${post.post_type_id === pt.id ? 'bg-brand-highlight/30 text-brand-highlight border border-brand-highlight' : 'bg-brand-bg hover:bg-brand-bg-light text-gray-300 border border-brand-bg-light'}">
|
||||
${escapeHtml(pt.name)}
|
||||
</button>
|
||||
`).join('')}
|
||||
${post.post_type_id ? `
|
||||
<button onclick="event.stopPropagation(); classifyPost('${post.id}', null)" class="px-2 py-1 text-xs rounded bg-red-900/30 hover:bg-red-900/50 text-red-300 border border-red-900/50">
|
||||
✕
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function classifyPost(postId, postTypeId) {
|
||||
const customerId = customerSelect.value;
|
||||
if (!customerId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/linkedin-posts/${postId}/classify`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ post_type_id: postTypeId })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to classify post');
|
||||
}
|
||||
|
||||
// Update local data
|
||||
const post = currentPosts.find(p => p.id === postId);
|
||||
if (post) {
|
||||
post.post_type_id = postTypeId;
|
||||
post.classification_method = 'manual';
|
||||
post.classification_confidence = 1.0;
|
||||
}
|
||||
|
||||
// Update stats
|
||||
const classified = currentPosts.filter(p => p.post_type_id).length;
|
||||
document.getElementById('classifiedCount').textContent = classified;
|
||||
document.getElementById('unclassifiedCount').textContent = currentPosts.length - classified;
|
||||
|
||||
// Re-render
|
||||
filterByType(currentFilter);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to classify post:', error);
|
||||
alert('Fehler beim Klassifizieren: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function openPostModal(postId) {
|
||||
const post = currentPosts.find(p => p.id === postId);
|
||||
if (!post) return;
|
||||
|
||||
const postType = currentPostTypes.find(pt => pt.id === post.post_type_id);
|
||||
|
||||
modalContent.innerHTML = `
|
||||
<div class="mb-4 flex items-center gap-3 flex-wrap">
|
||||
${postType ? `
|
||||
<span class="px-3 py-1.5 text-sm font-medium bg-brand-highlight/20 text-brand-highlight rounded-lg">
|
||||
${escapeHtml(postType.name)}
|
||||
</span>
|
||||
` : `
|
||||
<span class="px-3 py-1.5 text-sm font-medium bg-gray-600/30 text-gray-400 rounded-lg">
|
||||
Nicht klassifiziert
|
||||
</span>
|
||||
`}
|
||||
${post.posted_at ? `
|
||||
<span class="text-sm text-gray-500">${new Date(post.posted_at).toLocaleDateString('de-DE')}</span>
|
||||
` : ''}
|
||||
${post.engagement_score ? `
|
||||
<span class="text-sm text-gray-500">Engagement: ${post.engagement_score}</span>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="bg-brand-bg rounded-xl p-6 mb-6 border border-brand-bg-light max-h-[50vh] overflow-y-auto">
|
||||
<div class="whitespace-pre-wrap text-gray-200 font-sans text-base leading-relaxed">${escapeHtml(post.post_text || '')}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 flex-wrap border-t border-brand-bg-light pt-4">
|
||||
<span class="text-sm text-gray-400 font-medium">Typ zuweisen:</span>
|
||||
${currentPostTypes.map(pt => `
|
||||
<button onclick="classifyPost('${post.id}', '${pt.id}'); closeModal();"
|
||||
class="px-4 py-2 text-sm rounded-lg transition-colors ${post.post_type_id === pt.id ? 'bg-brand-highlight text-brand-bg-dark font-medium' : 'bg-brand-bg hover:bg-brand-bg-light text-gray-300 border border-brand-bg-light'}">
|
||||
${escapeHtml(pt.name)}
|
||||
</button>
|
||||
`).join('')}
|
||||
${post.post_type_id ? `
|
||||
<button onclick="classifyPost('${post.id}', null); closeModal();" class="px-4 py-2 text-sm rounded-lg bg-red-900/30 hover:bg-red-900/50 text-red-300 border border-red-900/50">
|
||||
Klassifizierung entfernen
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
postModal.classList.remove('hidden');
|
||||
postModal.classList.add('flex');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
postModal.classList.add('hidden');
|
||||
postModal.classList.remove('flex');
|
||||
}
|
||||
|
||||
// Close modal on backdrop click
|
||||
postModal.addEventListener('click', (e) => {
|
||||
if (e.target === postModal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal on Escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !postModal.classList.contains('hidden')) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-classify button
|
||||
classifyAllBtn.addEventListener('click', async () => {
|
||||
const customerId = customerSelect.value;
|
||||
if (!customerId) return;
|
||||
|
||||
classifyAllBtn.disabled = true;
|
||||
progressArea.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/customers/${customerId}/classify-posts`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
const taskId = data.task_id;
|
||||
await pollTask(taskId, async () => {
|
||||
await loadCustomerData(customerId);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Classification failed:', error);
|
||||
alert('Fehler bei der Klassifizierung: ' + error.message);
|
||||
} finally {
|
||||
classifyAllBtn.disabled = false;
|
||||
progressArea.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Analyze post types button
|
||||
analyzeTypesBtn.addEventListener('click', async () => {
|
||||
const customerId = customerSelect.value;
|
||||
if (!customerId) return;
|
||||
|
||||
analyzeTypesBtn.disabled = true;
|
||||
progressArea.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/customers/${customerId}/analyze-post-types`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
const taskId = data.task_id;
|
||||
await pollTask(taskId, async () => {
|
||||
await loadCustomerData(customerId);
|
||||
alert('Post-Typ-Analyse abgeschlossen! Die Analysen wurden aktualisiert.');
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Analysis failed:', error);
|
||||
alert('Fehler bei der Analyse: ' + error.message);
|
||||
} finally {
|
||||
analyzeTypesBtn.disabled = false;
|
||||
progressArea.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
async function pollTask(taskId, onComplete) {
|
||||
return new Promise((resolve) => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const statusResponse = await fetch(`/api/tasks/${taskId}`);
|
||||
const status = await statusResponse.json();
|
||||
|
||||
progressBar.style.width = `${status.progress}%`;
|
||||
progressPercent.textContent = `${status.progress}%`;
|
||||
progressMessage.textContent = status.message;
|
||||
|
||||
if (status.status === 'completed') {
|
||||
clearInterval(interval);
|
||||
await onComplete();
|
||||
resolve();
|
||||
} else if (status.status === 'error') {
|
||||
clearInterval(interval);
|
||||
alert('Fehler: ' + status.message);
|
||||
resolve();
|
||||
}
|
||||
} catch (error) {
|
||||
clearInterval(interval);
|
||||
console.error('Polling error:', error);
|
||||
resolve();
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
155
src/web/templates/status.html
Normal file
155
src/web/templates/status.html
Normal file
@@ -0,0 +1,155 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Status - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Status</h1>
|
||||
<p class="text-gray-400">Übersicht über alle Kunden und deren Setup-Status</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||
<strong>Error:</strong> {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if customer_statuses %}
|
||||
<div class="grid gap-6">
|
||||
{% for item in customer_statuses %}
|
||||
<div class="card-bg rounded-xl border overflow-hidden">
|
||||
<!-- Customer Header -->
|
||||
<div class="px-6 py-4 border-b border-brand-bg-light">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-full flex items-center justify-center overflow-hidden {{ 'bg-brand-highlight' if not item.profile_picture else '' }}">
|
||||
{% if item.profile_picture %}
|
||||
<img src="{{ item.profile_picture }}" alt="{{ item.customer.name }}" class="w-full h-full object-cover" loading="lazy" referrerpolicy="no-referrer">
|
||||
{% else %}
|
||||
<span class="text-brand-bg-dark font-bold text-lg">{{ item.customer.name[0] | upper }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-white text-lg">{{ item.customer.name }}</h3>
|
||||
<p class="text-sm text-gray-400">{{ item.customer.company_name or 'Kein Unternehmen' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{% if item.status.ready_for_posts %}
|
||||
<span class="px-3 py-1.5 bg-green-600/30 text-green-300 rounded-lg text-sm font-medium flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
Bereit für Posts
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-3 py-1.5 bg-yellow-600/30 text-yellow-300 rounded-lg text-sm font-medium flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
||||
Setup unvollständig
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Grid -->
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<!-- Scraped Posts -->
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
{% if item.status.has_scraped_posts %}
|
||||
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
{% else %}
|
||||
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
{% endif %}
|
||||
<span class="text-sm text-gray-400">Scraped Posts</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-white">{{ item.status.scraped_posts_count }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Profile Analysis -->
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
{% if item.status.has_profile_analysis %}
|
||||
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
{% else %}
|
||||
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
{% endif %}
|
||||
<span class="text-sm text-gray-400">Profil Analyse</span>
|
||||
</div>
|
||||
<p class="text-lg font-semibold text-white">{{ 'Vorhanden' if item.status.has_profile_analysis else 'Fehlt' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Research Topics -->
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
{% if item.status.research_count > 0 %}
|
||||
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
{% else %}
|
||||
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
{% endif %}
|
||||
<span class="text-sm text-gray-400">Research Topics</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-white">{{ item.status.research_count }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Generated Posts -->
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||
<span class="text-sm text-gray-400">Generierte Posts</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-white">{{ item.status.posts_count }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Missing Items -->
|
||||
{% if item.status.missing_items %}
|
||||
<div class="bg-yellow-900/20 border border-yellow-600/50 rounded-lg p-4">
|
||||
<h4 class="font-medium text-yellow-300 mb-2 flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
||||
Fehlende Elemente
|
||||
</h4>
|
||||
<ul class="space-y-1">
|
||||
{% for item_missing in item.status.missing_items %}
|
||||
<li class="text-yellow-200/80 text-sm flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
{{ item_missing }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="flex gap-3 mt-4">
|
||||
{% if not item.status.has_profile_analysis %}
|
||||
<a href="/customers/new" class="px-4 py-2 btn-primary rounded-lg text-sm transition-colors">
|
||||
Setup wiederholen
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if item.status.research_count == 0 %}
|
||||
<a href="/research" class="px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg text-sm text-white transition-colors">
|
||||
Recherche starten
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if item.status.ready_for_posts %}
|
||||
<a href="/create" class="px-4 py-2 btn-primary rounded-lg text-sm transition-colors">
|
||||
Post erstellen
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-bg rounded-xl border p-12 text-center">
|
||||
<svg class="w-16 h-16 text-gray-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Noch keine Kunden</h3>
|
||||
<p class="text-gray-400 mb-6">Erstelle deinen ersten Kunden, um den Status zu sehen.</p>
|
||||
<a href="/customers/new" class="inline-flex items-center gap-2 px-6 py-3 btn-primary font-medium rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||
Neuer Kunde
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
45
src/web/templates/user/auth_callback.html
Normal file
45
src/web/templates/user/auth_callback.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Anmeldung... - LinkedIn Posts</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body { background-color: #3d4848; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="text-gray-100 min-h-screen flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="w-16 h-16 border-4 border-brand-highlight border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<p class="text-white text-lg">Anmeldung wird verarbeitet...</p>
|
||||
<p class="text-gray-400 text-sm mt-2">Du wirst gleich weitergeleitet.</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Supabase returns tokens in URL hash fragment
|
||||
// Extract them and redirect to callback with query params
|
||||
const hash = window.location.hash.substring(1);
|
||||
const params = new URLSearchParams(hash);
|
||||
|
||||
const accessToken = params.get('access_token');
|
||||
const refreshToken = params.get('refresh_token');
|
||||
const error = params.get('error');
|
||||
const errorDescription = params.get('error_description');
|
||||
|
||||
if (error) {
|
||||
window.location.href = `/login?error=${encodeURIComponent(error)}`;
|
||||
} else if (accessToken) {
|
||||
// Redirect back to callback with tokens in query params
|
||||
let callbackUrl = `/auth/callback?access_token=${encodeURIComponent(accessToken)}`;
|
||||
if (refreshToken) {
|
||||
callbackUrl += `&refresh_token=${encodeURIComponent(refreshToken)}`;
|
||||
}
|
||||
window.location.href = callbackUrl;
|
||||
} else {
|
||||
// No tokens found, redirect to login
|
||||
window.location.href = '/login?error=no_tokens';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
113
src/web/templates/user/base.html
Normal file
113
src/web/templates/user/base.html
Normal file
@@ -0,0 +1,113 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}LinkedIn Posts{% endblock %}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'brand': {
|
||||
'bg': '#3d4848',
|
||||
'bg-light': '#4a5858',
|
||||
'bg-dark': '#2d3838',
|
||||
'highlight': '#ffc700',
|
||||
'highlight-dark': '#e6b300',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body { background-color: #3d4848; }
|
||||
.nav-link.active { background-color: #ffc700; color: #2d3838; }
|
||||
.nav-link.active svg { stroke: #2d3838; }
|
||||
.post-content { white-space: pre-wrap; word-wrap: break-word; }
|
||||
.btn-primary { background-color: #ffc700; color: #2d3838; }
|
||||
.btn-primary:hover { background-color: #e6b300; }
|
||||
.sidebar-bg { background-color: #2d3838; }
|
||||
.card-bg { background-color: #4a5858; border-color: #5a6868; }
|
||||
.input-bg { background-color: #3d4848; border-color: #5a6868; }
|
||||
.input-bg:focus { border-color: #ffc700; outline: none; }
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: #3d4848; }
|
||||
::-webkit-scrollbar-thumb { background: #5a6868; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #6a7878; }
|
||||
</style>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body class="text-gray-100 min-h-screen flex">
|
||||
<!-- Sidebar -->
|
||||
<aside class="w-64 sidebar-bg border-r border-gray-600 flex flex-col fixed h-full">
|
||||
<div class="p-4 border-b border-gray-600">
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<div>
|
||||
<img src="/static/logo.png" alt="Logo" class="h-15 w-auto">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Profile -->
|
||||
{% if session %}
|
||||
<div class="p-4 border-b border-gray-600">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full overflow-hidden bg-brand-highlight flex items-center justify-center">
|
||||
{% if session.linkedin_picture %}
|
||||
<img src="{{ session.linkedin_picture }}" alt="{{ session.linkedin_name }}" class="w-full h-full object-cover" referrerpolicy="no-referrer">
|
||||
{% else %}
|
||||
<span class="text-brand-bg-dark font-bold">{{ session.customer_name[0] | upper }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-white font-medium text-sm truncate">{{ session.linkedin_name or session.customer_name }}</p>
|
||||
<p class="text-gray-400 text-xs truncate">{{ session.customer_name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<nav class="flex-1 p-4 space-y-2">
|
||||
<a href="/" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'home' %}active{% endif %}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></svg>
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="/research" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'research' %}active{% endif %}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
Research Topics
|
||||
</a>
|
||||
<a href="/create" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'create' %}active{% endif %}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
Post erstellen
|
||||
</a>
|
||||
<a href="/posts" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'posts' %}active{% endif %}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
|
||||
Meine Posts
|
||||
</a>
|
||||
<a href="/status" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'status' %}active{% endif %}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
|
||||
Status
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="p-4 border-t border-gray-600">
|
||||
<a href="/logout" class="flex items-center gap-2 text-gray-400 hover:text-gray-200 text-sm transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
|
||||
Logout
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 ml-64">
|
||||
<div class="p-8">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
479
src/web/templates/user/create_post.html
Normal file
479
src/web/templates/user/create_post.html
Normal file
@@ -0,0 +1,479 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Post erstellen - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Post erstellen</h1>
|
||||
<p class="text-gray-400">Generiere einen neuen LinkedIn Post mit AI</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<!-- Left: Form -->
|
||||
<div>
|
||||
<form id="createPostForm" class="card-bg rounded-xl border p-6 space-y-6">
|
||||
<!-- Post Type Selection -->
|
||||
<div id="postTypeSelectionArea" class="hidden">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Post-Typ auswählen (optional)</label>
|
||||
<div id="postTypeCards" class="flex flex-wrap gap-2 mb-2">
|
||||
<!-- Post type cards will be loaded here -->
|
||||
</div>
|
||||
<input type="hidden" id="selectedPostTypeId" value="">
|
||||
</div>
|
||||
|
||||
<!-- Topic Selection -->
|
||||
<div id="topicSelectionArea">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Topic auswählen</label>
|
||||
<div id="topicsList" class="space-y-2 max-h-64 overflow-y-auto">
|
||||
<p class="text-gray-500">Lade Topics...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Topic -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||
<span>Oder eigenes Topic eingeben</span>
|
||||
</label>
|
||||
<div class="space-y-3">
|
||||
<input type="text" id="customTopicTitle" placeholder="Topic Titel" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
<textarea id="customTopicFact" rows="3" placeholder="Fakt / Kernaussage zum Topic..." class="w-full input-bg border rounded-lg px-4 py-2 text-white"></textarea>
|
||||
<input type="text" id="customTopicSource" placeholder="Quelle (optional)" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Area -->
|
||||
<div id="progressArea" class="hidden">
|
||||
<div class="bg-brand-bg rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span id="progressMessage" class="text-gray-300">Starte Post-Erstellung...</span>
|
||||
<span id="progressPercent" class="text-gray-400">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-brand-bg-dark rounded-full h-2">
|
||||
<div id="progressBar" class="bg-brand-highlight h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
<div id="iterationInfo" class="mt-2 text-sm text-gray-400"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="submitBtn" class="w-full btn-primary font-medium py-3 rounded-lg transition-colors flex items-center justify-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
Post generieren
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Right: Result -->
|
||||
<div>
|
||||
<div id="resultArea" class="card-bg rounded-xl border p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Generierter Post</h3>
|
||||
|
||||
<!-- Live Versions Display -->
|
||||
<div id="liveVersions" class="hidden space-y-4 mb-6">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-sm text-gray-400">Live-Vorschau der Iterationen:</span>
|
||||
</div>
|
||||
<div id="versionsContainer" class="space-y-4"></div>
|
||||
</div>
|
||||
|
||||
<div id="postResult">
|
||||
<p class="text-gray-400">Wähle ein Topic aus oder gib ein eigenes ein, dann klicke auf "Post generieren"...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const form = document.getElementById('createPostForm');
|
||||
const topicSelectionArea = document.getElementById('topicSelectionArea');
|
||||
const topicsList = document.getElementById('topicsList');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const progressArea = document.getElementById('progressArea');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressMessage = document.getElementById('progressMessage');
|
||||
const progressPercent = document.getElementById('progressPercent');
|
||||
const iterationInfo = document.getElementById('iterationInfo');
|
||||
const postResult = document.getElementById('postResult');
|
||||
const liveVersions = document.getElementById('liveVersions');
|
||||
const versionsContainer = document.getElementById('versionsContainer');
|
||||
const postTypeSelectionArea = document.getElementById('postTypeSelectionArea');
|
||||
const postTypeCards = document.getElementById('postTypeCards');
|
||||
const selectedPostTypeIdInput = document.getElementById('selectedPostTypeId');
|
||||
|
||||
let selectedTopic = null;
|
||||
let currentVersionIndex = 0;
|
||||
let currentPostTypes = [];
|
||||
let currentTopics = [];
|
||||
|
||||
function renderVersions(versions, feedbackList) {
|
||||
if (!versions || versions.length === 0) {
|
||||
liveVersions.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
liveVersions.classList.remove('hidden');
|
||||
|
||||
// Build version tabs and content
|
||||
let html = `
|
||||
<div class="flex gap-2 mb-4 flex-wrap">
|
||||
${versions.map((_, i) => `
|
||||
<button onclick="showVersion(${i})" id="versionTab${i}"
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors
|
||||
${i === currentVersionIndex ? 'bg-brand-highlight text-brand-bg-dark' : 'bg-brand-bg text-gray-300 hover:bg-brand-bg-light'}">
|
||||
V${i + 1}
|
||||
${feedbackList[i] ? `<span class="ml-1 text-xs opacity-75">(${feedbackList[i].overall_score || '?'})</span>` : ''}
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Show current version
|
||||
const currentVersion = versions[currentVersionIndex];
|
||||
const currentFeedback = feedbackList[currentVersionIndex];
|
||||
|
||||
html += `
|
||||
<div class="grid grid-cols-1 ${currentFeedback ? 'lg:grid-cols-2' : ''} gap-4">
|
||||
<div class="bg-brand-bg/50 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium text-gray-300">Version ${currentVersionIndex + 1}</span>
|
||||
${currentFeedback ? `
|
||||
<span class="px-2 py-0.5 text-xs rounded ${currentFeedback.approved ? 'bg-green-600/30 text-green-300' : 'bg-yellow-600/30 text-yellow-300'}">
|
||||
${currentFeedback.approved ? 'Approved' : `Score: ${currentFeedback.overall_score}/100`}
|
||||
</span>
|
||||
` : '<span class="text-xs text-gray-500">Wird bewertet...</span>'}
|
||||
</div>
|
||||
<pre class="whitespace-pre-wrap text-gray-200 font-sans text-sm max-h-96 overflow-y-auto">${currentVersion}</pre>
|
||||
</div>
|
||||
${currentFeedback ? `
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4 border border-brand-bg-light">
|
||||
<span class="text-sm font-medium text-gray-300 block mb-2">Kritik</span>
|
||||
<p class="text-sm text-gray-400 mb-3">${currentFeedback.feedback || 'Keine Kritik'}</p>
|
||||
${currentFeedback.improvements && currentFeedback.improvements.length > 0 ? `
|
||||
<div class="mt-2">
|
||||
<span class="text-xs font-medium text-gray-400">Verbesserungen:</span>
|
||||
<ul class="mt-1 space-y-1">
|
||||
${currentFeedback.improvements.map(imp => `
|
||||
<li class="text-xs text-gray-500 flex items-start gap-1">
|
||||
<span class="text-yellow-500">•</span> ${imp}
|
||||
</li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
${currentFeedback.scores ? `
|
||||
<div class="mt-3 pt-3 border-t border-brand-bg-light">
|
||||
<div class="grid grid-cols-3 gap-2 text-xs">
|
||||
<div class="text-center">
|
||||
<div class="text-gray-500">Authentizität</div>
|
||||
<div class="font-medium text-gray-300">${currentFeedback.scores.authenticity_and_style || '?'}/40</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-gray-500">Content</div>
|
||||
<div class="font-medium text-gray-300">${currentFeedback.scores.content_quality || '?'}/35</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-gray-500">Technik</div>
|
||||
<div class="font-medium text-gray-300">${currentFeedback.scores.technical_execution || '?'}/25</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
versionsContainer.innerHTML = html;
|
||||
}
|
||||
|
||||
function showVersion(index) {
|
||||
currentVersionIndex = index;
|
||||
// Get cached versions from progress store
|
||||
const cachedData = window.lastProgressData;
|
||||
if (cachedData) {
|
||||
renderVersions(cachedData.versions, cachedData.feedback_list);
|
||||
}
|
||||
}
|
||||
|
||||
// Load topics and post types on page load
|
||||
async function loadData() {
|
||||
// Load post types
|
||||
try {
|
||||
const ptResponse = await fetch('/api/post-types');
|
||||
const ptData = await ptResponse.json();
|
||||
|
||||
if (ptData.post_types && ptData.post_types.length > 0) {
|
||||
currentPostTypes = ptData.post_types;
|
||||
postTypeSelectionArea.classList.remove('hidden');
|
||||
|
||||
postTypeCards.innerHTML = `
|
||||
<button type="button" onclick="selectPostTypeForCreate('')" id="ptc_all"
|
||||
class="px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-highlight/20 border-brand-highlight text-white">
|
||||
Alle Typen
|
||||
</button>
|
||||
` + ptData.post_types.map(pt => `
|
||||
<button type="button" onclick="selectPostTypeForCreate('${pt.id}')" id="ptc_${pt.id}"
|
||||
class="px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white">
|
||||
${pt.name}
|
||||
${pt.has_analysis ? '<span class="ml-1 text-green-400 text-xs">*</span>' : ''}
|
||||
</button>
|
||||
`).join('');
|
||||
} else {
|
||||
postTypeSelectionArea.classList.add('hidden');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load post types:', error);
|
||||
postTypeSelectionArea.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Load topics
|
||||
loadTopics();
|
||||
}
|
||||
|
||||
async function loadTopics(postTypeId = null) {
|
||||
topicsList.innerHTML = '<p class="text-gray-500">Lade Topics...</p>';
|
||||
|
||||
try {
|
||||
let url = '/api/topics';
|
||||
if (postTypeId) {
|
||||
url += `?post_type_id=${postTypeId}`;
|
||||
}
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.topics && data.topics.length > 0) {
|
||||
renderTopicsList(data);
|
||||
} else {
|
||||
let message = '';
|
||||
if (data.used_count > 0) {
|
||||
message = `<div class="text-center py-4">
|
||||
<p class="text-gray-400 mb-2">Alle ${data.used_count} Topics wurden bereits verwendet.</p>
|
||||
<a href="/research" class="text-brand-highlight hover:underline">Neue Topics recherchieren</a>
|
||||
<p class="text-gray-500 text-sm mt-2">oder gib unten ein eigenes Topic ein.</p>
|
||||
</div>`;
|
||||
} else {
|
||||
message = `<div class="text-center py-4">
|
||||
<p class="text-gray-400 mb-2">Keine Topics gefunden${postTypeId ? ' für diesen Post-Typ' : ''}.</p>
|
||||
<a href="/research" class="text-brand-highlight hover:underline">Recherche starten</a>
|
||||
<p class="text-gray-500 text-sm mt-2">oder gib unten ein eigenes Topic ein.</p>
|
||||
</div>`;
|
||||
}
|
||||
topicsList.innerHTML = message;
|
||||
}
|
||||
} catch (error) {
|
||||
topicsList.innerHTML = `<p class="text-red-400">Fehler beim Laden: ${error.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear selected topic when custom topic is entered
|
||||
['customTopicTitle', 'customTopicFact', 'customTopicSource'].forEach(id => {
|
||||
document.getElementById(id).addEventListener('input', () => {
|
||||
selectedTopic = null;
|
||||
document.querySelectorAll('input[name="topic"]').forEach(radio => radio.checked = false);
|
||||
});
|
||||
});
|
||||
|
||||
function selectPostTypeForCreate(typeId) {
|
||||
selectedPostTypeIdInput.value = typeId;
|
||||
|
||||
// Update card styles
|
||||
document.querySelectorAll('[id^="ptc_"]').forEach(card => {
|
||||
if (card.id === `ptc_${typeId}` || (typeId === '' && card.id === 'ptc_all')) {
|
||||
card.className = 'px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-highlight/20 border-brand-highlight text-white';
|
||||
} else {
|
||||
card.className = 'px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white';
|
||||
}
|
||||
});
|
||||
|
||||
// Reload topics filtered by post type
|
||||
loadTopics(typeId);
|
||||
}
|
||||
|
||||
function renderTopicsList(data) {
|
||||
// Store topics in global array for safe access
|
||||
currentTopics = data.topics;
|
||||
|
||||
// Reset selected topic when list is re-rendered
|
||||
selectedTopic = null;
|
||||
|
||||
let statsHtml = '';
|
||||
if (data.used_count > 0) {
|
||||
statsHtml = `<p class="text-xs text-gray-500 mb-3">${data.available_count} verfügbar · ${data.used_count} bereits verwendet</p>`;
|
||||
}
|
||||
|
||||
topicsList.innerHTML = statsHtml + data.topics.map((topic, i) => `
|
||||
<label class="flex items-start gap-3 p-3 bg-brand-bg/50 rounded-lg cursor-pointer hover:bg-brand-bg transition-colors border border-transparent hover:border-brand-highlight/30">
|
||||
<input type="radio" name="topic" value="${i}" class="mt-1 text-brand-highlight" data-topic-index="${i}">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span class="inline-block px-2 py-0.5 text-xs font-medium bg-brand-highlight/20 text-brand-highlight rounded">${escapeHtml(topic.category || 'Topic')}</span>
|
||||
${topic.target_post_type_id ? `<span class="text-xs text-gray-500">Typ-spezifisch</span>` : ''}
|
||||
${topic.source ? `<span class="text-xs text-gray-500">🔗 ${escapeHtml(topic.source.substring(0, 30))}${topic.source.length > 30 ? '...' : ''}</span>` : ''}
|
||||
</div>
|
||||
<p class="font-medium text-white">${escapeHtml(topic.title)}</p>
|
||||
${topic.angle ? `<p class="text-xs text-brand-highlight/80 mt-1">→ ${escapeHtml(topic.angle)}</p>` : ''}
|
||||
${topic.hook_idea ? `<p class="text-sm text-gray-300 mt-2 italic border-l-2 border-brand-highlight/30 pl-2">"${escapeHtml(topic.hook_idea.substring(0, 120))}${topic.hook_idea.length > 120 ? '...' : ''}"</p>` : ''}
|
||||
${topic.key_facts && topic.key_facts.length > 0 ? `
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
${topic.key_facts.slice(0, 2).map(f => `<span class="text-xs bg-brand-bg-dark px-2 py-0.5 rounded text-gray-400">📊 ${escapeHtml(f.substring(0, 40))}${f.length > 40 ? '...' : ''}</span>`).join('')}
|
||||
</div>
|
||||
` : (topic.fact ? `<p class="text-sm text-gray-400 mt-1">${escapeHtml(topic.fact.substring(0, 100))}...</p>` : '')}
|
||||
${topic.why_this_person ? `<p class="text-xs text-gray-500 mt-2">💡 ${escapeHtml(topic.why_this_person.substring(0, 80))}${topic.why_this_person.length > 80 ? '...' : ''}</p>` : ''}
|
||||
</div>
|
||||
</label>
|
||||
`).join('');
|
||||
|
||||
// Add event listeners to radio buttons
|
||||
document.querySelectorAll('input[name="topic"]').forEach(radio => {
|
||||
radio.addEventListener('change', () => {
|
||||
const index = parseInt(radio.dataset.topicIndex, 10);
|
||||
selectedTopic = currentTopics[index];
|
||||
// Clear custom topic fields
|
||||
document.getElementById('customTopicTitle').value = '';
|
||||
document.getElementById('customTopicFact').value = '';
|
||||
document.getElementById('customTopicSource').value = '';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to escape HTML special characters
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Get topic (either selected or custom)
|
||||
let topic;
|
||||
const customTitle = document.getElementById('customTopicTitle').value.trim();
|
||||
const customFact = document.getElementById('customTopicFact').value.trim();
|
||||
|
||||
if (customTitle && customFact) {
|
||||
topic = {
|
||||
title: customTitle,
|
||||
fact: customFact,
|
||||
source: document.getElementById('customTopicSource').value.trim() || null,
|
||||
category: 'Custom'
|
||||
};
|
||||
} else if (selectedTopic) {
|
||||
topic = selectedTopic;
|
||||
} else {
|
||||
alert('Bitte wähle ein Topic aus oder gib ein eigenes ein.');
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg> Generiert...';
|
||||
progressArea.classList.remove('hidden');
|
||||
postResult.innerHTML = '<p class="text-gray-400">Post wird generiert...</p>';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('topic_json', JSON.stringify(topic));
|
||||
if (selectedPostTypeIdInput.value) {
|
||||
formData.append('post_type_id', selectedPostTypeIdInput.value);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/posts', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
const taskId = data.task_id;
|
||||
currentVersionIndex = 0;
|
||||
window.lastProgressData = null;
|
||||
|
||||
const pollInterval = setInterval(async () => {
|
||||
const statusResponse = await fetch(`/api/tasks/${taskId}`);
|
||||
const status = await statusResponse.json();
|
||||
|
||||
progressBar.style.width = `${status.progress}%`;
|
||||
progressPercent.textContent = `${status.progress}%`;
|
||||
progressMessage.textContent = status.message;
|
||||
|
||||
if (status.iteration !== undefined) {
|
||||
iterationInfo.textContent = `Iteration ${status.iteration}/${status.max_iterations}`;
|
||||
}
|
||||
|
||||
// Update live versions display
|
||||
if (status.versions && status.versions.length > 0) {
|
||||
window.lastProgressData = status;
|
||||
// Auto-select latest version
|
||||
if (status.versions.length > currentVersionIndex + 1) {
|
||||
currentVersionIndex = status.versions.length - 1;
|
||||
}
|
||||
renderVersions(status.versions, status.feedback_list || []);
|
||||
postResult.innerHTML = '<p class="text-gray-400">Siehe Live-Vorschau oben...</p>';
|
||||
}
|
||||
|
||||
if (status.status === 'completed') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> Post generieren';
|
||||
|
||||
// Keep live versions visible but update header
|
||||
const result = status.result;
|
||||
|
||||
postResult.innerHTML = `
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2 text-sm flex-wrap">
|
||||
<span class="px-2 py-1 rounded ${result.approved ? 'bg-green-600/30 text-green-300' : 'bg-yellow-600/30 text-yellow-300'}">
|
||||
${result.approved ? 'Approved' : 'Review needed'}
|
||||
</span>
|
||||
<span class="text-gray-400">Score: ${result.final_score}/100</span>
|
||||
<span class="text-gray-400">Iterations: ${result.iterations}</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-400 mb-2">Finaler Post:</div>
|
||||
<div class="bg-brand-bg/50 rounded-lg p-4">
|
||||
<pre class="whitespace-pre-wrap text-gray-200 font-sans">${result.final_post}</pre>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="copyPost()" class="px-4 py-2 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-sm text-white transition-colors">
|
||||
In Zwischenablage kopieren
|
||||
</button>
|
||||
<button onclick="toggleVersions()" class="px-4 py-2 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-sm text-white transition-colors">
|
||||
Versionen ${liveVersions.classList.contains('hidden') ? 'anzeigen' : 'ausblenden'}
|
||||
</button>
|
||||
<a href="/posts/${result.post_id}" class="px-4 py-2 bg-brand-highlight hover:bg-brand-highlight/90 rounded-lg text-sm text-brand-bg-dark font-medium transition-colors">
|
||||
Post öffnen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (status.status === 'error') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> Post generieren';
|
||||
postResult.innerHTML = `<p class="text-red-400">Fehler: ${status.message}</p>`;
|
||||
}
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> Post generieren';
|
||||
postResult.innerHTML = `<p class="text-red-400">Fehler: ${error.message}</p>`;
|
||||
}
|
||||
});
|
||||
|
||||
function copyPost() {
|
||||
const postText = document.querySelector('#postResult pre').textContent;
|
||||
navigator.clipboard.writeText(postText).then(() => {
|
||||
alert('Post in Zwischenablage kopiert!');
|
||||
});
|
||||
}
|
||||
|
||||
function toggleVersions() {
|
||||
liveVersions.classList.toggle('hidden');
|
||||
}
|
||||
|
||||
// Load data on page load
|
||||
loadData();
|
||||
</script>
|
||||
{% endblock %}
|
||||
76
src/web/templates/user/dashboard.html
Normal file
76
src/web/templates/user/dashboard.html
Normal file
@@ -0,0 +1,76 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Dashboard</h1>
|
||||
<p class="text-gray-400">Willkommen zurück, {{ session.linkedin_name or session.customer_name }}!</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||
<strong>Error:</strong> {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<div class="card-bg rounded-xl border p-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-green-600/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-400 text-sm">Generierte Posts</p>
|
||||
<p class="text-2xl font-bold text-white">{{ total_posts or 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-bg rounded-xl border p-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-400 text-sm">AI Agents</p>
|
||||
<p class="text-2xl font-bold text-white">5</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="card-bg rounded-xl border p-6">
|
||||
<h2 class="text-xl font-semibold text-white mb-4">Schnellaktionen</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<a href="/research" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||
<div class="w-10 h-10 btn-primary rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-white">Research</p>
|
||||
<p class="text-sm text-gray-400">Topics finden</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/create" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||
<div class="w-10 h-10 btn-primary rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-white">Post erstellen</p>
|
||||
<p class="text-sm text-gray-400">Content generieren</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/posts" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||
<div class="w-10 h-10 btn-primary rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-white">Meine Posts</p>
|
||||
<p class="text-sm text-gray-400">Übersicht anzeigen</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
75
src/web/templates/user/login.html
Normal file
75
src/web/templates/user/login.html
Normal file
@@ -0,0 +1,75 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - LinkedIn Posts</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'brand': {
|
||||
'bg': '#3d4848',
|
||||
'bg-light': '#4a5858',
|
||||
'bg-dark': '#2d3838',
|
||||
'highlight': '#ffc700',
|
||||
'highlight-dark': '#e6b300',
|
||||
},
|
||||
'linkedin': '#0A66C2'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body { background-color: #3d4848; }
|
||||
.btn-linkedin { background-color: #0A66C2; }
|
||||
.btn-linkedin:hover { background-color: #004182; }
|
||||
.card-bg { background-color: #4a5858; border-color: #5a6868; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="text-gray-100 min-h-screen flex items-center justify-center">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="card-bg rounded-xl border p-8">
|
||||
<div class="text-center mb-8">
|
||||
<img src="/static/logo.png" alt="Logo" class="h-16 w-auto mx-auto mb-4">
|
||||
<h1 class="text-2xl font-bold text-white mb-2">LinkedIn Posts</h1>
|
||||
<p class="text-gray-400">AI Workflow System</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||
{% if error == 'access_denied' %}
|
||||
Zugriff verweigert. Bitte versuche es erneut.
|
||||
{% elif error == 'unauthorized' %}
|
||||
Dein LinkedIn-Profil ist nicht autorisiert.
|
||||
{% else %}
|
||||
Fehler bei der Anmeldung: {{ error }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="space-y-6">
|
||||
<a href="/auth/linkedin" class="w-full btn-linkedin text-white font-medium py-3 px-4 rounded-lg transition-colors flex items-center justify-center gap-3">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||
</svg>
|
||||
Mit LinkedIn anmelden
|
||||
</a>
|
||||
|
||||
<p class="text-center text-gray-500 text-sm">
|
||||
Melde dich mit deinem LinkedIn-Konto an, um auf das Dashboard zuzugreifen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<a href="/admin/login" class="text-gray-500 hover:text-gray-300 text-sm">
|
||||
Admin-Login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
40
src/web/templates/user/not_authorized.html
Normal file
40
src/web/templates/user/not_authorized.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nicht autorisiert - LinkedIn Posts</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body { background-color: #3d4848; }
|
||||
.card-bg { background-color: #4a5858; border-color: #5a6868; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="text-gray-100 min-h-screen flex items-center justify-center">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="card-bg rounded-xl border p-8 text-center">
|
||||
<div class="w-20 h-20 bg-red-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<svg class="w-10 h-10 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl font-bold text-white mb-4">Nicht autorisiert</h1>
|
||||
|
||||
<p class="text-gray-400 mb-6">
|
||||
Dein LinkedIn-Profil ist nicht mit einem Kundenkonto verknüpft.
|
||||
Bitte kontaktiere den Administrator, um Zugang zu erhalten.
|
||||
</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<a href="/login" class="block w-full bg-brand-bg hover:bg-brand-bg-light text-white font-medium py-3 px-4 rounded-lg transition-colors">
|
||||
Zurück zur Anmeldung
|
||||
</a>
|
||||
<a href="/admin/login" class="block w-full text-gray-400 hover:text-white text-sm transition-colors py-2">
|
||||
Admin-Login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
698
src/web/templates/user/post_detail.html
Normal file
698
src/web/templates/user/post_detail.html
Normal file
@@ -0,0 +1,698 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ post.topic_title }} - Post Details{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.section-card {
|
||||
background: rgba(61, 72, 72, 0.3);
|
||||
border: 1px solid rgba(61, 72, 72, 0.6);
|
||||
}
|
||||
.title-truncate {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.reference-post {
|
||||
background: linear-gradient(135deg, rgba(61, 72, 72, 0.4) 0%, rgba(45, 56, 56, 0.6) 100%);
|
||||
border: 1px solid rgba(255, 199, 0, 0.15);
|
||||
position: relative;
|
||||
}
|
||||
.reference-post::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: linear-gradient(180deg, #ffc700 0%, rgba(255, 199, 0, 0.3) 100%);
|
||||
border-radius: 3px 0 0 3px;
|
||||
}
|
||||
.profile-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.profile-item {
|
||||
background: rgba(45, 56, 56, 0.5);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid rgba(61, 72, 72, 0.8);
|
||||
}
|
||||
.stat-bar {
|
||||
height: 6px;
|
||||
background: rgba(45, 56, 56, 0.8);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stat-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #ffc700 0%, #ffdb4d 100%);
|
||||
border-radius: 3px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
/* LinkedIn Preview styles */
|
||||
.linkedin-preview {
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
color: rgba(0, 0, 0, 0.9);
|
||||
overflow: hidden;
|
||||
}
|
||||
.linkedin-header {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
.linkedin-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #0a66c2 0%, #004182 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.linkedin-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.linkedin-user-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.linkedin-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.9);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.linkedin-headline {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
line-height: 1.3;
|
||||
margin-top: 2px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.linkedin-timestamp {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.linkedin-content {
|
||||
padding: 0 16px 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: rgba(0, 0, 0, 0.9);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.linkedin-content.collapsed {
|
||||
max-height: 120px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.linkedin-content.collapsed::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 40px;
|
||||
background: linear-gradient(transparent, white);
|
||||
}
|
||||
.linkedin-see-more {
|
||||
padding: 0 16px 12px;
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.linkedin-see-more:hover {
|
||||
color: #0a66c2;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.linkedin-engagement {
|
||||
padding: 8px 16px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.linkedin-actions {
|
||||
display: flex;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.linkedin-action-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 12px 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: default;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.linkedin-action-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.linkedin-more-btn {
|
||||
padding: 4px;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 50%;
|
||||
cursor: default;
|
||||
}
|
||||
.view-toggle {
|
||||
display: inline-flex;
|
||||
background: rgba(61, 72, 72, 0.5);
|
||||
border-radius: 8px;
|
||||
padding: 2px;
|
||||
}
|
||||
.view-toggle-btn {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
color: #9ca3af;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.view-toggle-btn.active {
|
||||
background: rgba(255, 199, 0, 0.2);
|
||||
color: #ffc700;
|
||||
}
|
||||
.view-toggle-btn:hover:not(.active) {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
/* Tab styles */
|
||||
.tab-btn {
|
||||
position: relative;
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-weight: 500;
|
||||
color: #9ca3af;
|
||||
transition: all 0.2s ease;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
.tab-btn:hover {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.tab-btn.active {
|
||||
color: #ffc700;
|
||||
border-bottom-color: #ffc700;
|
||||
}
|
||||
.tab-btn.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: #ffc700;
|
||||
}
|
||||
.tab-content {
|
||||
display: none;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Breadcrumb & Header -->
|
||||
<div class="mb-6">
|
||||
<a href="/posts" class="inline-flex items-center gap-2 text-gray-400 hover:text-brand-highlight transition-colors mb-4">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
|
||||
Zurück zu meinen Posts
|
||||
</a>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-2xl font-bold text-white mb-2 title-truncate" title="{{ post.topic_title or 'Untitled Post' }}">
|
||||
{{ post.topic_title or 'Untitled Post' }}
|
||||
</h1>
|
||||
<div class="flex items-center gap-3 text-sm text-gray-400 flex-wrap">
|
||||
<span>{{ post.created_at.strftime('%d.%m.%Y um %H:%M Uhr') if post.created_at else 'N/A' }}</span>
|
||||
<span class="text-gray-600">|</span>
|
||||
<span>{{ post.iterations }} Iteration{{ 's' if post.iterations != 1 else '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 flex-shrink-0">
|
||||
<span class="px-3 py-1.5 rounded-lg text-sm font-medium {{ 'bg-green-600/30 text-green-300 border border-green-600/50' if post.status == 'approved' else 'bg-yellow-600/30 text-yellow-300 border border-yellow-600/50' }}">
|
||||
{{ post.status | capitalize }}
|
||||
</span>
|
||||
{% if final_feedback %}
|
||||
<span class="px-3 py-1.5 rounded-lg text-sm font-bold {{ 'bg-green-600/30 text-green-300' if final_feedback.overall_score >= 85 else 'bg-yellow-600/30 text-yellow-300' if final_feedback.overall_score >= 70 else 'bg-red-600/30 text-red-300' }}">
|
||||
Score: {{ final_feedback.overall_score }}/100
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="mb-6 border-b border-brand-bg-light">
|
||||
<nav class="flex gap-1" role="tablist">
|
||||
<button class="tab-btn active" onclick="switchTab('ergebnis')" role="tab" aria-selected="true" data-tab="ergebnis">
|
||||
<svg class="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
Ergebnis
|
||||
</button>
|
||||
{% if post.writer_versions and post.writer_versions | length > 0 %}
|
||||
<button class="tab-btn" onclick="switchTab('iterationen')" role="tab" aria-selected="false" data-tab="iterationen">
|
||||
<svg class="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
Iterationen
|
||||
<span class="ml-1 px-1.5 py-0.5 text-xs bg-brand-bg rounded">{{ post.writer_versions | length }}</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Ergebnis (mit Sidebar) -->
|
||||
<div id="tab-ergebnis" class="tab-content active">
|
||||
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
<!-- Post Content -->
|
||||
<div class="xl:col-span-2">
|
||||
<div class="section-card rounded-xl p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
Finaler Post
|
||||
</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- View Toggle -->
|
||||
<div class="view-toggle">
|
||||
<button onclick="setView('preview')" id="previewToggle" class="view-toggle-btn active">
|
||||
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||
Preview
|
||||
</button>
|
||||
<button onclick="setView('raw')" id="rawToggle" class="view-toggle-btn">
|
||||
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/></svg>
|
||||
Raw
|
||||
</button>
|
||||
</div>
|
||||
<button onclick="copyToClipboard()" class="px-3 py-1.5 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-sm text-gray-300 transition-colors flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
|
||||
Kopieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LinkedIn Preview View -->
|
||||
<div id="linkedinPreview" class="linkedin-preview shadow-lg">
|
||||
<div class="linkedin-header">
|
||||
<div class="linkedin-avatar">
|
||||
{% if profile_picture_url %}
|
||||
<img src="{{ profile_picture_url }}" alt="{{ session.linkedin_name }}" loading="lazy" referrerpolicy="no-referrer">
|
||||
{% else %}
|
||||
{{ session.linkedin_name[:2] | upper if session.linkedin_name else 'UN' }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="linkedin-user-info">
|
||||
<div class="linkedin-name">{{ session.linkedin_name or 'LinkedIn User' }}</div>
|
||||
<div class="linkedin-headline">{{ session.customer_name or 'LinkedIn Member' }}</div>
|
||||
<div class="linkedin-timestamp">
|
||||
<span>Jetzt</span>
|
||||
<span>•</span>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 1a7 7 0 107 7 7 7 0 00-7-7zM3 8a5 5 0 011-3l.55.55A1.5 1.5 0 015 6.62v1.07a.75.75 0 00.22.53l.56.56a.75.75 0 00.53.22H7v.69a.75.75 0 00.22.53l.56.56a.75.75 0 01.22.53V13a5 5 0 01-5-5zm6.24 4.83l2-2.46a.75.75 0 00.09-.8l-.58-1.16A.76.76 0 0010 8H7v-.19a.51.51 0 01.28-.45l.38-.19a.74.74 0 00.3-1L7.4 5.19a.75.75 0 00-.67-.41H5.67a.75.75 0 01-.44-.14l-.34-.26a5 5 0 017.35 8.44z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="linkedin-more-btn">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M14 12a2 2 0 11-4 0 2 2 0 014 0zM4 12a2 2 0 11-4 0 2 2 0 014 0zm16 0a2 2 0 11-4 0 2 2 0 014 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div id="linkedinContent" class="linkedin-content collapsed">{{ post.post_content }}</div>
|
||||
<div id="seeMoreBtn" class="linkedin-see-more" onclick="toggleContent()">...mehr anzeigen</div>
|
||||
<div class="linkedin-engagement">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="#0a66c2">
|
||||
<path d="M19.46 11l-3.91-3.91a7 7 0 01-1.69-2.74l-.49-1.47A2.76 2.76 0 0010.76 1 2.75 2.75 0 008 3.74v1.12a9.19 9.19 0 00.46 2.85L8.89 9H4.12A2.12 2.12 0 002 11.12a2.16 2.16 0 00.92 1.76A2.11 2.11 0 002 14.62a2.14 2.14 0 001.28 2 2 2 0 00-.28 1 2.12 2.12 0 002 2.12v.14A2.12 2.12 0 007.12 22h7.49a8.08 8.08 0 003.58-.84l.31-.16H21V11z"/>
|
||||
</svg>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="#df704d" style="margin-left: -6px;">
|
||||
<path d="M22.51 11.35a5.84 5.84 0 00-2.53-4.83 5.71 5.71 0 00-5.36-.58 5.64 5.64 0 00-2.62 2.09 5.64 5.64 0 00-2.62-2.09 5.71 5.71 0 00-5.36.58 5.84 5.84 0 00-2.53 4.83 6.6 6.6 0 00.49 2.56c1 2.33 9.91 8.09 9.91 8.09s8.92-5.76 9.91-8.09a6.6 6.6 0 00.71-2.56z"/>
|
||||
</svg>
|
||||
<span style="margin-left: 4px;">42</span>
|
||||
<span style="margin-left: auto;">12 Kommentare • 3 Reposts</span>
|
||||
</div>
|
||||
<div class="linkedin-actions">
|
||||
<button class="linkedin-action-btn">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19.46 11l-3.91-3.91a7 7 0 01-1.69-2.74l-.49-1.47A2.76 2.76 0 0010.76 1 2.75 2.75 0 008 3.74v1.12a9.19 9.19 0 00.46 2.85L8.89 9H4.12A2.12 2.12 0 002 11.12a2.16 2.16 0 00.92 1.76A2.11 2.11 0 002 14.62a2.14 2.14 0 001.28 2 2 2 0 00-.28 1 2.12 2.12 0 002 2.12v.14A2.12 2.12 0 007.12 22h7.49a8.08 8.08 0 003.58-.84l.31-.16H21V11z"/>
|
||||
</svg>
|
||||
Gefällt mir
|
||||
</button>
|
||||
<button class="linkedin-action-btn">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M7 9h10v1H7zm0 4h7v-1H7zm16-2a6.78 6.78 0 01-2.84 5.61L12 22v-4H8A7 7 0 018 4h8a7 7 0 017 7z"/>
|
||||
</svg>
|
||||
Kommentieren
|
||||
</button>
|
||||
<button class="linkedin-action-btn">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M13.96 5H6c-.55 0-1 .45-1 1v11H3V6c0-1.66 1.34-3 3-3h7.96L12 0l1.96 5zM17 7h-7c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h7c1.1 0 2-.9 2-2V9c0-1.1-.9-2-2-2z"/>
|
||||
</svg>
|
||||
Reposten
|
||||
</button>
|
||||
<button class="linkedin-action-btn">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21 3L0 10l7.66 4.26L16 8l-6.26 8.34L14 24l7-21z"/>
|
||||
</svg>
|
||||
Senden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Raw View -->
|
||||
<div id="rawView" class="bg-brand-bg/50 rounded-lg p-5 hidden">
|
||||
<pre id="finalPostContent" class="whitespace-pre-wrap text-gray-200 font-sans text-sm leading-relaxed">{{ post.post_content }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="space-y-6">
|
||||
<!-- Score Breakdown -->
|
||||
{% if final_feedback and final_feedback.scores %}
|
||||
<div class="section-card rounded-xl p-6">
|
||||
<h3 class="font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
|
||||
Score-Aufschlüsselung
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="flex justify-between text-sm mb-2">
|
||||
<span class="text-gray-400">Authentizität & Stil</span>
|
||||
<span class="text-white font-medium">{{ final_feedback.scores.authenticity_and_style }}/40</span>
|
||||
</div>
|
||||
<div class="stat-bar">
|
||||
<div class="stat-bar-fill" style="width: {{ (final_feedback.scores.authenticity_and_style / 40 * 100) | int }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between text-sm mb-2">
|
||||
<span class="text-gray-400">Content-Qualität</span>
|
||||
<span class="text-white font-medium">{{ final_feedback.scores.content_quality }}/35</span>
|
||||
</div>
|
||||
<div class="stat-bar">
|
||||
<div class="stat-bar-fill" style="width: {{ (final_feedback.scores.content_quality / 35 * 100) | int }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between text-sm mb-2">
|
||||
<span class="text-gray-400">Technische Umsetzung</span>
|
||||
<span class="text-white font-medium">{{ final_feedback.scores.technical_execution }}/25</span>
|
||||
</div>
|
||||
<div class="stat-bar">
|
||||
<div class="stat-bar-fill" style="width: {{ (final_feedback.scores.technical_execution / 25 * 100) | int }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-3 border-t border-brand-bg-light">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-300 font-medium">Gesamt</span>
|
||||
<span class="text-brand-highlight font-bold text-lg">{{ final_feedback.overall_score }}/100</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Final Feedback Summary -->
|
||||
{% if final_feedback %}
|
||||
<div class="section-card rounded-xl p-6">
|
||||
<h3 class="font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"/></svg>
|
||||
Finales Feedback
|
||||
</h3>
|
||||
<p class="text-sm text-gray-300 mb-4">{{ final_feedback.feedback }}</p>
|
||||
{% if final_feedback.strengths %}
|
||||
<div class="bg-green-900/10 rounded-lg p-3 border border-green-600/20">
|
||||
<span class="text-xs font-medium text-green-400 block mb-2">Stärken</span>
|
||||
<ul class="space-y-1">
|
||||
{% for s in final_feedback.strengths %}
|
||||
<li class="text-sm text-gray-400 flex items-start gap-2"><span class="text-green-400">+</span> {{ s }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="section-card rounded-xl p-6">
|
||||
<h3 class="font-semibold text-white mb-4">Aktionen</h3>
|
||||
<div class="space-y-2">
|
||||
<button onclick="copyToClipboard()" class="w-full px-4 py-2.5 bg-brand-highlight hover:bg-brand-highlight/90 text-brand-bg-dark font-medium rounded-lg transition-colors flex items-center justify-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
|
||||
Post kopieren
|
||||
</button>
|
||||
<a href="/create" class="w-full px-4 py-2.5 bg-brand-bg hover:bg-brand-bg-light text-white rounded-lg transition-colors flex items-center justify-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||
Neuen Post erstellen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Iterationen -->
|
||||
{% if post.writer_versions and post.writer_versions | length > 0 %}
|
||||
<div id="tab-iterationen" class="tab-content">
|
||||
<div class="section-card rounded-xl p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
Iterationen
|
||||
</h2>
|
||||
<!-- Pagination Controls -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="prevVersion" onclick="changeVersion(-1)" class="p-2 rounded-lg bg-brand-bg hover:bg-brand-bg-light text-gray-400 hover:text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
|
||||
</button>
|
||||
<span class="text-sm text-gray-400">
|
||||
<span id="currentVersionNum">1</span> / {{ post.writer_versions | length }}
|
||||
</span>
|
||||
<button id="nextVersion" onclick="changeVersion(1)" class="p-2 rounded-lg bg-brand-bg hover:bg-brand-bg-light text-gray-400 hover:text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Version Content Container -->
|
||||
{% for i in range(post.writer_versions | length) %}
|
||||
<div class="version-panel {% if i == 0 %}block{% else %}hidden{% endif %}" data-version="{{ i }}">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="px-3 py-1 bg-brand-highlight/20 text-brand-highlight rounded-lg text-sm font-medium">Version {{ i + 1 }}</span>
|
||||
{% if post.critic_feedback and i < post.critic_feedback | length %}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2 py-1 rounded text-xs font-medium {{ 'bg-green-600/30 text-green-300' if post.critic_feedback[i].overall_score >= 85 else 'bg-yellow-600/30 text-yellow-300' }}">
|
||||
Score: {{ post.critic_feedback[i].overall_score }}/100
|
||||
</span>
|
||||
{% if post.critic_feedback[i].approved %}
|
||||
<span class="px-2 py-1 bg-green-600/30 text-green-300 rounded text-xs">Approved</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4 mb-4">
|
||||
<pre class="whitespace-pre-wrap text-gray-300 font-sans text-sm">{{ post.writer_versions[i] }}</pre>
|
||||
</div>
|
||||
|
||||
{% if post.critic_feedback and i < post.critic_feedback | length %}
|
||||
{% set fb = post.critic_feedback[i] %}
|
||||
<div class="bg-brand-bg/20 rounded-lg p-4 border border-brand-bg-light">
|
||||
<h4 class="text-sm font-medium text-white mb-2 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"/></svg>
|
||||
Critic Feedback
|
||||
</h4>
|
||||
<p class="text-sm text-gray-400 mb-3">{{ fb.feedback }}</p>
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
{% if fb.strengths %}
|
||||
<div class="bg-green-900/10 rounded-lg p-3 border border-green-600/20">
|
||||
<span class="text-xs font-medium text-green-400 block mb-2">Stärken</span>
|
||||
<ul class="space-y-1 text-sm text-gray-400">
|
||||
{% for s in fb.strengths %}
|
||||
<li class="flex items-start gap-2"><span class="text-green-400 mt-0.5">+</span> {{ s }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if fb.improvements %}
|
||||
<div class="bg-yellow-900/10 rounded-lg p-3 border border-yellow-600/20">
|
||||
<span class="text-xs font-medium text-yellow-400 block mb-2">Verbesserungen</span>
|
||||
<ul class="space-y-1 text-sm text-gray-400">
|
||||
{% for imp in fb.improvements %}
|
||||
<li class="flex items-start gap-2"><span class="text-yellow-400 mt-0.5">-</span> {{ imp }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
let currentVersion = 0;
|
||||
const totalVersions = {{ post.writer_versions | length if post.writer_versions else 0 }};
|
||||
let currentView = 'preview';
|
||||
let contentExpanded = false;
|
||||
|
||||
// LinkedIn Preview functions
|
||||
function setView(view) {
|
||||
currentView = view;
|
||||
const previewEl = document.getElementById('linkedinPreview');
|
||||
const rawEl = document.getElementById('rawView');
|
||||
const previewToggle = document.getElementById('previewToggle');
|
||||
const rawToggle = document.getElementById('rawToggle');
|
||||
|
||||
if (view === 'preview') {
|
||||
previewEl.classList.remove('hidden');
|
||||
rawEl.classList.add('hidden');
|
||||
previewToggle.classList.add('active');
|
||||
rawToggle.classList.remove('active');
|
||||
} else {
|
||||
previewEl.classList.add('hidden');
|
||||
rawEl.classList.remove('hidden');
|
||||
previewToggle.classList.remove('active');
|
||||
rawToggle.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleContent() {
|
||||
const contentEl = document.getElementById('linkedinContent');
|
||||
const seeMoreBtn = document.getElementById('seeMoreBtn');
|
||||
|
||||
if (contentExpanded) {
|
||||
contentEl.classList.add('collapsed');
|
||||
seeMoreBtn.textContent = '...mehr anzeigen';
|
||||
seeMoreBtn.style.display = 'block';
|
||||
} else {
|
||||
contentEl.classList.remove('collapsed');
|
||||
seeMoreBtn.textContent = '...weniger anzeigen';
|
||||
}
|
||||
contentExpanded = !contentExpanded;
|
||||
}
|
||||
|
||||
function initLinkedInPreview() {
|
||||
const contentEl = document.getElementById('linkedinContent');
|
||||
const seeMoreBtn = document.getElementById('seeMoreBtn');
|
||||
|
||||
if (contentEl && seeMoreBtn) {
|
||||
// Check if content is short enough to not need "see more"
|
||||
const maxCollapsedHeight = 120;
|
||||
contentEl.classList.remove('collapsed');
|
||||
const fullHeight = contentEl.scrollHeight;
|
||||
contentEl.classList.add('collapsed');
|
||||
|
||||
if (fullHeight <= maxCollapsedHeight) {
|
||||
// Content is short, hide "see more" button and remove collapsed class
|
||||
seeMoreBtn.style.display = 'none';
|
||||
contentEl.classList.remove('collapsed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tab switching function
|
||||
function switchTab(tabName) {
|
||||
// Hide all tab contents
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
|
||||
// Deactivate all tab buttons
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
btn.setAttribute('aria-selected', 'false');
|
||||
});
|
||||
|
||||
// Show selected tab content
|
||||
const tabContent = document.getElementById('tab-' + tabName);
|
||||
if (tabContent) {
|
||||
tabContent.classList.add('active');
|
||||
}
|
||||
|
||||
// Activate selected tab button
|
||||
const tabBtn = document.querySelector(`.tab-btn[data-tab="${tabName}"]`);
|
||||
if (tabBtn) {
|
||||
tabBtn.classList.add('active');
|
||||
tabBtn.setAttribute('aria-selected', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard() {
|
||||
const content = document.getElementById('finalPostContent').textContent;
|
||||
navigator.clipboard.writeText(content).then(() => {
|
||||
const btn = event.target.closest('button');
|
||||
const originalHTML = btn.innerHTML;
|
||||
btn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg> Kopiert!';
|
||||
setTimeout(() => btn.innerHTML = originalHTML, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function changeVersion(delta) {
|
||||
const newVersion = currentVersion + delta;
|
||||
if (newVersion < 0 || newVersion >= totalVersions) return;
|
||||
|
||||
// Hide current
|
||||
document.querySelector(`.version-panel[data-version="${currentVersion}"]`).classList.add('hidden');
|
||||
document.querySelector(`.version-panel[data-version="${currentVersion}"]`).classList.remove('block');
|
||||
|
||||
// Show new
|
||||
currentVersion = newVersion;
|
||||
document.querySelector(`.version-panel[data-version="${currentVersion}"]`).classList.remove('hidden');
|
||||
document.querySelector(`.version-panel[data-version="${currentVersion}"]`).classList.add('block');
|
||||
|
||||
// Update counter
|
||||
document.getElementById('currentVersionNum').textContent = currentVersion + 1;
|
||||
|
||||
// Update button states
|
||||
document.getElementById('prevVersion').disabled = currentVersion === 0;
|
||||
document.getElementById('nextVersion').disabled = currentVersion === totalVersions - 1;
|
||||
}
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (totalVersions > 0) {
|
||||
document.getElementById('prevVersion').disabled = true;
|
||||
document.getElementById('nextVersion').disabled = totalVersions <= 1;
|
||||
}
|
||||
// Initialize LinkedIn preview
|
||||
initLinkedInPreview();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
114
src/web/templates/user/posts.html
Normal file
114
src/web/templates/user/posts.html
Normal file
@@ -0,0 +1,114 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Meine Posts - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.post-card {
|
||||
background: linear-gradient(135deg, rgba(61, 72, 72, 0.3) 0%, rgba(45, 56, 56, 0.4) 100%);
|
||||
border: 1px solid rgba(61, 72, 72, 0.6);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.post-card:hover {
|
||||
border-color: rgba(255, 199, 0, 0.3);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.score-ring {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.score-high { background: rgba(34, 197, 94, 0.2); border: 2px solid rgba(34, 197, 94, 0.5); color: #86efac; }
|
||||
.score-medium { background: rgba(234, 179, 8, 0.2); border: 2px solid rgba(234, 179, 8, 0.5); color: #fde047; }
|
||||
.score-low { background: rgba(239, 68, 68, 0.2); border: 2px solid rgba(239, 68, 68, 0.5); color: #fca5a5; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Meine Posts</h1>
|
||||
<p class="text-gray-400">{{ total_posts }} generierte Posts</p>
|
||||
</div>
|
||||
<a href="/create" class="px-4 py-2.5 btn-primary rounded-lg font-medium flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||
Neuer Post
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||
<strong>Error:</strong> {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if posts %}
|
||||
<div class="card-bg rounded-xl border overflow-hidden">
|
||||
<div class="p-4">
|
||||
<div class="grid gap-3">
|
||||
{% for post in posts %}
|
||||
<a href="/posts/{{ post.id }}" class="post-card rounded-xl p-4 block group">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Score Circle -->
|
||||
{% if post.critic_feedback and post.critic_feedback | length > 0 %}
|
||||
{% set score = post.critic_feedback[-1].overall_score %}
|
||||
<div class="score-ring flex-shrink-0 {{ 'score-high' if score >= 85 else 'score-medium' if score >= 70 else 'score-low' }}">
|
||||
{{ score }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="score-ring flex-shrink-0 bg-brand-bg-dark border-2 border-brand-bg-light text-gray-500">
|
||||
—
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<h4 class="font-medium text-white group-hover:text-brand-highlight transition-colors truncate">
|
||||
{{ post.topic_title or 'Untitled' }}
|
||||
</h4>
|
||||
<span class="flex-shrink-0 px-2 py-0.5 text-xs rounded font-medium {{ 'bg-green-600/20 text-green-400 border border-green-600/30' if post.status == 'approved' else 'bg-yellow-600/20 text-yellow-400 border border-yellow-600/30' }}">
|
||||
{{ post.status | capitalize }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 mt-1.5 text-sm text-gray-500">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||
{{ post.created_at.strftime('%d.%m.%Y') if post.created_at else 'N/A' }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
{{ post.iterations }} Iteration{{ 's' if post.iterations != 1 else '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow -->
|
||||
<svg class="w-5 h-5 text-gray-600 group-hover:text-brand-highlight transition-colors flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-bg rounded-xl border p-12 text-center">
|
||||
<div class="w-20 h-20 bg-brand-bg rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<svg class="w-10 h-10 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Noch keine Posts</h3>
|
||||
<p class="text-gray-400 mb-6 max-w-md mx-auto">Erstelle deinen ersten LinkedIn Post mit KI-Unterstützung.</p>
|
||||
<a href="/create" class="inline-flex items-center gap-2 px-6 py-3 btn-primary font-medium rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||
Post erstellen
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
185
src/web/templates/user/research.html
Normal file
185
src/web/templates/user/research.html
Normal file
@@ -0,0 +1,185 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Research Topics - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Research Topics</h1>
|
||||
<p class="text-gray-400">Recherchiere neue Content-Themen mit Perplexity AI</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<!-- Left: Form -->
|
||||
<div>
|
||||
<form id="researchForm" class="card-bg rounded-xl border p-6">
|
||||
<!-- Post Type Selection -->
|
||||
<div id="postTypeArea" class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Post-Typ (optional)</label>
|
||||
<div id="postTypeCards" class="grid grid-cols-2 gap-2 mb-2">
|
||||
<div class="text-gray-500 text-sm">Lade Post-Typen...</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">Wähle einen Post-Typ für gezielte Recherche oder lasse leer für allgemeine Recherche.</p>
|
||||
<input type="hidden" name="post_type_id" id="selectedPostTypeId" value="">
|
||||
</div>
|
||||
|
||||
<!-- Progress Area -->
|
||||
<div id="progressArea" class="hidden mb-6">
|
||||
<div class="bg-brand-bg rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span id="progressMessage" class="text-gray-300">Starte Recherche...</span>
|
||||
<span id="progressPercent" class="text-gray-400">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-brand-bg-dark rounded-full h-2">
|
||||
<div id="progressBar" class="bg-brand-highlight h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="submitBtn" class="w-full btn-primary font-medium py-3 rounded-lg transition-colors flex items-center justify-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
Research starten
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Right: Results -->
|
||||
<div>
|
||||
<div id="resultsArea" class="card-bg rounded-xl border p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Gefundene Topics</h3>
|
||||
<div id="topicsList" class="space-y-4">
|
||||
<p class="text-gray-400">Starte eine Recherche um Topics zu finden...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const form = document.getElementById('researchForm');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const progressArea = document.getElementById('progressArea');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressMessage = document.getElementById('progressMessage');
|
||||
const progressPercent = document.getElementById('progressPercent');
|
||||
const topicsList = document.getElementById('topicsList');
|
||||
const postTypeCards = document.getElementById('postTypeCards');
|
||||
const selectedPostTypeId = document.getElementById('selectedPostTypeId');
|
||||
|
||||
let currentPostTypes = [];
|
||||
|
||||
// Load post types on page load
|
||||
async function loadPostTypes() {
|
||||
try {
|
||||
const response = await fetch('/api/post-types');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.post_types && data.post_types.length > 0) {
|
||||
currentPostTypes = data.post_types;
|
||||
|
||||
postTypeCards.innerHTML = `
|
||||
<button type="button" onclick="selectPostType('')" id="pt_all"
|
||||
class="p-3 rounded-lg border text-left transition-colors bg-brand-highlight/20 border-brand-highlight text-white">
|
||||
<div class="font-medium text-sm">Alle Typen</div>
|
||||
<div class="text-xs text-gray-400 mt-1">Allgemeine Recherche</div>
|
||||
</button>
|
||||
` + data.post_types.map(pt => `
|
||||
<button type="button" onclick="selectPostType('${pt.id}')" id="pt_${pt.id}"
|
||||
class="p-3 rounded-lg border text-left transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white">
|
||||
<div class="font-medium text-sm">${pt.name}</div>
|
||||
<div class="text-xs text-gray-400 mt-1">${pt.analyzed_post_count || 0} Posts analysiert</div>
|
||||
${pt.has_analysis ? '<span class="text-xs text-green-400">Analyse</span>' : ''}
|
||||
</button>
|
||||
`).join('');
|
||||
} else {
|
||||
postTypeCards.innerHTML = '<p class="text-gray-500 text-sm col-span-2">Keine Post-Typen konfiguriert.</p>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load post types:', error);
|
||||
postTypeCards.innerHTML = '<p class="text-red-400 text-sm col-span-2">Fehler beim Laden.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function selectPostType(typeId) {
|
||||
selectedPostTypeId.value = typeId;
|
||||
|
||||
document.querySelectorAll('[id^="pt_"]').forEach(card => {
|
||||
if (card.id === `pt_${typeId}` || (typeId === '' && card.id === 'pt_all')) {
|
||||
card.className = 'p-3 rounded-lg border text-left transition-colors bg-brand-highlight/20 border-brand-highlight text-white';
|
||||
} else {
|
||||
card.className = 'p-3 rounded-lg border text-left transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg> Recherchiert...';
|
||||
progressArea.classList.remove('hidden');
|
||||
|
||||
const formData = new FormData();
|
||||
if (selectedPostTypeId.value) {
|
||||
formData.append('post_type_id', selectedPostTypeId.value);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/research', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
const taskId = data.task_id;
|
||||
const pollInterval = setInterval(async () => {
|
||||
const statusResponse = await fetch(`/api/tasks/${taskId}`);
|
||||
const status = await statusResponse.json();
|
||||
|
||||
progressBar.style.width = `${status.progress}%`;
|
||||
progressPercent.textContent = `${status.progress}%`;
|
||||
progressMessage.textContent = status.message;
|
||||
|
||||
if (status.status === 'completed') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> Research starten';
|
||||
|
||||
if (status.topics && status.topics.length > 0) {
|
||||
topicsList.innerHTML = status.topics.map((topic, i) => `
|
||||
<div class="bg-brand-bg rounded-lg p-4 border border-brand-bg-light">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<span class="inline-block px-2 py-1 text-xs font-medium bg-brand-highlight/20 text-brand-highlight rounded mb-2">${topic.category || 'Topic'}</span>
|
||||
<h4 class="font-semibold text-white">${topic.title}</h4>
|
||||
${topic.angle ? `<p class="text-sm text-brand-highlight/80 mt-1">↳ ${topic.angle}</p>` : ''}
|
||||
${topic.hook_idea ? `<p class="text-sm text-gray-300 mt-2 italic border-l-2 border-brand-highlight/30 pl-2">"${topic.hook_idea.substring(0, 150)}..."</p>` : ''}
|
||||
<p class="text-gray-400 text-sm mt-2">${topic.fact ? topic.fact.substring(0, 200) + '...' : ''}</p>
|
||||
${topic.source ? `<p class="text-gray-500 text-xs mt-2">Quelle: ${topic.source}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
topicsList.innerHTML = '<p class="text-gray-400">Keine Topics gefunden.</p>';
|
||||
}
|
||||
} else if (status.status === 'error') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> Research starten';
|
||||
topicsList.innerHTML = `<p class="text-red-400">Fehler: ${status.message}</p>`;
|
||||
}
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> Research starten';
|
||||
topicsList.innerHTML = `<p class="text-red-400">Fehler: ${error.message}</p>`;
|
||||
}
|
||||
});
|
||||
|
||||
// Load post types on page load
|
||||
loadPostTypes();
|
||||
</script>
|
||||
{% endblock %}
|
||||
142
src/web/templates/user/status.html
Normal file
142
src/web/templates/user/status.html
Normal file
@@ -0,0 +1,142 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Status - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Status</h1>
|
||||
<p class="text-gray-400">Übersicht über deinen Setup-Status</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||
<strong>Error:</strong> {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if status %}
|
||||
<div class="card-bg rounded-xl border overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="px-6 py-4 border-b border-brand-bg-light">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-full flex items-center justify-center overflow-hidden {{ 'bg-brand-highlight' if not profile_picture else '' }}">
|
||||
{% if profile_picture %}
|
||||
<img src="{{ profile_picture }}" alt="{{ customer.name }}" class="w-full h-full object-cover" loading="lazy" referrerpolicy="no-referrer">
|
||||
{% else %}
|
||||
<span class="text-brand-bg-dark font-bold text-lg">{{ customer.name[0] | upper }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-white text-lg">{{ customer.name }}</h3>
|
||||
<p class="text-sm text-gray-400">{{ customer.company_name or 'Kein Unternehmen' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{% if status.ready_for_posts %}
|
||||
<span class="px-3 py-1.5 bg-green-600/30 text-green-300 rounded-lg text-sm font-medium flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
Bereit für Posts
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-3 py-1.5 bg-yellow-600/30 text-yellow-300 rounded-lg text-sm font-medium flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
||||
Setup unvollständig
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Grid -->
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<!-- Scraped Posts -->
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
{% if status.has_scraped_posts %}
|
||||
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
{% else %}
|
||||
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
{% endif %}
|
||||
<span class="text-sm text-gray-400">Scraped Posts</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-white">{{ status.scraped_posts_count }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Profile Analysis -->
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
{% if status.has_profile_analysis %}
|
||||
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
{% else %}
|
||||
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
{% endif %}
|
||||
<span class="text-sm text-gray-400">Profil Analyse</span>
|
||||
</div>
|
||||
<p class="text-lg font-semibold text-white">{{ 'Vorhanden' if status.has_profile_analysis else 'Fehlt' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Research Topics -->
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
{% if status.research_count > 0 %}
|
||||
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
{% else %}
|
||||
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
{% endif %}
|
||||
<span class="text-sm text-gray-400">Research Topics</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-white">{{ status.research_count }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Generated Posts -->
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||
<span class="text-sm text-gray-400">Generierte Posts</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-white">{{ status.posts_count }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Missing Items -->
|
||||
{% if status.missing_items %}
|
||||
<div class="bg-yellow-900/20 border border-yellow-600/50 rounded-lg p-4">
|
||||
<h4 class="font-medium text-yellow-300 mb-2 flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
||||
Fehlende Elemente
|
||||
</h4>
|
||||
<ul class="space-y-1">
|
||||
{% for item in status.missing_items %}
|
||||
<li class="text-yellow-200/80 text-sm flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
{{ item }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="flex gap-3 mt-4">
|
||||
{% if status.research_count == 0 %}
|
||||
<a href="/research" class="px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg text-sm text-white transition-colors">
|
||||
Recherche starten
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if status.ready_for_posts %}
|
||||
<a href="/create" class="px-4 py-2 btn-primary rounded-lg text-sm transition-colors">
|
||||
Post erstellen
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-bg rounded-xl border p-12 text-center">
|
||||
<svg class="w-16 h-16 text-gray-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Status nicht verfügbar</h3>
|
||||
<p class="text-gray-400 mb-6">Es konnte kein Status geladen werden.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
4
src/web/user/__init__.py
Normal file
4
src/web/user/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""User frontend module."""
|
||||
from src.web.user.routes import user_router
|
||||
|
||||
__all__ = ["user_router"]
|
||||
348
src/web/user/auth.py
Normal file
348
src/web/user/auth.py
Normal file
@@ -0,0 +1,348 @@
|
||||
"""User authentication with Supabase LinkedIn OAuth."""
|
||||
import re
|
||||
import secrets
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Request, Response
|
||||
from loguru import logger
|
||||
|
||||
from src.config import settings
|
||||
from src.database import db
|
||||
|
||||
# Session management
|
||||
USER_SESSION_COOKIE = "linkedin_user_session"
|
||||
SESSION_SECRET = settings.session_secret or secrets.token_hex(32)
|
||||
|
||||
|
||||
def normalize_linkedin_url(url: str) -> str:
|
||||
"""Normalize LinkedIn URL for comparison.
|
||||
|
||||
Extracts the username/vanityName from various LinkedIn URL formats.
|
||||
"""
|
||||
if not url:
|
||||
return ""
|
||||
# Match linkedin.com/in/username with optional trailing slash or query params
|
||||
match = re.search(r'linkedin\.com/in/([^/?]+)', url.lower())
|
||||
if match:
|
||||
return match.group(1).rstrip('/')
|
||||
return url.lower().strip()
|
||||
|
||||
|
||||
async def get_customer_by_vanity_name(vanity_name: str) -> Optional[dict]:
|
||||
"""Find customer by LinkedIn vanityName.
|
||||
|
||||
Constructs the LinkedIn URL from vanityName and matches against
|
||||
Customer.linkedin_url (normalized).
|
||||
"""
|
||||
if not vanity_name:
|
||||
return None
|
||||
|
||||
normalized_vanity = normalize_linkedin_url(f"https://www.linkedin.com/in/{vanity_name}/")
|
||||
|
||||
# Get all customers and match
|
||||
customers = await db.list_customers()
|
||||
for customer in customers:
|
||||
customer_vanity = normalize_linkedin_url(customer.linkedin_url)
|
||||
if customer_vanity == normalized_vanity:
|
||||
return {
|
||||
"id": str(customer.id),
|
||||
"name": customer.name,
|
||||
"linkedin_url": customer.linkedin_url,
|
||||
"company_name": customer.company_name,
|
||||
"email": customer.email
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def get_customer_by_email(email: str) -> Optional[dict]:
|
||||
"""Find customer by email address.
|
||||
|
||||
Fallback matching when LinkedIn vanityName is not available.
|
||||
"""
|
||||
if not email:
|
||||
return None
|
||||
|
||||
email_lower = email.lower().strip()
|
||||
|
||||
# Get all customers and match by email
|
||||
customers = await db.list_customers()
|
||||
for customer in customers:
|
||||
if customer.email and customer.email.lower().strip() == email_lower:
|
||||
return {
|
||||
"id": str(customer.id),
|
||||
"name": customer.name,
|
||||
"linkedin_url": customer.linkedin_url,
|
||||
"company_name": customer.company_name,
|
||||
"email": customer.email
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def get_customer_by_name(name: str) -> Optional[dict]:
|
||||
"""Find customer by name.
|
||||
|
||||
Fallback matching when email is not available.
|
||||
Tries exact match first, then case-insensitive.
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
|
||||
name_lower = name.lower().strip()
|
||||
|
||||
# Get all customers and match by name
|
||||
customers = await db.list_customers()
|
||||
|
||||
# First try exact match
|
||||
for customer in customers:
|
||||
if customer.name == name:
|
||||
return {
|
||||
"id": str(customer.id),
|
||||
"name": customer.name,
|
||||
"linkedin_url": customer.linkedin_url,
|
||||
"company_name": customer.company_name,
|
||||
"email": customer.email
|
||||
}
|
||||
|
||||
# Then try case-insensitive
|
||||
for customer in customers:
|
||||
if customer.name.lower().strip() == name_lower:
|
||||
return {
|
||||
"id": str(customer.id),
|
||||
"name": customer.name,
|
||||
"linkedin_url": customer.linkedin_url,
|
||||
"company_name": customer.company_name,
|
||||
"email": customer.email
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class UserSession:
|
||||
"""User session data."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
customer_id: str,
|
||||
customer_name: str,
|
||||
linkedin_vanity_name: str,
|
||||
linkedin_name: Optional[str] = None,
|
||||
linkedin_picture: Optional[str] = None,
|
||||
email: Optional[str] = None
|
||||
):
|
||||
self.customer_id = customer_id
|
||||
self.customer_name = customer_name
|
||||
self.linkedin_vanity_name = linkedin_vanity_name
|
||||
self.linkedin_name = linkedin_name
|
||||
self.linkedin_picture = linkedin_picture
|
||||
self.email = email
|
||||
|
||||
def to_cookie_value(self) -> str:
|
||||
"""Serialize session to cookie value."""
|
||||
import json
|
||||
import hashlib
|
||||
|
||||
data = {
|
||||
"customer_id": self.customer_id,
|
||||
"customer_name": self.customer_name,
|
||||
"linkedin_vanity_name": self.linkedin_vanity_name,
|
||||
"linkedin_name": self.linkedin_name,
|
||||
"linkedin_picture": self.linkedin_picture,
|
||||
"email": self.email
|
||||
}
|
||||
|
||||
# Create signed cookie value
|
||||
json_data = json.dumps(data)
|
||||
signature = hashlib.sha256(f"{json_data}{SESSION_SECRET}".encode()).hexdigest()[:16]
|
||||
|
||||
import base64
|
||||
encoded = base64.b64encode(json_data.encode()).decode()
|
||||
return f"{encoded}.{signature}"
|
||||
|
||||
@classmethod
|
||||
def from_cookie_value(cls, cookie_value: str) -> Optional["UserSession"]:
|
||||
"""Deserialize session from cookie value."""
|
||||
import json
|
||||
import hashlib
|
||||
import base64
|
||||
|
||||
try:
|
||||
parts = cookie_value.split(".")
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
|
||||
encoded, signature = parts
|
||||
json_data = base64.b64decode(encoded.encode()).decode()
|
||||
|
||||
# Verify signature
|
||||
expected_sig = hashlib.sha256(f"{json_data}{SESSION_SECRET}".encode()).hexdigest()[:16]
|
||||
if signature != expected_sig:
|
||||
logger.warning("Invalid session signature")
|
||||
return None
|
||||
|
||||
data = json.loads(json_data)
|
||||
return cls(
|
||||
customer_id=data["customer_id"],
|
||||
customer_name=data["customer_name"],
|
||||
linkedin_vanity_name=data["linkedin_vanity_name"],
|
||||
linkedin_name=data.get("linkedin_name"),
|
||||
linkedin_picture=data.get("linkedin_picture"),
|
||||
email=data.get("email")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to parse session cookie: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_user_session(request: Request) -> Optional[UserSession]:
|
||||
"""Get user session from request cookies."""
|
||||
cookie = request.cookies.get(USER_SESSION_COOKIE)
|
||||
if not cookie:
|
||||
return None
|
||||
return UserSession.from_cookie_value(cookie)
|
||||
|
||||
|
||||
def set_user_session(response: Response, session: UserSession) -> None:
|
||||
"""Set user session cookie."""
|
||||
response.set_cookie(
|
||||
key=USER_SESSION_COOKIE,
|
||||
value=session.to_cookie_value(),
|
||||
httponly=True,
|
||||
max_age=60 * 60 * 24 * 7, # 7 days
|
||||
samesite="lax"
|
||||
)
|
||||
|
||||
|
||||
def clear_user_session(response: Response) -> None:
|
||||
"""Clear user session cookie."""
|
||||
response.delete_cookie(USER_SESSION_COOKIE)
|
||||
|
||||
|
||||
async def handle_oauth_callback(
|
||||
access_token: str,
|
||||
refresh_token: Optional[str] = None
|
||||
) -> Optional[UserSession]:
|
||||
"""Handle OAuth callback from Supabase.
|
||||
|
||||
1. Get user info from Supabase using access token
|
||||
2. Extract LinkedIn vanityName from user metadata
|
||||
3. Match with Customer record
|
||||
4. Create session if match found
|
||||
|
||||
Returns UserSession if authorized, None if not.
|
||||
"""
|
||||
from supabase import create_client
|
||||
|
||||
try:
|
||||
# Create a new client with the user's access token
|
||||
supabase = create_client(settings.supabase_url, settings.supabase_key)
|
||||
|
||||
# Get user info using the access token
|
||||
user_response = supabase.auth.get_user(access_token)
|
||||
|
||||
if not user_response or not user_response.user:
|
||||
logger.error("Failed to get user from Supabase")
|
||||
return None
|
||||
|
||||
user = user_response.user
|
||||
user_metadata = user.user_metadata or {}
|
||||
|
||||
# Debug: Log full response
|
||||
import json
|
||||
logger.info(f"=== FULL OAUTH RESPONSE ===")
|
||||
logger.info(f"user.id: {user.id}")
|
||||
logger.info(f"user.email: {user.email}")
|
||||
logger.info(f"user.phone: {user.phone}")
|
||||
logger.info(f"user.app_metadata: {json.dumps(user.app_metadata, indent=2)}")
|
||||
logger.info(f"user.user_metadata: {json.dumps(user.user_metadata, indent=2)}")
|
||||
logger.info(f"--- Einzelne Felder ---")
|
||||
logger.info(f"given_name: {user_metadata.get('given_name')}")
|
||||
logger.info(f"family_name: {user_metadata.get('family_name')}")
|
||||
logger.info(f"name: {user_metadata.get('name')}")
|
||||
logger.info(f"email (metadata): {user_metadata.get('email')}")
|
||||
logger.info(f"picture: {user_metadata.get('picture')}")
|
||||
logger.info(f"sub: {user_metadata.get('sub')}")
|
||||
logger.info(f"provider_id: {user_metadata.get('provider_id')}")
|
||||
logger.info(f"=== END OAUTH RESPONSE ===")
|
||||
|
||||
# LinkedIn OIDC provides these fields
|
||||
vanity_name = user_metadata.get("vanityName") # LinkedIn username (often not provided)
|
||||
name = user_metadata.get("name")
|
||||
picture = user_metadata.get("picture")
|
||||
email = user.email
|
||||
|
||||
logger.info(f"OAuth callback for user: {name} (vanityName={vanity_name}, email={email})")
|
||||
|
||||
# Try to match with customer
|
||||
customer = None
|
||||
|
||||
# First try vanityName if available
|
||||
if vanity_name:
|
||||
customer = await get_customer_by_vanity_name(vanity_name)
|
||||
if customer:
|
||||
logger.info(f"Matched by vanityName: {vanity_name}")
|
||||
|
||||
# Fallback to email matching
|
||||
if not customer and email:
|
||||
customer = await get_customer_by_email(email)
|
||||
if customer:
|
||||
logger.info(f"Matched by email: {email}")
|
||||
|
||||
# Fallback to name matching
|
||||
if not customer and name:
|
||||
customer = await get_customer_by_name(name)
|
||||
if customer:
|
||||
logger.info(f"Matched by name: {name}")
|
||||
|
||||
if not customer:
|
||||
# Debug: List all customers to help diagnose
|
||||
all_customers = await db.list_customers()
|
||||
logger.warning(f"No customer found for LinkedIn user: {name} (email={email}, vanityName={vanity_name})")
|
||||
logger.warning(f"Available customers:")
|
||||
for c in all_customers:
|
||||
logger.warning(f" - {c.name}: email={c.email}, linkedin={c.linkedin_url}")
|
||||
return None
|
||||
|
||||
logger.info(f"User {name} matched with customer {customer['name']}")
|
||||
|
||||
# Use vanityName from OAuth or extract from customer's linkedin_url
|
||||
effective_vanity_name = vanity_name
|
||||
if not effective_vanity_name and customer.get("linkedin_url"):
|
||||
effective_vanity_name = normalize_linkedin_url(customer["linkedin_url"])
|
||||
|
||||
return UserSession(
|
||||
customer_id=customer["id"],
|
||||
customer_name=customer["name"],
|
||||
linkedin_vanity_name=effective_vanity_name or "",
|
||||
linkedin_name=name,
|
||||
linkedin_picture=picture,
|
||||
email=email
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"OAuth callback error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_supabase_login_url(redirect_to: str) -> str:
|
||||
"""Generate Supabase OAuth login URL for LinkedIn.
|
||||
|
||||
Args:
|
||||
redirect_to: The URL to redirect to after OAuth (the callback endpoint)
|
||||
|
||||
Returns:
|
||||
The Supabase OAuth URL to redirect the user to
|
||||
"""
|
||||
from urllib.parse import urlencode
|
||||
|
||||
# Supabase OAuth endpoint
|
||||
base_url = f"{settings.supabase_url}/auth/v1/authorize"
|
||||
|
||||
params = {
|
||||
"provider": "linkedin_oidc",
|
||||
"redirect_to": redirect_to
|
||||
}
|
||||
|
||||
return f"{base_url}?{urlencode(params)}"
|
||||
464
src/web/user/routes.py
Normal file
464
src/web/user/routes.py
Normal file
@@ -0,0 +1,464 @@
|
||||
"""User frontend routes (LinkedIn OAuth protected)."""
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Request, Form, BackgroundTasks, HTTPException
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
from loguru import logger
|
||||
|
||||
from src.config import settings
|
||||
from src.database import db
|
||||
from src.orchestrator import orchestrator
|
||||
from src.web.user.auth import (
|
||||
get_user_session, set_user_session, clear_user_session,
|
||||
get_supabase_login_url, handle_oauth_callback, UserSession
|
||||
)
|
||||
|
||||
# Router for user frontend
|
||||
user_router = APIRouter(tags=["user"])
|
||||
|
||||
# Templates
|
||||
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates" / "user")
|
||||
|
||||
# Store for progress updates
|
||||
progress_store = {}
|
||||
|
||||
|
||||
async def get_customer_profile_picture(customer_id: UUID) -> Optional[str]:
|
||||
"""Get profile picture URL from customer's LinkedIn posts."""
|
||||
linkedin_posts = await db.get_linkedin_posts(customer_id)
|
||||
for lp in linkedin_posts:
|
||||
if lp.raw_data and isinstance(lp.raw_data, dict):
|
||||
author = lp.raw_data.get("author", {})
|
||||
if author and isinstance(author, dict):
|
||||
profile_picture_url = author.get("profile_picture")
|
||||
if profile_picture_url:
|
||||
return profile_picture_url
|
||||
return None
|
||||
|
||||
|
||||
def require_user_session(request: Request) -> Optional[UserSession]:
|
||||
"""Check if user is authenticated, redirect to login if not."""
|
||||
session = get_user_session(request)
|
||||
if not session:
|
||||
return None
|
||||
return session
|
||||
|
||||
|
||||
# ==================== AUTH ROUTES ====================
|
||||
|
||||
@user_router.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request, error: str = None):
|
||||
"""User login page with LinkedIn OAuth button."""
|
||||
# If already logged in, redirect to dashboard
|
||||
session = get_user_session(request)
|
||||
if session:
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
|
||||
return templates.TemplateResponse("login.html", {
|
||||
"request": request,
|
||||
"error": error
|
||||
})
|
||||
|
||||
|
||||
@user_router.get("/auth/linkedin")
|
||||
async def start_oauth(request: Request):
|
||||
"""Start LinkedIn OAuth flow via Supabase."""
|
||||
# Build callback URL
|
||||
callback_url = settings.supabase_redirect_url
|
||||
if not callback_url:
|
||||
# Fallback to constructing from request
|
||||
callback_url = str(request.url_for("oauth_callback"))
|
||||
|
||||
login_url = get_supabase_login_url(callback_url)
|
||||
return RedirectResponse(url=login_url, status_code=302)
|
||||
|
||||
|
||||
@user_router.get("/auth/callback")
|
||||
async def oauth_callback(
|
||||
request: Request,
|
||||
access_token: str = None,
|
||||
refresh_token: str = None,
|
||||
error: str = None,
|
||||
error_description: str = None
|
||||
):
|
||||
"""Handle OAuth callback from Supabase."""
|
||||
if error:
|
||||
logger.error(f"OAuth error: {error} - {error_description}")
|
||||
return RedirectResponse(url=f"/login?error={error}", status_code=302)
|
||||
|
||||
# Supabase returns tokens in URL hash, not query params
|
||||
# We need to handle this client-side and redirect back
|
||||
# Check if we have the tokens
|
||||
if not access_token:
|
||||
# Render a page that extracts hash params and redirects
|
||||
return templates.TemplateResponse("auth_callback.html", {
|
||||
"request": request
|
||||
})
|
||||
|
||||
# We have the tokens, try to authenticate
|
||||
session = await handle_oauth_callback(access_token, refresh_token)
|
||||
|
||||
if not session:
|
||||
return RedirectResponse(url="/not-authorized", status_code=302)
|
||||
|
||||
# Success - set session and redirect to dashboard
|
||||
response = RedirectResponse(url="/", status_code=302)
|
||||
set_user_session(response, session)
|
||||
return response
|
||||
|
||||
|
||||
@user_router.get("/logout")
|
||||
async def logout(request: Request):
|
||||
"""Log out user."""
|
||||
response = RedirectResponse(url="/login", status_code=302)
|
||||
clear_user_session(response)
|
||||
return response
|
||||
|
||||
|
||||
@user_router.get("/not-authorized", response_class=HTMLResponse)
|
||||
async def not_authorized_page(request: Request):
|
||||
"""Page shown when user's LinkedIn profile doesn't match any customer."""
|
||||
return templates.TemplateResponse("not_authorized.html", {
|
||||
"request": request
|
||||
})
|
||||
|
||||
|
||||
# ==================== PROTECTED PAGES ====================
|
||||
|
||||
@user_router.get("/", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request):
|
||||
"""User dashboard - shows only their own stats."""
|
||||
session = require_user_session(request)
|
||||
if not session:
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
try:
|
||||
customer_id = UUID(session.customer_id)
|
||||
customer = await db.get_customer(customer_id)
|
||||
posts = await db.get_generated_posts(customer_id)
|
||||
profile_picture = session.linkedin_picture or await get_customer_profile_picture(customer_id)
|
||||
|
||||
return templates.TemplateResponse("dashboard.html", {
|
||||
"request": request,
|
||||
"page": "home",
|
||||
"session": session,
|
||||
"customer": customer,
|
||||
"total_posts": len(posts),
|
||||
"profile_picture": profile_picture
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading dashboard: {e}")
|
||||
return templates.TemplateResponse("dashboard.html", {
|
||||
"request": request,
|
||||
"page": "home",
|
||||
"session": session,
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
|
||||
@user_router.get("/posts", response_class=HTMLResponse)
|
||||
async def posts_page(request: Request):
|
||||
"""View user's own posts."""
|
||||
session = require_user_session(request)
|
||||
if not session:
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
try:
|
||||
customer_id = UUID(session.customer_id)
|
||||
customer = await db.get_customer(customer_id)
|
||||
posts = await db.get_generated_posts(customer_id)
|
||||
profile_picture = session.linkedin_picture or await get_customer_profile_picture(customer_id)
|
||||
|
||||
return templates.TemplateResponse("posts.html", {
|
||||
"request": request,
|
||||
"page": "posts",
|
||||
"session": session,
|
||||
"customer": customer,
|
||||
"posts": posts,
|
||||
"total_posts": len(posts),
|
||||
"profile_picture": profile_picture
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading posts: {e}")
|
||||
return templates.TemplateResponse("posts.html", {
|
||||
"request": request,
|
||||
"page": "posts",
|
||||
"session": session,
|
||||
"posts": [],
|
||||
"total_posts": 0,
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
|
||||
@user_router.get("/posts/{post_id}", response_class=HTMLResponse)
|
||||
async def post_detail_page(request: Request, post_id: str):
|
||||
"""Detailed view of a single post."""
|
||||
session = require_user_session(request)
|
||||
if not session:
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
try:
|
||||
post = await db.get_generated_post(UUID(post_id))
|
||||
if not post:
|
||||
return RedirectResponse(url="/posts", status_code=302)
|
||||
|
||||
# Verify user owns this post
|
||||
if str(post.customer_id) != session.customer_id:
|
||||
return RedirectResponse(url="/posts", status_code=302)
|
||||
|
||||
customer = await db.get_customer(post.customer_id)
|
||||
linkedin_posts = await db.get_linkedin_posts(post.customer_id)
|
||||
reference_posts = [p.post_text for p in linkedin_posts if p.post_text and len(p.post_text) > 100][:10]
|
||||
|
||||
profile_picture_url = session.linkedin_picture
|
||||
if not profile_picture_url:
|
||||
for lp in linkedin_posts:
|
||||
if lp.raw_data and isinstance(lp.raw_data, dict):
|
||||
author = lp.raw_data.get("author", {})
|
||||
if author and isinstance(author, dict):
|
||||
profile_picture_url = author.get("profile_picture")
|
||||
if profile_picture_url:
|
||||
break
|
||||
|
||||
profile_analysis_record = await db.get_profile_analysis(post.customer_id)
|
||||
profile_analysis = profile_analysis_record.full_analysis if profile_analysis_record else None
|
||||
|
||||
post_type = None
|
||||
post_type_analysis = None
|
||||
if post.post_type_id:
|
||||
post_type = await db.get_post_type(post.post_type_id)
|
||||
if post_type and post_type.analysis:
|
||||
post_type_analysis = post_type.analysis
|
||||
|
||||
final_feedback = None
|
||||
if post.critic_feedback and len(post.critic_feedback) > 0:
|
||||
final_feedback = post.critic_feedback[-1]
|
||||
|
||||
return templates.TemplateResponse("post_detail.html", {
|
||||
"request": request,
|
||||
"page": "posts",
|
||||
"session": session,
|
||||
"post": post,
|
||||
"customer": customer,
|
||||
"reference_posts": reference_posts,
|
||||
"profile_analysis": profile_analysis,
|
||||
"post_type": post_type,
|
||||
"post_type_analysis": post_type_analysis,
|
||||
"final_feedback": final_feedback,
|
||||
"profile_picture_url": profile_picture_url
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading post detail: {e}")
|
||||
return RedirectResponse(url="/posts", status_code=302)
|
||||
|
||||
|
||||
@user_router.get("/research", response_class=HTMLResponse)
|
||||
async def research_page(request: Request):
|
||||
"""Research topics page - no customer dropdown needed."""
|
||||
session = require_user_session(request)
|
||||
if not session:
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
return templates.TemplateResponse("research.html", {
|
||||
"request": request,
|
||||
"page": "research",
|
||||
"session": session,
|
||||
"customer_id": session.customer_id
|
||||
})
|
||||
|
||||
|
||||
@user_router.get("/create", response_class=HTMLResponse)
|
||||
async def create_post_page(request: Request):
|
||||
"""Create post page - no customer dropdown needed."""
|
||||
session = require_user_session(request)
|
||||
if not session:
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
return templates.TemplateResponse("create_post.html", {
|
||||
"request": request,
|
||||
"page": "create",
|
||||
"session": session,
|
||||
"customer_id": session.customer_id
|
||||
})
|
||||
|
||||
|
||||
@user_router.get("/status", response_class=HTMLResponse)
|
||||
async def status_page(request: Request):
|
||||
"""User's status page."""
|
||||
session = require_user_session(request)
|
||||
if not session:
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
try:
|
||||
customer_id = UUID(session.customer_id)
|
||||
customer = await db.get_customer(customer_id)
|
||||
status = await orchestrator.get_customer_status(customer_id)
|
||||
profile_picture = session.linkedin_picture or await get_customer_profile_picture(customer_id)
|
||||
|
||||
return templates.TemplateResponse("status.html", {
|
||||
"request": request,
|
||||
"page": "status",
|
||||
"session": session,
|
||||
"customer": customer,
|
||||
"status": status,
|
||||
"profile_picture": profile_picture
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading status: {e}")
|
||||
return templates.TemplateResponse("status.html", {
|
||||
"request": request,
|
||||
"page": "status",
|
||||
"session": session,
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
|
||||
# ==================== API ENDPOINTS ====================
|
||||
|
||||
@user_router.get("/api/post-types")
|
||||
async def get_post_types(request: Request):
|
||||
"""Get post types for the logged-in user's customer."""
|
||||
session = require_user_session(request)
|
||||
if not session:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
try:
|
||||
post_types = await db.get_post_types(UUID(session.customer_id))
|
||||
return {
|
||||
"post_types": [
|
||||
{
|
||||
"id": str(pt.id),
|
||||
"name": pt.name,
|
||||
"description": pt.description,
|
||||
"has_analysis": pt.analysis is not None,
|
||||
"analyzed_post_count": pt.analyzed_post_count,
|
||||
}
|
||||
for pt in post_types
|
||||
]
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading post types: {e}")
|
||||
return {"post_types": [], "error": str(e)}
|
||||
|
||||
|
||||
@user_router.get("/api/topics")
|
||||
async def get_topics(request: Request, post_type_id: str = None):
|
||||
"""Get research topics for the logged-in user."""
|
||||
session = require_user_session(request)
|
||||
if not session:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
try:
|
||||
customer_id = UUID(session.customer_id)
|
||||
if post_type_id:
|
||||
all_research = await db.get_all_research(customer_id, UUID(post_type_id))
|
||||
else:
|
||||
all_research = await db.get_all_research(customer_id)
|
||||
|
||||
# Get used topics
|
||||
generated_posts = await db.get_generated_posts(customer_id)
|
||||
used_topic_titles = set()
|
||||
for post in generated_posts:
|
||||
if post.topic_title:
|
||||
used_topic_titles.add(post.topic_title.lower().strip())
|
||||
|
||||
all_topics = []
|
||||
for research in all_research:
|
||||
if research.suggested_topics:
|
||||
for topic in research.suggested_topics:
|
||||
topic_title = topic.get("title", "").lower().strip()
|
||||
if topic_title in used_topic_titles:
|
||||
continue
|
||||
topic["research_id"] = str(research.id)
|
||||
topic["target_post_type_id"] = str(research.target_post_type_id) if research.target_post_type_id else None
|
||||
all_topics.append(topic)
|
||||
|
||||
return {"topics": all_topics, "used_count": len(used_topic_titles), "available_count": len(all_topics)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading topics: {e}")
|
||||
return {"topics": [], "error": str(e)}
|
||||
|
||||
|
||||
@user_router.get("/api/tasks/{task_id}")
|
||||
async def get_task_status(task_id: str):
|
||||
"""Get task progress."""
|
||||
return progress_store.get(task_id, {"status": "unknown", "message": "Task not found"})
|
||||
|
||||
|
||||
@user_router.post("/api/research")
|
||||
async def start_research(request: Request, background_tasks: BackgroundTasks, post_type_id: str = Form(None)):
|
||||
"""Start research for the logged-in user."""
|
||||
session = require_user_session(request)
|
||||
if not session:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
customer_id = session.customer_id
|
||||
task_id = f"research_{customer_id}_{asyncio.get_event_loop().time()}"
|
||||
progress_store[task_id] = {"status": "starting", "message": "Starte Recherche...", "progress": 0}
|
||||
|
||||
async def run_research():
|
||||
try:
|
||||
def progress_callback(message: str, step: int, total: int):
|
||||
progress_store[task_id] = {"status": "running", "message": message, "progress": int((step / total) * 100)}
|
||||
|
||||
topics = await orchestrator.research_new_topics(
|
||||
UUID(customer_id),
|
||||
progress_callback=progress_callback,
|
||||
post_type_id=UUID(post_type_id) if post_type_id else None
|
||||
)
|
||||
progress_store[task_id] = {"status": "completed", "message": f"{len(topics)} Topics gefunden!", "progress": 100, "topics": topics}
|
||||
except Exception as e:
|
||||
logger.exception(f"Research failed: {e}")
|
||||
progress_store[task_id] = {"status": "error", "message": str(e), "progress": 0}
|
||||
|
||||
background_tasks.add_task(run_research)
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
@user_router.post("/api/posts")
|
||||
async def create_post(request: Request, background_tasks: BackgroundTasks, topic_json: str = Form(...), post_type_id: str = Form(None)):
|
||||
"""Create a new post for the logged-in user."""
|
||||
session = require_user_session(request)
|
||||
if not session:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
customer_id = session.customer_id
|
||||
task_id = f"post_{customer_id}_{asyncio.get_event_loop().time()}"
|
||||
progress_store[task_id] = {"status": "starting", "message": "Starte Post-Erstellung...", "progress": 0}
|
||||
topic = json.loads(topic_json)
|
||||
|
||||
async def run_create_post():
|
||||
try:
|
||||
def progress_callback(message: str, iteration: int, max_iterations: int, score: int = None, versions: list = None, feedback_list: list = None):
|
||||
progress = int((iteration / max_iterations) * 100) if iteration > 0 else 5
|
||||
score_text = f" (Score: {score}/100)" if score else ""
|
||||
progress_store[task_id] = {
|
||||
"status": "running", "message": f"{message}{score_text}", "progress": progress,
|
||||
"iteration": iteration, "max_iterations": max_iterations,
|
||||
"versions": versions or [], "feedback_list": feedback_list or []
|
||||
}
|
||||
|
||||
result = await orchestrator.create_post(
|
||||
customer_id=UUID(customer_id), topic=topic, max_iterations=3,
|
||||
progress_callback=progress_callback,
|
||||
post_type_id=UUID(post_type_id) if post_type_id else None
|
||||
)
|
||||
progress_store[task_id] = {
|
||||
"status": "completed", "message": "Post erstellt!", "progress": 100,
|
||||
"result": {
|
||||
"post_id": str(result["post_id"]), "final_post": result["final_post"],
|
||||
"iterations": result["iterations"], "final_score": result["final_score"], "approved": result["approved"]
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception(f"Post creation failed: {e}")
|
||||
progress_store[task_id] = {"status": "error", "message": str(e), "progress": 0}
|
||||
|
||||
background_tasks.add_task(run_create_post)
|
||||
return {"task_id": task_id}
|
||||
Reference in New Issue
Block a user