Major updates: LinkedIn auto-posting, timezone fixes, and Docker improvements

Features:
- Add LinkedIn OAuth integration and auto-posting functionality
- Add scheduler service for automated post publishing
- Add metadata field to generated_posts for LinkedIn URLs
- Add privacy policy page for LinkedIn API compliance
- Add company management features and employee accounts
- Add license key system for company registrations

Fixes:
- Fix timezone issues (use UTC consistently across app)
- Fix datetime serialization errors in database operations
- Fix scheduling timezone conversion (local time to UTC)
- Fix import errors (get_database -> db)

Infrastructure:
- Update Docker setup to use port 8001 (avoid conflicts)
- Add SSL support with nginx-proxy and Let's Encrypt
- Add LinkedIn setup documentation
- Add migration scripts for schema updates

Services:
- Add linkedin_service.py for LinkedIn API integration
- Add scheduler_service.py for background job processing
- Add storage_service.py for Supabase Storage
- Add email_service.py improvements
- Add encryption utilities for token storage

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 11:30:20 +01:00
parent b50594dbfa
commit f14515e9cf
94 changed files with 21601 additions and 5111 deletions

View File

@@ -1,12 +1,13 @@
"""Base agent class."""
import asyncio
from abc import ABC, abstractmethod
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional, Tuple
from openai import OpenAI
import httpx
from loguru import logger
from src.config import settings
from src.config import settings, estimate_cost
from src.database.client import db
class BaseAgent(ABC):
@@ -21,13 +22,59 @@ class BaseAgent(ABC):
"""
self.name = name
self.openai_client = OpenAI(api_key=settings.openai_api_key)
self._usage_logs: List[Dict[str, Any]] = []
self._user_id: Optional[str] = None
self._company_id: Optional[str] = None
self._operation: str = "unknown"
logger.info(f"Initialized {name} agent")
def set_tracking_context(
self,
operation: str,
user_id: Optional[str] = None,
company_id: Optional[str] = None
):
"""Set context for usage tracking."""
self._operation = operation
self._user_id = user_id
self._company_id = company_id
self._usage_logs = []
@abstractmethod
async def process(self, *args, **kwargs) -> Any:
"""Process the agent's task."""
pass
async def _log_usage(self, provider: str, model: str, prompt_tokens: int, completion_tokens: int, total_tokens: int):
"""Log API usage to database."""
cost = estimate_cost(model, prompt_tokens, completion_tokens)
usage = {
"provider": provider,
"model": model,
"operation": self._operation,
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
"estimated_cost_usd": cost
}
self._usage_logs.append(usage)
try:
from uuid import UUID
await db.log_api_usage(
provider=provider,
model=model,
operation=self._operation,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=total_tokens,
estimated_cost_usd=cost,
user_id=UUID(self._user_id) if self._user_id else None,
company_id=UUID(self._company_id) if self._company_id else None
)
except Exception as e:
logger.warning(f"Failed to log usage to DB: {e}")
async def call_openai(
self,
system_prompt: str,
@@ -74,6 +121,16 @@ class BaseAgent(ABC):
result = response.choices[0].message.content
logger.debug(f"[{self.name}] Received response (length: {len(result)})")
# Track usage
if response.usage:
await self._log_usage(
provider="openai",
model=model,
prompt_tokens=response.usage.prompt_tokens,
completion_tokens=response.usage.completion_tokens,
total_tokens=response.usage.total_tokens
)
return result
async def call_perplexity(
@@ -117,4 +174,15 @@ class BaseAgent(ABC):
content = result["choices"][0]["message"]["content"]
logger.debug(f"[{self.name}] Received Perplexity response (length: {len(content)})")
# Track usage
usage = result.get("usage", {})
if usage:
await self._log_usage(
provider="perplexity",
model=model,
prompt_tokens=usage.get("prompt_tokens", 0),
completion_tokens=usage.get("completion_tokens", 0),
total_tokens=usage.get("prompt_tokens", 0) + usage.get("completion_tokens", 0)
)
return content

View File

@@ -1,7 +1,7 @@
"""Research agent using Perplexity."""
import json
import random
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import Dict, Any, List
from loguru import logger
@@ -258,7 +258,7 @@ Gib deine Antwort im folgenden JSON-Format zurück:
pain_points_text = ", ".join(pain_points) if pain_points else "Allgemeine Business-Probleme"
# Current date for time-specific searches
today = datetime.now()
today = datetime.now(timezone.utc)
date_str = today.strftime("%d. %B %Y")
week_ago = (today - timedelta(days=7)).strftime("%d. %B %Y")

View File

@@ -27,7 +27,10 @@ class WriterAgent(BaseAgent):
critic_result: Optional[Dict[str, Any]] = None,
learned_lessons: Optional[Dict[str, Any]] = None,
post_type: Any = None,
post_type_analysis: Optional[Dict[str, Any]] = None
post_type_analysis: Optional[Dict[str, Any]] = None,
user_thoughts: str = "",
selected_hook: str = "",
company_strategy: Optional[Dict[str, Any]] = None
) -> str:
"""
Write a LinkedIn post.
@@ -42,6 +45,7 @@ class WriterAgent(BaseAgent):
learned_lessons: Optional lessons learned from past critic feedback
post_type: Optional PostType object for type-specific writing
post_type_analysis: Optional analysis of the post type
company_strategy: Optional company strategy to consider during writing
Returns:
Written LinkedIn post
@@ -58,12 +62,19 @@ class WriterAgent(BaseAgent):
critic_result=critic_result,
learned_lessons=learned_lessons,
post_type=post_type,
post_type_analysis=post_type_analysis
post_type_analysis=post_type_analysis,
user_thoughts=user_thoughts,
selected_hook=selected_hook,
company_strategy=company_strategy
)
else:
logger.info(f"Writing initial post for topic: {topic.get('title', 'Unknown')}")
if post_type:
logger.info(f"Using post type: {post_type.name}")
if user_thoughts:
logger.info(f"Including user thoughts ({len(user_thoughts)} chars)")
if selected_hook:
logger.info(f"Using selected hook: {selected_hook[:50]}...")
# Select example posts - use semantic matching if enabled
selected_examples = self._select_example_posts(topic, example_posts, profile_analysis)
@@ -76,7 +87,10 @@ class WriterAgent(BaseAgent):
example_posts=selected_examples,
learned_lessons=learned_lessons,
post_type=post_type,
post_type_analysis=post_type_analysis
post_type_analysis=post_type_analysis,
user_thoughts=user_thoughts,
selected_hook=selected_hook,
company_strategy=company_strategy
)
else:
return await self._write_single_draft(
@@ -85,7 +99,10 @@ class WriterAgent(BaseAgent):
example_posts=selected_examples,
learned_lessons=learned_lessons,
post_type=post_type,
post_type_analysis=post_type_analysis
post_type_analysis=post_type_analysis,
user_thoughts=user_thoughts,
selected_hook=selected_hook,
company_strategy=company_strategy
)
def _select_example_posts(
@@ -164,10 +181,14 @@ class WriterAgent(BaseAgent):
# If we still don't have enough, fill with top scored
while len(selected) < 3 and len(selected) < len(example_posts):
found = False
for item in scored_posts:
if item["post"] not in selected:
selected.append(item["post"])
found = True
break
if not found:
break # no more unique posts available
logger.info(f"Selected {len(selected)} example posts via semantic matching")
return selected
@@ -212,7 +233,10 @@ class WriterAgent(BaseAgent):
example_posts: List[str],
learned_lessons: Optional[Dict[str, Any]] = None,
post_type: Any = None,
post_type_analysis: Optional[Dict[str, Any]] = None
post_type_analysis: Optional[Dict[str, Any]] = None,
user_thoughts: str = "",
selected_hook: str = "",
company_strategy: Optional[Dict[str, Any]] = None
) -> str:
"""
Generate multiple drafts and select the best one.
@@ -224,6 +248,7 @@ class WriterAgent(BaseAgent):
learned_lessons: Lessons learned from past feedback
post_type: Optional PostType object
post_type_analysis: Optional post type analysis
company_strategy: Optional company strategy to consider
Returns:
Best selected draft
@@ -231,7 +256,7 @@ class WriterAgent(BaseAgent):
num_drafts = min(max(settings.writer_multi_draft_count, 2), 5) # Clamp between 2-5
logger.info(f"Generating {num_drafts} drafts for selection")
system_prompt = self._get_system_prompt(profile_analysis, example_posts, learned_lessons, post_type, post_type_analysis)
system_prompt = self._get_system_prompt(profile_analysis, example_posts, learned_lessons, post_type, post_type_analysis, company_strategy)
# Generate drafts in parallel with different temperatures/approaches
draft_configs = [
@@ -244,7 +269,7 @@ class WriterAgent(BaseAgent):
# Create draft tasks
async def generate_draft(config: Dict, draft_num: int) -> Dict[str, Any]:
user_prompt = self._get_user_prompt_for_draft(topic, draft_num, config["approach"])
user_prompt = self._get_user_prompt_for_draft(topic, draft_num, config["approach"], user_thoughts, selected_hook)
try:
draft = await self.call_openai(
system_prompt=system_prompt,
@@ -286,7 +311,9 @@ class WriterAgent(BaseAgent):
self,
topic: Dict[str, Any],
draft_num: int,
approach: str
approach: str,
user_thoughts: str = "",
selected_hook: str = ""
) -> str:
"""Get user prompt with slight variations for different drafts."""
# Different emphasis for each draft
@@ -305,8 +332,11 @@ class WriterAgent(BaseAgent):
if topic.get('angle'):
angle_section = f"\n**ANGLE/PERSPEKTIVE:**\n{topic.get('angle')}\n"
# User-selected hook takes priority
hook_section = ""
if topic.get('hook_idea'):
if selected_hook:
hook_section = f"\n**HOOK (VERWENDE DIESEN ALS EINSTIEG!):**\n\"{selected_hook}\"\n"
elif topic.get('hook_idea'):
hook_section = f"\n**HOOK-IDEE (als Inspiration):**\n\"{topic.get('hook_idea')}\"\n"
facts_section = ""
@@ -318,6 +348,34 @@ class WriterAgent(BaseAgent):
if topic.get('why_this_person'):
why_section = f"\n**WARUM DU DARÜBER SCHREIBEN SOLLTEST:**\n{topic.get('why_this_person')}\n"
# User thoughts section
thoughts_section = ""
if user_thoughts:
thoughts_section = f"""
**PERSÖNLICHE GEDANKEN (UNBEDINGT einbauen!):**
{user_thoughts}
Diese Gedanken MÜSSEN im Post verarbeitet werden!
"""
# Adjust task based on hook
if selected_hook:
task_text = """**AUFGABE:**
Schreibe einen authentischen LinkedIn-Post, der:
1. Mit dem VORGEGEBENEN HOOK beginnt (exakt oder leicht angepasst)
2. Den Fakt/das Thema aufgreift und Mehrwert bietet
3. Die persönlichen Gedanken organisch einbaut
4. Die Key Facts verwendet wo es passt
5. Mit einem passenden CTA endet"""
else:
task_text = """**AUFGABE:**
Schreibe einen authentischen LinkedIn-Post, der:
1. Mit einem STARKEN, unerwarteten Hook beginnt (nutze die Hook-Idee als Inspiration, NICHT wörtlich!)
2. Den Fakt/das Thema aufgreift und Mehrwert bietet
3. Die persönlichen Gedanken organisch einbaut (falls vorhanden)
4. Die Key Facts einbaut wo es passt
5. Mit einem passenden CTA endet"""
return f"""Schreibe einen LinkedIn-Post zu folgendem Thema:
**THEMA:** {topic.get('title', 'Unbekanntes Thema')}
@@ -326,26 +384,19 @@ class WriterAgent(BaseAgent):
{angle_section}{hook_section}
**KERN-FAKT / INHALT:**
{topic.get('fact', topic.get('description', ''))}
{facts_section}
{facts_section}{thoughts_section}
**WARUM RELEVANT:**
{topic.get('relevance', 'Aktuelles Thema für die Zielgruppe')}
{why_section}
**DEIN ANSATZ FÜR DIESEN ENTWURF ({approach}):**
{emphasis}
**AUFGABE:**
Schreibe einen authentischen LinkedIn-Post, der:
1. Mit einem STARKEN, unerwarteten Hook beginnt (nutze die Hook-Idee als Inspiration, NICHT wörtlich!)
2. Den Fakt/das Thema aufgreift und Mehrwert bietet
3. Die Key Facts einbaut wo es passt
4. Eine persönliche Note oder Meinung enthält
5. Mit einem passenden CTA endet
{task_text}
WICHTIG:
- Vermeide KI-typische Formulierungen ("In der heutigen Zeit", "Tauchen Sie ein", etc.)
- Schreibe natürlich und menschlich
- Der Post soll SOFORT 85+ Punkte im Review erreichen
- Die Hook-Idee ist nur INSPIRATION - mach etwas Eigenes daraus!
Gib NUR den fertigen Post zurück."""
@@ -446,7 +497,10 @@ Analysiere jeden Entwurf kurz und wähle den besten. Antworte im JSON-Format:
critic_result: Optional[Dict[str, Any]] = None,
learned_lessons: Optional[Dict[str, Any]] = None,
post_type: Any = None,
post_type_analysis: Optional[Dict[str, Any]] = None
post_type_analysis: Optional[Dict[str, Any]] = None,
user_thoughts: str = "",
selected_hook: str = "",
company_strategy: Optional[Dict[str, Any]] = None
) -> str:
"""Write a single draft (original behavior)."""
# Select examples if not already selected
@@ -461,8 +515,8 @@ Analysiere jeden Entwurf kurz und wähle den besten. Antworte im JSON-Format:
elif len(selected_examples) > 3:
selected_examples = random.sample(selected_examples, 3)
system_prompt = self._get_system_prompt(profile_analysis, selected_examples, learned_lessons, post_type, post_type_analysis)
user_prompt = self._get_user_prompt(topic, feedback, previous_version, critic_result)
system_prompt = self._get_system_prompt(profile_analysis, selected_examples, learned_lessons, post_type, post_type_analysis, company_strategy)
user_prompt = self._get_user_prompt(topic, feedback, previous_version, critic_result, user_thoughts, selected_hook)
# Lower temperature for more consistent style matching
post = await self.call_openai(
@@ -481,7 +535,8 @@ Analysiere jeden Entwurf kurz und wähle den besten. Antworte im JSON-Format:
example_posts: List[str] = None,
learned_lessons: Optional[Dict[str, Any]] = None,
post_type: Any = None,
post_type_analysis: Optional[Dict[str, Any]] = None
post_type_analysis: Optional[Dict[str, Any]] = None,
company_strategy: Optional[Dict[str, Any]] = None
) -> str:
"""Get system prompt for writer - orientiert an bewährten n8n-Prompts."""
# Extract key profile information
@@ -670,16 +725,65 @@ Vermeide IMMER diese KI-typischen Muster:
- Zu perfekte, glatte Formulierungen - echte Menschen schreiben mit Ecken und Kanten
{lessons_section}
{post_type_section}
{self._get_company_strategy_section(company_strategy)}
DEIN AUFTRAG: Schreibe den Post so, dass er für die Zielgruppe ({audience.get('target_audience', 'Professionals')}) einen klaren Mehrwert bietet und ihre Pain Points ({pain_points_str}) adressiert. Mach die Persönlichkeit des linguistischen Fingerabdrucks spürbar.
Beginne DIREKT mit dem Hook. Keine einleitenden Sätze, kein "Hier ist der Post"."""
def _get_company_strategy_section(self, company_strategy: Optional[Dict[str, Any]] = None) -> str:
"""Build the company strategy section for the system prompt."""
if not company_strategy:
return ""
# Extract strategy elements
mission = company_strategy.get("mission", "")
vision = company_strategy.get("vision", "")
brand_voice = company_strategy.get("brand_voice", "")
tone_guidelines = company_strategy.get("tone_guidelines", "")
content_pillars = company_strategy.get("content_pillars", [])
target_audience = company_strategy.get("target_audience", "")
dos = company_strategy.get("dos", [])
donts = company_strategy.get("donts", [])
# Build section only if there's meaningful content
if not any([mission, vision, brand_voice, content_pillars, dos, donts]):
return ""
section = """
8. UNTERNEHMENSSTRATEGIE (WICHTIG - IMMER BEACHTEN!):
Der Post muss zur Unternehmensstrategie passen, aber authentisch im Stil des Autors bleiben!
"""
if mission:
section += f"\nMISSION: {mission}"
if vision:
section += f"\nVISION: {vision}"
if brand_voice:
section += f"\nBRAND VOICE: {brand_voice}"
if tone_guidelines:
section += f"\nTONE GUIDELINES: {tone_guidelines}"
if content_pillars:
section += f"\nCONTENT PILLARS: {', '.join(content_pillars)}"
if target_audience:
section += f"\nZIELGRUPPE: {target_audience}"
if dos:
section += f"\n\nDO's (empfohlen):\n" + "\n".join([f" + {d}" for d in dos])
if donts:
section += f"\n\nDON'Ts (vermeiden):\n" + "\n".join([f" - {d}" for d in donts])
section += "\n\nWICHTIG: Integriere die Unternehmenswerte subtil - der Post soll nicht wie eine Werbung klingen!"
return section
def _get_user_prompt(
self,
topic: Dict[str, Any],
feedback: Optional[str] = None,
previous_version: Optional[str] = None,
critic_result: Optional[Dict[str, Any]] = None
critic_result: Optional[Dict[str, Any]] = None,
user_thoughts: str = "",
selected_hook: str = ""
) -> str:
"""Get user prompt for writer."""
if feedback and previous_version:
@@ -727,8 +831,11 @@ Gib NUR den überarbeiteten Post zurück - keine Kommentare."""
if topic.get('angle'):
angle_section = f"\n**ANGLE/PERSPEKTIVE:**\n{topic.get('angle')}\n"
# User-selected hook takes priority over topic hook_idea
hook_section = ""
if topic.get('hook_idea'):
if selected_hook:
hook_section = f"\n**HOOK (VERWENDE DIESEN ALS EINSTIEG!):**\n\"{selected_hook}\"\n"
elif topic.get('hook_idea'):
hook_section = f"\n**HOOK-IDEE (als Inspiration):**\n\"{topic.get('hook_idea')}\"\n"
facts_section = ""
@@ -736,6 +843,34 @@ Gib NUR den überarbeiteten Post zurück - keine Kommentare."""
if key_facts and isinstance(key_facts, list) and len(key_facts) > 0:
facts_section = "\n**KEY FACTS (nutze diese!):**\n" + "\n".join([f"- {f}" for f in key_facts]) + "\n"
# User thoughts section
thoughts_section = ""
if user_thoughts:
thoughts_section = f"""
**PERSÖNLICHE GEDANKEN (UNBEDINGT einbauen!):**
{user_thoughts}
Diese Gedanken MÜSSEN im Post verarbeitet werden - sie spiegeln die echte Meinung der Person wider!
"""
# Adjust task based on whether hook is provided
if selected_hook:
task_section = """**AUFGABE:**
Schreibe einen authentischen LinkedIn-Post, der:
1. Mit dem VORGEGEBENEN HOOK beginnt (exakt oder leicht angepasst)
2. Den Fakt/das Thema aufgreift und Mehrwert bietet
3. Die persönlichen Gedanken organisch einbaut
4. Die Key Facts verwendet wo es passt
5. Mit einem passenden CTA endet"""
else:
task_section = """**AUFGABE:**
Schreibe einen authentischen LinkedIn-Post, der:
1. Mit einem STARKEN, unerwarteten Hook beginnt (nutze Hook-Idee als Inspiration!)
2. Den Fakt/das Thema aufgreift und Mehrwert bietet
3. Die persönlichen Gedanken organisch einbaut (falls vorhanden)
4. Die Key Facts verwendet wo es passt
5. Mit einem passenden CTA endet"""
return f"""Schreibe einen LinkedIn-Post zu folgendem Thema:
**THEMA:** {topic.get('title', 'Unbekanntes Thema')}
@@ -744,17 +879,11 @@ Gib NUR den überarbeiteten Post zurück - keine Kommentare."""
{angle_section}{hook_section}
**KERN-FAKT / INHALT:**
{topic.get('fact', topic.get('description', ''))}
{facts_section}
{facts_section}{thoughts_section}
**WARUM RELEVANT:**
{topic.get('relevance', 'Aktuelles Thema für die Zielgruppe')}
**AUFGABE:**
Schreibe einen authentischen LinkedIn-Post, der:
1. Mit einem STARKEN, unerwarteten Hook beginnt (nutze Hook-Idee als Inspiration!)
2. Den Fakt/das Thema aufgreift und Mehrwert bietet
3. Die Key Facts einbaut wo es passt
4. Eine persönliche Note oder Meinung enthält
5. Mit einem passenden CTA endet
{task_section}
WICHTIG:
- Vermeide KI-typische Formulierungen ("In der heutigen Zeit", "Tauchen Sie ein", etc.)
@@ -762,3 +891,263 @@ WICHTIG:
- Der Post soll SOFORT 85+ Punkte im Review erreichen
Gib NUR den fertigen Post zurück."""
async def generate_hooks(
self,
topic: Dict[str, Any],
profile_analysis: Dict[str, Any],
user_thoughts: str = "",
post_type: Any = None
) -> List[Dict[str, str]]:
"""
Generate 4 different hook options for a topic.
Args:
topic: Topic dictionary with title, fact, etc.
profile_analysis: Profile analysis for style matching
user_thoughts: User's personal thoughts about the topic
post_type: Optional PostType object
Returns:
List of {"hook": "...", "style": "..."} dictionaries
"""
logger.info(f"Generating hooks for topic: {topic.get('title', 'Unknown')}")
# Extract style info from profile
tone = profile_analysis.get('tone_analysis', {}).get('primary_tone', 'Professionell')
energy = profile_analysis.get('linguistic_fingerprint', {}).get('energy_level', 7)
address = profile_analysis.get('writing_style', {}).get('form_of_address', 'Du')
hook_phrases = profile_analysis.get('phrase_library', {}).get('hook_phrases', [])
# Build example hooks section
example_hooks = ""
if hook_phrases:
example_hooks = "\n\nBeispiel-Hooks dieser Person (zur Inspiration):\n" + "\n".join([f"- {h}" for h in hook_phrases[:5]])
system_prompt = f"""Du bist ein Hook-Spezialist für LinkedIn Posts. Deine Aufgabe ist es, 4 verschiedene, aufmerksamkeitsstarke Hooks zu generieren.
STIL DER PERSON:
- Tonalität: {tone}
- Energie-Level: {energy}/10 (höher = emotionaler, niedriger = sachlicher)
- Ansprache: {address}
{example_hooks}
GENERIERE 4 VERSCHIEDENE HOOKS:
1. **Provokant** - Eine kontroverse These oder überraschende Aussage
2. **Storytelling** - Beginn einer persönlichen Geschichte oder Anekdote
3. **Fakten-basiert** - Eine überraschende Statistik oder Fakt
4. **Neugier-weckend** - Eine Frage oder ein Cliffhanger
REGELN:
- Jeder Hook sollte 1-2 Sätze lang sein
- Hooks müssen zum Energie-Level und Ton der Person passen
- Keine KI-typischen Phrasen ("In der heutigen Zeit", "Stellen Sie sich vor")
- Die Hooks sollen SOFORT Aufmerksamkeit erregen
- Bei Energie 8+ darf es emotional/leidenschaftlich sein
- Bei Energie 5-7 eher sachlich-professionell
Antworte im JSON-Format:
{{"hooks": [
{{"hook": "Der Hook-Text hier", "style": "Provokant"}},
{{"hook": "Der Hook-Text hier", "style": "Storytelling"}},
{{"hook": "Der Hook-Text hier", "style": "Fakten-basiert"}},
{{"hook": "Der Hook-Text hier", "style": "Neugier-weckend"}}
]}}"""
# Build user prompt
thoughts_section = ""
if user_thoughts:
thoughts_section = f"\n\nPERSÖNLICHE GEDANKEN DER PERSON ZUM THEMA:\n{user_thoughts}\n\nIntegriere diese Perspektive in die Hooks!"
post_type_section = ""
if post_type:
post_type_section = f"\n\nPOST-TYP: {post_type.name}"
if post_type.description:
post_type_section += f"\n{post_type.description}"
user_prompt = f"""Generiere 4 Hooks für dieses Thema:
THEMA: {topic.get('title', 'Unbekanntes Thema')}
KATEGORIE: {topic.get('category', 'Allgemein')}
KERN-FAKT/INHALT:
{topic.get('fact', topic.get('description', 'Keine Details verfügbar'))}
{thoughts_section}{post_type_section}
Generiere jetzt die 4 verschiedenen Hooks im JSON-Format."""
response = await self.call_openai(
system_prompt=system_prompt,
user_prompt=user_prompt,
model="gpt-4o-mini",
temperature=0.8,
response_format={"type": "json_object"}
)
try:
result = json.loads(response)
hooks = result.get("hooks", [])
logger.info(f"Generated {len(hooks)} hooks successfully")
return hooks
except (json.JSONDecodeError, KeyError) as e:
logger.error(f"Failed to parse hooks response: {e}")
# Return fallback hooks
return [
{"hook": f"Was wäre, wenn {topic.get('title', 'dieses Thema')} alles verändert?", "style": "Neugier-weckend"},
{"hook": f"Letzte Woche habe ich etwas über {topic.get('title', 'dieses Thema')} gelernt, das mich nicht mehr loslässt.", "style": "Storytelling"},
{"hook": f"Die meisten unterschätzen {topic.get('title', 'dieses Thema')}. Ein Fehler.", "style": "Provokant"},
{"hook": f"Eine Zahl hat mich diese Woche überrascht: {topic.get('fact', 'Ein überraschender Fakt')}.", "style": "Fakten-basiert"}
]
async def generate_improvement_suggestions(
self,
post_content: str,
profile_analysis: Dict[str, Any],
critic_feedback: Optional[Dict[str, Any]] = None
) -> List[Dict[str, str]]:
"""
Generate improvement suggestions for an existing post based on critic feedback.
Args:
post_content: The current post content
profile_analysis: Profile analysis for style matching
critic_feedback: Optional feedback from the critic with improvements list
Returns:
List of {"label": "...", "action": "..."} dictionaries
"""
logger.info("Generating improvement suggestions for post")
# Extract style info from profile
tone = profile_analysis.get('tone_analysis', {}).get('primary_tone', 'Professionell')
energy = profile_analysis.get('linguistic_fingerprint', {}).get('energy_level', 7)
address = profile_analysis.get('writing_style', {}).get('form_of_address', 'Du')
# Build feedback context
feedback_context = ""
if critic_feedback:
feedback_text = critic_feedback.get('feedback', '')
improvements = critic_feedback.get('improvements', [])
strengths = critic_feedback.get('strengths', [])
score = critic_feedback.get('overall_score', 0)
feedback_context = f"""
LETZTES KRITIKER-FEEDBACK (Score: {score}/100):
{feedback_text}
IDENTIFIZIERTE VERBESSERUNGSPUNKTE:
{chr(10).join(['- ' + imp for imp in improvements]) if improvements else '- Keine spezifischen Verbesserungen genannt'}
STÄRKEN (diese beibehalten!):
{chr(10).join(['- ' + s for s in strengths]) if strengths else '- Keine spezifischen Stärken genannt'}
"""
system_prompt = f"""Du generierst kurze, klickbare Verbesserungsvorschläge für LinkedIn-Posts.
PROFIL DER PERSON:
- Tonalität: {tone}
- Energie: {energy}/10
- Ansprache: {address}
{feedback_context}
DEINE AUFGABE:
Generiere 4 KURZE Verbesserungsvorschläge (max 5-6 Worte pro Label).
Jeder Vorschlag muss:
1. Auf dem Kritiker-Feedback basieren (falls vorhanden)
2. Zum Stil der Person passen
3. Eine konkrete, umsetzbare Änderung beschreiben
FORMAT - Kurze Labels wie:
- "Hook emotionaler machen"
- "Persönliche Anekdote einbauen"
- "Absätze kürzen"
- "Call-to-Action verstärken"
Antworte im JSON-Format:
{{"suggestions": [
{{"label": "Kurzes Label (max 6 Worte)", "action": "Detaillierte Anweisung für die KI, was genau geändert werden soll"}},
...
]}}"""
user_prompt = f"""POST:
{post_content[:1500]}
Generiere 4 kurze, spezifische Verbesserungsvorschläge basierend auf dem Feedback und Profil."""
response = await self.call_openai(
system_prompt=system_prompt,
user_prompt=user_prompt,
model="gpt-4o-mini",
temperature=0.7,
response_format={"type": "json_object"}
)
try:
result = json.loads(response)
suggestions = result.get("suggestions", [])
logger.info(f"Generated {len(suggestions)} improvement suggestions")
return suggestions
except (json.JSONDecodeError, KeyError) as e:
logger.error(f"Failed to parse suggestions response: {e}")
return [
{"label": "Hook verstärken", "action": "Mache den ersten Satz emotionaler und aufmerksamkeitsstärker"},
{"label": "Storytelling einbauen", "action": "Füge eine kurze persönliche Anekdote oder Erfahrung hinzu"},
{"label": "Call-to-Action ergänzen", "action": "Füge am Ende eine Frage oder Handlungsaufforderung hinzu"},
{"label": "Struktur verbessern", "action": "Kürze lange Absätze und verbessere die Lesbarkeit"}
]
async def apply_suggestion(
self,
post_content: str,
suggestion: str,
profile_analysis: Dict[str, Any]
) -> str:
"""
Apply a specific improvement suggestion to a post.
Args:
post_content: The current post content
suggestion: The suggestion to apply
profile_analysis: Profile analysis for style matching
Returns:
The improved post content
"""
logger.info(f"Applying suggestion to post: {suggestion[:50]}...")
# Extract style info from profile
tone = profile_analysis.get('tone_analysis', {}).get('primary_tone', 'Professionell')
energy = profile_analysis.get('linguistic_fingerprint', {}).get('energy_level', 7)
address = profile_analysis.get('writing_style', {}).get('form_of_address', 'Du')
system_prompt = f"""Du bist ein LinkedIn-Experte. Deine Aufgabe ist es, einen Post basierend auf einem konkreten Verbesserungsvorschlag zu überarbeiten.
STIL DER PERSON:
- Tonalität: {tone}
- Energie-Level: {energy}/10
- Ansprache: {address}
WICHTIG:
- Behalte den Kern und die Hauptaussage des Posts bei
- Wende NUR die angegebene Verbesserung an
- Behalte die Länge ungefähr bei
- Antworte NUR mit dem überarbeiteten Post, keine Erklärungen"""
user_prompt = f"""ORIGINAL-POST:
{post_content}
ANZUWENDENDE VERBESSERUNG:
{suggestion}
Schreibe jetzt den überarbeiteten Post:"""
response = await self.call_openai(
system_prompt=system_prompt,
user_prompt=user_prompt,
model="gpt-4o-mini",
temperature=0.5
)
logger.info("Successfully applied suggestion to post")
return response.strip()

View File

@@ -6,7 +6,6 @@ from pathlib import Path
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
# API Keys
openai_api_key: str
perplexity_api_key: str
@@ -15,6 +14,7 @@ class Settings(BaseSettings):
# Supabase
supabase_url: str
supabase_key: str
supabase_service_role_key: str = "" # Required for admin operations like deleting users
# Apify
apify_actor_id: str = "apimaestro~linkedin-profile-posts"
@@ -46,6 +46,14 @@ class Settings(BaseSettings):
user_frontend_enabled: bool = True # Enable user frontend with LinkedIn OAuth
supabase_redirect_url: str = "" # OAuth Callback URL (e.g., https://linkedin.onyva.dev/auth/callback)
# LinkedIn API (Custom OAuth for auto-posting)
linkedin_client_id: str = ""
linkedin_client_secret: str = ""
linkedin_redirect_uri: str = "" # e.g., https://yourdomain.com/settings/linkedin/callback
# Token Encryption
encryption_key: str = "" # Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
@@ -55,3 +63,18 @@ class Settings(BaseSettings):
# Global settings instance
settings = Settings()
# API pricing per 1M tokens (input, output)
API_PRICING = {
"gpt-4o": {"input": 2.50, "output": 10.00},
"gpt-4o-mini": {"input": 0.15, "output": 0.60},
"sonar": {"input": 1.00, "output": 1.00},
}
def estimate_cost(model: str, prompt_tokens: int, completion_tokens: int) -> float:
"""Estimate cost in USD for an API call."""
pricing = API_PRICING.get(model, {"input": 1.00, "output": 1.00})
input_cost = (prompt_tokens / 1_000_000) * pricing["input"]
output_cost = (completion_tokens / 1_000_000) * pricing["output"]
return input_cost + output_cost

View File

@@ -1,7 +1,6 @@
"""Database module."""
from src.database.client import DatabaseClient, db
from src.database.models import (
Customer,
LinkedInProfile,
LinkedInPost,
Topic,
@@ -9,12 +8,24 @@ from src.database.models import (
ResearchResult,
GeneratedPost,
PostType,
User,
Profile,
Company,
Invitation,
ExamplePost,
ReferenceProfile,
AccountType,
OnboardingStatus,
AuthMethod,
InvitationStatus,
ApiUsageLog,
LicenseKey,
CompanyDailyQuota,
)
__all__ = [
"DatabaseClient",
"db",
"Customer",
"LinkedInProfile",
"LinkedInPost",
"Topic",
@@ -22,4 +33,17 @@ __all__ = [
"ResearchResult",
"GeneratedPost",
"PostType",
"User",
"Profile",
"Company",
"Invitation",
"ExamplePost",
"ReferenceProfile",
"AccountType",
"OnboardingStatus",
"AuthMethod",
"InvitationStatus",
"ApiUsageLog",
"LicenseKey",
"CompanyDailyQuota",
]

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
"""Pydantic models for database entities."""
from datetime import datetime
from datetime import datetime, date
from enum import Enum
from typing import Optional, Dict, Any, List
from uuid import UUID
from pydantic import BaseModel, Field, ConfigDict
@@ -10,22 +11,210 @@ class DBModel(BaseModel):
model_config = ConfigDict(extra='ignore')
class Customer(DBModel):
"""Customer/Client model."""
# ==================== ENUMS ====================
class AccountType(str, Enum):
"""User account type."""
GHOSTWRITER = "ghostwriter"
COMPANY = "company"
EMPLOYEE = "employee"
class OnboardingStatus(str, Enum):
"""Onboarding status for users."""
PENDING = "pending"
PROFILE_SETUP = "profile_setup"
POSTS_SCRAPED = "posts_scraped"
CATEGORIZING = "categorizing"
COMPLETED = "completed"
class AuthMethod(str, Enum):
"""Authentication method for users."""
LINKEDIN_OAUTH = "linkedin_oauth"
EMAIL_PASSWORD = "email_password"
class InvitationStatus(str, Enum):
"""Status of an invitation."""
PENDING = "pending"
ACCEPTED = "accepted"
EXPIRED = "expired"
CANCELLED = "cancelled"
# ==================== USER & COMPANY MODELS ====================
class Profile(DBModel):
"""Profile model - extends auth.users with app-specific data.
The id is the same as auth.users.id (Supabase handles authentication).
"""
id: Optional[UUID] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
name: str
email: Optional[str] = None
company_name: Optional[str] = None
linkedin_url: str
# Account Type
account_type: AccountType = AccountType.GHOSTWRITER
# Display name
display_name: Optional[str] = None
# Onboarding
onboarding_status: OnboardingStatus = OnboardingStatus.PENDING
onboarding_data: Dict[str, Any] = Field(default_factory=dict)
# Links
company_id: Optional[UUID] = None
# Fields migrated from customers table
linkedin_url: Optional[str] = None
writing_style_notes: Optional[str] = None
metadata: Dict[str, Any] = Field(default_factory=dict)
profile_picture: Optional[str] = None
creator_email: Optional[str] = None
customer_email: Optional[str] = None
is_active: bool = True
class LinkedInAccount(DBModel):
"""LinkedIn account connection for auto-posting."""
id: Optional[UUID] = None
user_id: UUID
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
# LinkedIn Identity
linkedin_user_id: str
linkedin_vanity_name: Optional[str] = None
linkedin_name: Optional[str] = None
linkedin_picture: Optional[str] = None
# OAuth Tokens (encrypted)
access_token: str
refresh_token: Optional[str] = None
token_expires_at: datetime
granted_scopes: List[str] = Field(default_factory=list)
# Status
is_active: bool = True
last_used_at: Optional[datetime] = None
last_error: Optional[str] = None
last_error_at: Optional[datetime] = None
class User(DBModel):
"""User model - combines auth.users data with profile data.
This is a view model that combines data from Supabase auth.users
and our profiles table. Used for compatibility with existing code.
"""
id: Optional[UUID] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
# From auth.users
email: str = ""
password_hash: Optional[str] = None # Not stored, Supabase handles it
auth_method: AuthMethod = AuthMethod.LINKEDIN_OAUTH
# LinkedIn OAuth Data (from auth.users.raw_user_meta_data)
linkedin_sub: Optional[str] = None
linkedin_vanity_name: Optional[str] = None
linkedin_name: Optional[str] = None
linkedin_picture: Optional[str] = None
# From profiles table
account_type: AccountType = AccountType.GHOSTWRITER
display_name: Optional[str] = None
onboarding_status: OnboardingStatus = OnboardingStatus.PENDING
onboarding_data: Dict[str, Any] = Field(default_factory=dict)
company_id: Optional[UUID] = None
# Fields migrated from customers table
linkedin_url: Optional[str] = None
writing_style_notes: Optional[str] = None
metadata: Dict[str, Any] = Field(default_factory=dict)
profile_picture: Optional[str] = None
creator_email: Optional[str] = None
customer_email: Optional[str] = None
is_active: bool = True
# Email verification (from auth.users)
email_verified: bool = False
email_verification_token: Optional[str] = None
email_verification_expires_at: Optional[datetime] = None
class Company(DBModel):
"""Company model for company accounts."""
id: Optional[UUID] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
# Company Data
name: str
description: Optional[str] = None
website: Optional[str] = None
industry: Optional[str] = None
# Strategy
company_strategy: Dict[str, Any] = Field(default_factory=dict)
owner_user_id: UUID
onboarding_completed: bool = False
# License key reference (limits are stored in license_keys table)
license_key_id: Optional[UUID] = None
class Invitation(DBModel):
"""Invitation model for employee invitations."""
id: Optional[UUID] = None
created_at: Optional[datetime] = None
email: str
token: str
expires_at: datetime
company_id: UUID
invited_by_user_id: UUID
status: InvitationStatus = InvitationStatus.PENDING
accepted_at: Optional[datetime] = None
accepted_by_user_id: Optional[UUID] = None
class ExamplePost(DBModel):
"""Example post model for manual posts during onboarding."""
id: Optional[UUID] = None
user_id: UUID
created_at: Optional[datetime] = None
post_text: str
source: str = "manual" # 'manual' | 'reference_profile'
source_linkedin_url: Optional[str] = None
post_type_id: Optional[UUID] = None
class ReferenceProfile(DBModel):
"""Reference profile for scraping alternative LinkedIn profiles."""
id: Optional[UUID] = None
user_id: UUID
created_at: Optional[datetime] = None
linkedin_url: str
name: Optional[str] = None
posts_scraped: int = 0
# ==================== CONTENT MODELS ====================
class PostType(DBModel):
"""Post type model for categorizing different types of posts."""
id: Optional[UUID] = None
customer_id: UUID
user_id: UUID
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
name: str
@@ -42,7 +231,7 @@ class PostType(DBModel):
class LinkedInProfile(DBModel):
"""LinkedIn profile model."""
id: Optional[UUID] = None
customer_id: UUID
user_id: UUID
scraped_at: Optional[datetime] = None
profile_data: Dict[str, Any]
name: Optional[str] = None
@@ -55,7 +244,7 @@ class LinkedInProfile(DBModel):
class LinkedInPost(DBModel):
"""LinkedIn post model."""
id: Optional[UUID] = None
customer_id: UUID
user_id: UUID
scraped_at: Optional[datetime] = None
post_url: Optional[str] = None
post_text: str
@@ -73,7 +262,7 @@ class LinkedInPost(DBModel):
class Topic(DBModel):
"""Topic model."""
id: Optional[UUID] = None
customer_id: UUID
user_id: UUID
created_at: Optional[datetime] = None
title: str
description: Optional[str] = None
@@ -82,13 +271,13 @@ class Topic(DBModel):
extraction_confidence: Optional[float] = None
is_used: bool = False
used_at: Optional[datetime] = None
target_post_type_id: Optional[UUID] = None # Target post type for this topic
target_post_type_id: Optional[UUID] = None
class ProfileAnalysis(DBModel):
"""Profile analysis model."""
id: Optional[UUID] = None
customer_id: UUID
user_id: UUID
created_at: Optional[datetime] = None
writing_style: Dict[str, Any]
tone_analysis: Dict[str, Any]
@@ -100,19 +289,33 @@ class ProfileAnalysis(DBModel):
class ResearchResult(DBModel):
"""Research result model."""
id: Optional[UUID] = None
customer_id: UUID
user_id: UUID
created_at: Optional[datetime] = None
query: str
results: Dict[str, Any]
suggested_topics: List[Dict[str, Any]]
source: str = "perplexity"
target_post_type_id: Optional[UUID] = None # Target post type for this research
target_post_type_id: Optional[UUID] = None
class ApiUsageLog(DBModel):
"""API usage log for tracking token usage and costs."""
id: Optional[UUID] = None
user_id: Optional[UUID] = None
provider: str # 'openai' | 'perplexity'
model: str # 'gpt-4o', 'gpt-4o-mini', 'sonar'
operation: str # 'post_creation', 'research', 'profile_analysis', etc.
prompt_tokens: int = 0
completion_tokens: int = 0
total_tokens: int = 0
estimated_cost_usd: float = 0.0
created_at: Optional[datetime] = None
class GeneratedPost(DBModel):
"""Generated post model."""
id: Optional[UUID] = None
customer_id: UUID
user_id: UUID
created_at: Optional[datetime] = None
topic_id: Optional[UUID] = None
topic_title: str
@@ -120,7 +323,48 @@ class GeneratedPost(DBModel):
iterations: int = 0
writer_versions: List[str] = Field(default_factory=list)
critic_feedback: List[Dict[str, Any]] = Field(default_factory=list)
status: str = "draft" # draft, approved, published, rejected
status: str = "draft" # draft, approved, ready, scheduled, published, rejected
approved_at: Optional[datetime] = None
published_at: Optional[datetime] = None
post_type_id: Optional[UUID] = None # Post type used for this generated post
post_type_id: Optional[UUID] = None
# Image
image_url: Optional[str] = None
# Scheduling fields
scheduled_at: Optional[datetime] = None
scheduled_by_user_id: Optional[UUID] = None
# Metadata for additional info (e.g., LinkedIn post URL, auto-posting status)
metadata: Optional[Dict[str, Any]] = None
# ==================== LICENSE KEY MODELS ====================
class LicenseKey(DBModel):
"""License key for company registration."""
id: Optional[UUID] = None
key: str
description: Optional[str] = None
# Limits
max_employees: int = 5
max_posts_per_day: int = 10
max_researches_per_day: int = 5
# Usage
used: bool = False
company_id: Optional[UUID] = None
used_at: Optional[datetime] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class CompanyDailyQuota(DBModel):
"""Daily usage quota for a company."""
id: Optional[UUID] = None
company_id: UUID
date: date
posts_created: int = 0
researches_created: int = 0
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None

View File

@@ -5,7 +5,7 @@ from uuid import UUID
from loguru import logger
from src.config import settings
from src.database import db, Customer, LinkedInProfile, LinkedInPost, Topic
from src.database import db, LinkedInProfile, LinkedInPost, Topic
from src.scraper import scraper
from src.agents import (
ProfileAnalyzerAgent,
@@ -31,65 +31,80 @@ class WorkflowOrchestrator:
self.critic = CriticAgent()
self.post_classifier = PostClassifierAgent()
self.post_type_analyzer = PostTypeAnalyzerAgent()
self._all_agents = [
self.profile_analyzer, self.topic_extractor, self.researcher,
self.writer, self.critic, self.post_classifier, self.post_type_analyzer
]
logger.info("WorkflowOrchestrator initialized")
def _set_tracking(self, operation: str, user_id: Optional[str] = None,
company_id: Optional[str] = None):
"""Set tracking context on all agents."""
uid = str(user_id) if user_id else None
comp_id = str(company_id) if company_id else None
for agent in self._all_agents:
agent.set_tracking_context(operation=operation, user_id=uid, company_id=comp_id)
async def _resolve_tracking_ids(self, user_id: UUID) -> dict:
"""Resolve company_id from a user_id for tracking."""
try:
profile = await db.get_profile(user_id)
if profile:
return {
"user_id": str(user_id),
"company_id": str(profile.company_id) if profile.company_id else None
}
except Exception as e:
logger.debug(f"Could not resolve tracking IDs for user {user_id}: {e}")
return {"user_id": str(user_id), "company_id": None}
async def run_initial_setup(
self,
user_id: UUID,
linkedin_url: str,
customer_name: str,
customer_data: Dict[str, Any],
profile_data: Dict[str, Any],
post_types_data: Optional[List[Dict[str, Any]]] = None
) -> Customer:
) -> None:
"""
Run initial setup for a new customer.
Run initial setup for a user.
This includes:
1. Creating customer record
1. Updating profile with linkedin_url and metadata
2. Creating post types (if provided)
3. Scraping LinkedIn posts (NO profile scraping)
4. Creating profile from customer_data
4. Creating profile from profile_data
5. Analyzing profile
6. Extracting topics from existing posts
7. Classifying posts by type (if post types exist)
8. Analyzing post types (if enough posts)
Args:
user_id: User UUID
linkedin_url: LinkedIn profile URL
customer_name: Customer name
customer_data: Complete customer data (company, persona, style_guide, etc.)
profile_data: Profile data (writing style notes, etc.)
post_types_data: Optional list of post type definitions
Returns:
Customer object
"""
logger.info(f"=== INITIAL SETUP for {customer_name} ===")
logger.info(f"=== INITIAL SETUP for user {user_id} ===")
ids = await self._resolve_tracking_ids(user_id)
self._set_tracking("initial_setup", **ids)
# Step 1: Check if customer already exists
existing_customer = await db.get_customer_by_linkedin(linkedin_url)
if existing_customer:
logger.warning(f"Customer already exists: {existing_customer.id}")
return existing_customer
# Step 2: Create customer
# Step 1: Update profile with linkedin_url
total_steps = 7 if post_types_data else 5
logger.info(f"Step 1/{total_steps}: Creating customer record")
customer = Customer(
name=customer_name,
linkedin_url=linkedin_url,
company_name=customer_data.get("company_name"),
email=customer_data.get("email"),
metadata=customer_data
)
customer = await db.create_customer(customer)
logger.info(f"Customer created: {customer.id}")
logger.info(f"Step 1/{total_steps}: Updating profile")
await db.update_profile(user_id, {
"linkedin_url": linkedin_url,
"writing_style_notes": profile_data.get("writing_style_notes"),
"metadata": profile_data
})
logger.info(f"Profile updated for user: {user_id}")
# Step 2.5: Create post types if provided
# Step 2: Create post types if provided
created_post_types = []
if post_types_data:
logger.info(f"Step 2/{total_steps}: Creating {len(post_types_data)} post types")
for pt_data in post_types_data:
post_type = PostType(
customer_id=customer.id,
user_id=user_id,
name=pt_data.get("name", "Unnamed"),
description=pt_data.get("description"),
identifying_hashtags=pt_data.get("identifying_hashtags", []),
@@ -102,19 +117,20 @@ class WorkflowOrchestrator:
created_post_types = await db.create_post_types_bulk(created_post_types)
logger.info(f"Created {len(created_post_types)} post types")
# Step 3: Create LinkedIn profile from customer data (NO scraping)
# Step 3: Create LinkedIn profile from profile data (NO scraping)
step_num = 3 if post_types_data else 2
logger.info(f"Step {step_num}/{total_steps}: Creating LinkedIn profile from provided data")
profile = await db.get_profile(user_id)
linkedin_profile = LinkedInProfile(
customer_id=customer.id,
user_id=user_id,
profile_data={
"persona": customer_data.get("persona"),
"form_of_address": customer_data.get("form_of_address"),
"style_guide": customer_data.get("style_guide"),
"persona": profile_data.get("persona"),
"form_of_address": profile_data.get("form_of_address"),
"style_guide": profile_data.get("style_guide"),
"linkedin_url": linkedin_url
},
name=customer_name,
headline=customer_data.get("persona", "")[:100] if customer_data.get("persona") else None
name=profile.display_name or "",
headline=profile_data.get("persona", "")[:100] if profile_data.get("persona") else None
)
await db.save_linkedin_profile(linkedin_profile)
logger.info("LinkedIn profile saved")
@@ -129,7 +145,7 @@ class WorkflowOrchestrator:
linkedin_posts = []
for post_data in parsed_posts:
post = LinkedInPost(
customer_id=customer.id,
user_id=user_id,
**post_data
)
linkedin_posts.append(post)
@@ -151,13 +167,13 @@ class WorkflowOrchestrator:
profile_analysis = await self.profile_analyzer.process(
profile=linkedin_profile,
posts=linkedin_posts,
customer_data=customer_data
customer_data=profile_data
)
# Save profile analysis
from src.database.models import ProfileAnalysis
analysis_record = ProfileAnalysis(
customer_id=customer.id,
user_id=user_id,
writing_style=profile_analysis.get("writing_style", {}),
tone_analysis=profile_analysis.get("tone_analysis", {}),
topic_patterns=profile_analysis.get("topic_patterns", {}),
@@ -177,7 +193,7 @@ class WorkflowOrchestrator:
try:
topics = await self.topic_extractor.process(
posts=linkedin_posts,
customer_id=customer.id # Pass UUID directly
user_id=user_id
)
if topics:
await db.save_topics(topics)
@@ -192,40 +208,41 @@ class WorkflowOrchestrator:
# Step 7: Classify posts
logger.info(f"Step {total_steps - 1}/{total_steps}: Classifying posts by type")
try:
await self.classify_posts(customer.id)
await self.classify_posts(user_id)
except Exception as e:
logger.error(f"Post classification failed: {e}", exc_info=True)
# Step 8: Analyze post types
logger.info(f"Step {total_steps}/{total_steps}: Analyzing post types")
try:
await self.analyze_post_types(customer.id)
await self.analyze_post_types(user_id)
except Exception as e:
logger.error(f"Post type analysis failed: {e}", exc_info=True)
logger.info(f"Step {total_steps}/{total_steps}: Initial setup complete!")
return customer
async def classify_posts(self, customer_id: UUID) -> int:
async def classify_posts(self, user_id: UUID) -> int:
"""
Classify unclassified posts for a customer.
Classify unclassified posts for a user.
Args:
customer_id: Customer UUID
user_id: User UUID
Returns:
Number of posts classified
"""
logger.info(f"=== CLASSIFYING POSTS for customer {customer_id} ===")
logger.info(f"=== CLASSIFYING POSTS for user {user_id} ===")
ids = await self._resolve_tracking_ids(user_id)
self._set_tracking("classify_posts", **ids)
# Get post types
post_types = await db.get_post_types(customer_id)
post_types = await db.get_post_types(user_id)
if not post_types:
logger.info("No post types defined, skipping classification")
return 0
# Get unclassified posts
posts = await db.get_unclassified_posts(customer_id)
posts = await db.get_unclassified_posts(user_id)
if not posts:
logger.info("No unclassified posts found")
return 0
@@ -243,20 +260,22 @@ class WorkflowOrchestrator:
return 0
async def analyze_post_types(self, customer_id: UUID) -> Dict[str, Any]:
async def analyze_post_types(self, user_id: UUID) -> Dict[str, Any]:
"""
Analyze all post types for a customer.
Analyze all post types for a user.
Args:
customer_id: Customer UUID
user_id: User UUID
Returns:
Dictionary with analysis results per post type
"""
logger.info(f"=== ANALYZING POST TYPES for customer {customer_id} ===")
logger.info(f"=== ANALYZING POST TYPES for user {user_id} ===")
ids = await self._resolve_tracking_ids(user_id)
self._set_tracking("analyze_post_types", **ids)
# Get post types
post_types = await db.get_post_types(customer_id)
post_types = await db.get_post_types(user_id)
if not post_types:
logger.info("No post types defined")
return {}
@@ -264,7 +283,7 @@ class WorkflowOrchestrator:
results = {}
for post_type in post_types:
# Get posts for this type
posts = await db.get_posts_by_type(customer_id, post_type.id)
posts = await db.get_posts_by_type(user_id, post_type.id)
if len(posts) < self.post_type_analyzer.MIN_POSTS_FOR_ANALYSIS:
logger.info(f"Post type '{post_type.name}' has only {len(posts)} posts, skipping analysis")
@@ -292,22 +311,24 @@ class WorkflowOrchestrator:
async def research_new_topics(
self,
customer_id: UUID,
user_id: UUID,
progress_callback: Optional[Callable[[str, int, int], None]] = None,
post_type_id: Optional[UUID] = None
) -> List[Dict[str, Any]]:
"""
Research new content topics for a customer.
Research new content topics for a user.
Args:
customer_id: Customer UUID
user_id: User UUID
progress_callback: Optional callback(message, current_step, total_steps)
post_type_id: Optional post type to target research for
Returns:
List of suggested topics
"""
logger.info(f"=== RESEARCHING NEW TOPICS for customer {customer_id} ===")
logger.info(f"=== RESEARCHING NEW TOPICS for user {user_id} ===")
ids = await self._resolve_tracking_ids(user_id)
self._set_tracking("research", **ids)
# Get post type context if specified
post_type = None
@@ -324,7 +345,7 @@ class WorkflowOrchestrator:
# Step 1: Get profile analysis
report_progress("Lade Profil-Analyse...", 1)
profile_analysis = await db.get_profile_analysis(customer_id)
profile_analysis = await db.get_profile_analysis(user_id)
if not profile_analysis:
raise ValueError("Profile analysis not found. Run initial setup first.")
@@ -333,12 +354,12 @@ class WorkflowOrchestrator:
existing_topics = set()
# From topics table
existing_topics_records = await db.get_topics(customer_id)
existing_topics_records = await db.get_topics(user_id)
for t in existing_topics_records:
existing_topics.add(t.title)
# From previous research results
all_research = await db.get_all_research(customer_id)
all_research = await db.get_all_research(user_id)
for research in all_research:
if research.suggested_topics:
for topic in research.suggested_topics:
@@ -346,7 +367,7 @@ class WorkflowOrchestrator:
existing_topics.add(topic["title"])
# From generated posts
generated_posts = await db.get_generated_posts(customer_id)
generated_posts = await db.get_generated_posts(user_id)
for post in generated_posts:
if post.topic_title:
existing_topics.add(post.topic_title)
@@ -354,15 +375,15 @@ class WorkflowOrchestrator:
existing_topics = list(existing_topics)
logger.info(f"Found {len(existing_topics)} existing topics to avoid")
# Get customer data
customer = await db.get_customer(customer_id)
# Get profile data
profile = await db.get_profile(user_id)
# Get example posts to understand the person's actual content style
# If post_type_id is specified, only use posts of that type
if post_type_id:
linkedin_posts = await db.get_posts_by_type(customer_id, post_type_id)
linkedin_posts = await db.get_posts_by_type(user_id, post_type_id)
else:
linkedin_posts = await db.get_linkedin_posts(customer_id)
linkedin_posts = await db.get_linkedin_posts(user_id)
example_post_texts = [
post.post_text for post in linkedin_posts
@@ -376,7 +397,7 @@ class WorkflowOrchestrator:
research_results = await self.researcher.process(
profile_analysis=profile_analysis.full_analysis,
existing_topics=existing_topics,
customer_data=customer.metadata,
customer_data=profile.metadata,
example_posts=example_post_texts,
post_type=post_type,
post_type_analysis=post_type_analysis
@@ -386,8 +407,8 @@ class WorkflowOrchestrator:
report_progress("Speichere Ergebnisse...", 4)
from src.database.models import ResearchResult
research_record = ResearchResult(
customer_id=customer_id,
query=f"New topics for {customer.name}" + (f" ({post_type.name})" if post_type else ""),
user_id=user_id,
query=f"New topics for {profile.display_name}" + (f" ({post_type.name})" if post_type else ""),
results={"raw_response": research_results["raw_response"]},
suggested_topics=research_results["suggested_topics"],
target_post_type_id=post_type_id
@@ -397,19 +418,135 @@ class WorkflowOrchestrator:
return research_results["suggested_topics"]
async def generate_hooks(
self,
user_id: UUID,
topic: Dict[str, Any],
user_thoughts: str = "",
post_type_id: Optional[UUID] = None
) -> List[Dict[str, str]]:
"""
Generate 4 hook options for a topic.
Args:
user_id: User UUID
topic: Topic dictionary
user_thoughts: User's personal thoughts about the topic
post_type_id: Optional post type for context
Returns:
List of {"hook": "...", "style": "..."} dictionaries
"""
logger.info(f"=== GENERATING HOOKS for topic: {topic.get('title')} ===")
ids = await self._resolve_tracking_ids(user_id)
self._set_tracking("generate_hooks", **ids)
# Get profile analysis for style matching
profile_analysis = await db.get_profile_analysis(user_id)
if not profile_analysis:
raise ValueError("Profile analysis not found. Run initial setup first.")
# Get post type context if specified
post_type = None
if post_type_id:
post_type = await db.get_post_type(post_type_id)
# Generate hooks via writer agent
hooks = await self.writer.generate_hooks(
topic=topic,
profile_analysis=profile_analysis.full_analysis,
user_thoughts=user_thoughts,
post_type=post_type
)
logger.info(f"Generated {len(hooks)} hooks")
return hooks
async def generate_improvement_suggestions(
self,
user_id: UUID,
post_content: str,
critic_feedback: Optional[Dict[str, Any]] = None
) -> List[Dict[str, str]]:
"""
Generate improvement suggestions for an existing post.
Args:
user_id: User UUID
post_content: The current post content
critic_feedback: Optional feedback from the critic
Returns:
List of {"label": "...", "action": "..."} dictionaries
"""
logger.info("=== GENERATING IMPROVEMENT SUGGESTIONS ===")
ids = await self._resolve_tracking_ids(user_id)
self._set_tracking("improvement_suggestions", **ids)
# Get profile analysis for style matching
profile_analysis = await db.get_profile_analysis(user_id)
if not profile_analysis:
raise ValueError("Profile analysis not found.")
suggestions = await self.writer.generate_improvement_suggestions(
post_content=post_content,
profile_analysis=profile_analysis.full_analysis,
critic_feedback=critic_feedback
)
logger.info(f"Generated {len(suggestions)} improvement suggestions")
return suggestions
async def apply_suggestion_to_post(
self,
user_id: UUID,
post_content: str,
suggestion: str
) -> str:
"""
Apply a suggestion to a post and return the improved version.
Args:
user_id: User UUID
post_content: The current post content
suggestion: The suggestion to apply
Returns:
The improved post content
"""
logger.info(f"=== APPLYING SUGGESTION TO POST ===")
ids = await self._resolve_tracking_ids(user_id)
self._set_tracking("apply_suggestion", **ids)
# Get profile analysis for style matching
profile_analysis = await db.get_profile_analysis(user_id)
if not profile_analysis:
raise ValueError("Profile analysis not found.")
improved_post = await self.writer.apply_suggestion(
post_content=post_content,
suggestion=suggestion,
profile_analysis=profile_analysis.full_analysis
)
logger.info("Successfully applied suggestion to post")
return improved_post
async def create_post(
self,
customer_id: UUID,
user_id: UUID,
topic: Dict[str, Any],
max_iterations: int = 3,
progress_callback: Optional[Callable[[str, int, int, Optional[int], Optional[List], Optional[List]], None]] = None,
post_type_id: Optional[UUID] = None
post_type_id: Optional[UUID] = None,
user_thoughts: str = "",
selected_hook: str = ""
) -> Dict[str, Any]:
"""
Create a LinkedIn post through writer-critic iteration.
Args:
customer_id: Customer UUID
user_id: User UUID
topic: Topic dictionary
max_iterations: Maximum number of writer-critic iterations
progress_callback: Optional callback(message, iteration, max_iterations, score, versions, feedback_list)
@@ -419,6 +556,8 @@ class WorkflowOrchestrator:
Dictionary with final post and metadata
"""
logger.info(f"=== CREATING POST for topic: {topic.get('title')} ===")
ids = await self._resolve_tracking_ids(user_id)
self._set_tracking("post_creation", **ids)
def report_progress(message: str, iteration: int, score: Optional[int] = None,
versions: Optional[List] = None, feedback_list: Optional[List] = None):
@@ -427,7 +566,7 @@ class WorkflowOrchestrator:
# Get profile analysis
report_progress("Lade Profil-Analyse...", 0, None, [], [])
profile_analysis = await db.get_profile_analysis(customer_id)
profile_analysis = await db.get_profile_analysis(user_id)
if not profile_analysis:
raise ValueError("Profile analysis not found. Run initial setup first.")
@@ -440,16 +579,16 @@ class WorkflowOrchestrator:
post_type_analysis = post_type.analysis
logger.info(f"Using post type '{post_type.name}' for writing")
# Load customer's real posts as style examples
# Load user's real posts as style examples
# If post_type_id is specified, only use posts of that type
if post_type_id:
linkedin_posts = await db.get_posts_by_type(customer_id, post_type_id)
linkedin_posts = await db.get_posts_by_type(user_id, post_type_id)
if len(linkedin_posts) < 3:
# Fall back to all posts if not enough type-specific posts
linkedin_posts = await db.get_linkedin_posts(customer_id)
linkedin_posts = await db.get_linkedin_posts(user_id)
logger.info("Not enough type-specific posts, using all posts")
else:
linkedin_posts = await db.get_linkedin_posts(customer_id)
linkedin_posts = await db.get_linkedin_posts(user_id)
example_post_texts = [
post.post_text for post in linkedin_posts
@@ -458,7 +597,7 @@ class WorkflowOrchestrator:
logger.info(f"Loaded {len(example_post_texts)} example posts for style reference")
# Extract lessons from past feedback (if enabled)
feedback_lessons = await self._extract_recurring_feedback(customer_id)
feedback_lessons = await self._extract_recurring_feedback(user_id)
# Initialize tracking
writer_versions = []
@@ -467,6 +606,15 @@ class WorkflowOrchestrator:
approved = False
iteration = 0
# Load company strategy if user belongs to a company
company_strategy = None
profile = await db.get_profile(user_id)
if profile and profile.company_id:
company = await db.get_company(profile.company_id)
if company and company.company_strategy:
company_strategy = company.company_strategy
logger.info(f"Loaded company strategy for post creation: {company.name}")
# Writer-Critic loop
while iteration < max_iterations and not approved:
iteration += 1
@@ -482,7 +630,10 @@ class WorkflowOrchestrator:
example_posts=example_post_texts,
learned_lessons=feedback_lessons, # Pass lessons from past feedback
post_type=post_type,
post_type_analysis=post_type_analysis
post_type_analysis=post_type_analysis,
user_thoughts=user_thoughts,
selected_hook=selected_hook,
company_strategy=company_strategy # NEW: Pass company strategy
)
else:
# Revision based on feedback - pass full critic result for structured changes
@@ -497,7 +648,10 @@ class WorkflowOrchestrator:
critic_result=last_feedback, # Pass full critic result with specific_changes
learned_lessons=feedback_lessons, # Also for revisions
post_type=post_type,
post_type_analysis=post_type_analysis
post_type_analysis=post_type_analysis,
user_thoughts=user_thoughts,
selected_hook=selected_hook,
company_strategy=company_strategy # NEW: Pass company strategy
)
writer_versions.append(current_post)
@@ -538,19 +692,14 @@ class WorkflowOrchestrator:
if iteration < max_iterations:
logger.info("Post needs revision, continuing...")
# Determine final status based on score
# All new posts start as draft - user moves them via Kanban board
final_score = critic_feedback_list[-1].get("overall_score", 0) if critic_feedback_list else 0
if approved and final_score >= 85:
status = "approved"
elif approved and final_score >= 80:
status = "approved" # Auto-approved
else:
status = "draft"
status = "draft"
# Save generated post
from src.database.models import GeneratedPost
generated_post = GeneratedPost(
customer_id=customer_id,
user_id=user_id,
topic_title=topic.get("title", "Unknown"),
post_content=current_post,
iterations=iteration,
@@ -573,12 +722,12 @@ class WorkflowOrchestrator:
"critic_feedback": critic_feedback_list
}
async def _extract_recurring_feedback(self, customer_id: UUID) -> Dict[str, Any]:
async def _extract_recurring_feedback(self, user_id: UUID) -> Dict[str, Any]:
"""
Extract recurring feedback patterns from past generated posts.
Args:
customer_id: Customer UUID
user_id: User UUID
Returns:
Dictionary with recurring improvements and lessons learned
@@ -587,7 +736,7 @@ class WorkflowOrchestrator:
return {"lessons": [], "patterns": {}}
# Get recent generated posts with their critic feedback
generated_posts = await db.get_generated_posts(customer_id)
generated_posts = await db.get_generated_posts(user_id)
if not generated_posts:
return {"lessons": [], "patterns": {}}
@@ -683,26 +832,26 @@ class WorkflowOrchestrator:
}
}
async def get_customer_status(self, customer_id: UUID) -> Dict[str, Any]:
async def get_user_status(self, user_id: UUID) -> Dict[str, Any]:
"""
Get status information for a customer.
Get status information for a user.
Args:
customer_id: Customer UUID
user_id: User UUID
Returns:
Status dictionary
"""
customer = await db.get_customer(customer_id)
if not customer:
raise ValueError("Customer not found")
profile = await db.get_profile(user_id)
if not profile:
raise ValueError("User not found")
profile = await db.get_linkedin_profile(customer_id)
posts = await db.get_linkedin_posts(customer_id)
analysis = await db.get_profile_analysis(customer_id)
generated_posts = await db.get_generated_posts(customer_id)
all_research = await db.get_all_research(customer_id)
post_types = await db.get_post_types(customer_id)
linkedin_profile = await db.get_linkedin_profile(user_id)
posts = await db.get_linkedin_posts(user_id)
analysis = await db.get_profile_analysis(user_id)
generated_posts = await db.get_generated_posts(user_id)
all_research = await db.get_all_research(user_id)
post_types = await db.get_post_types(user_id)
# Count total research entries
research_count = len(all_research)

0
src/services/__init__.py Normal file
View File

View File

@@ -0,0 +1,481 @@
"""Background job system for running AI analyses without blocking the UI."""
import asyncio
from datetime import datetime
from enum import Enum
from typing import Dict, Any, Optional, Callable, Awaitable
from uuid import UUID, uuid4
from dataclasses import dataclass, field
from loguru import logger
class JobStatus(str, Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
class JobType(str, Enum):
POST_SCRAPING = "post_scraping"
PROFILE_ANALYSIS = "profile_analysis"
POST_CATEGORIZATION = "post_categorization"
POST_TYPE_ANALYSIS = "post_type_analysis"
@dataclass
class BackgroundJob:
id: str
job_type: JobType
user_id: str
status: JobStatus = JobStatus.PENDING
progress: int = 0
message: str = ""
created_at: datetime = field(default_factory=datetime.utcnow)
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
error: Optional[str] = None
result: Optional[Dict[str, Any]] = None
class BackgroundJobManager:
"""Manages background jobs for AI analyses."""
def __init__(self):
self._jobs: Dict[str, BackgroundJob] = {}
self._user_jobs: Dict[str, list] = {} # user_id -> list of job_ids
self._listeners: Dict[str, list] = {} # user_id -> list of callbacks
def create_job(self, job_type: JobType, user_id: str) -> BackgroundJob:
"""Create a new background job."""
job_id = str(uuid4())
job = BackgroundJob(
id=job_id,
job_type=job_type,
user_id=user_id
)
self._jobs[job_id] = job
if user_id not in self._user_jobs:
self._user_jobs[user_id] = []
self._user_jobs[user_id].append(job_id)
logger.info(f"Created background job {job_id} of type {job_type} for user {user_id}")
return job
def get_job(self, job_id: str) -> Optional[BackgroundJob]:
"""Get a job by ID."""
return self._jobs.get(job_id)
def get_user_jobs(self, user_id: str) -> list[BackgroundJob]:
"""Get all jobs for a user."""
job_ids = self._user_jobs.get(user_id, [])
return [self._jobs[jid] for jid in job_ids if jid in self._jobs]
def get_active_jobs(self, user_id: str) -> list[BackgroundJob]:
"""Get running/pending jobs for a user."""
return [
j for j in self.get_user_jobs(user_id)
if j.status in (JobStatus.PENDING, JobStatus.RUNNING)
]
async def update_job(
self,
job_id: str,
status: Optional[JobStatus] = None,
progress: Optional[int] = None,
message: Optional[str] = None,
error: Optional[str] = None,
result: Optional[Dict[str, Any]] = None
):
"""Update a job's status and notify listeners."""
job = self._jobs.get(job_id)
if not job:
return
if status:
job.status = status
if status == JobStatus.RUNNING and not job.started_at:
job.started_at = datetime.utcnow()
elif status in (JobStatus.COMPLETED, JobStatus.FAILED):
job.completed_at = datetime.utcnow()
if progress is not None:
job.progress = progress
if message:
job.message = message
if error:
job.error = error
if result:
job.result = result
# Notify listeners
await self._notify_listeners(job.user_id, job)
async def _notify_listeners(self, user_id: str, job: BackgroundJob):
"""Notify all listeners for a user about job updates."""
listeners = self._listeners.get(user_id, [])
for callback in listeners:
try:
await callback(job)
except Exception as e:
logger.error(f"Error notifying listener: {e}")
def add_listener(self, user_id: str, callback: Callable[[BackgroundJob], Awaitable[None]]):
"""Add a listener for job updates."""
if user_id not in self._listeners:
self._listeners[user_id] = []
self._listeners[user_id].append(callback)
def remove_listener(self, user_id: str, callback: Callable[[BackgroundJob], Awaitable[None]]):
"""Remove a listener."""
if user_id in self._listeners:
try:
self._listeners[user_id].remove(callback)
except ValueError:
pass
def cleanup_old_jobs(self, max_age_hours: int = 24):
"""Remove completed jobs older than max_age_hours."""
cutoff = datetime.utcnow()
to_remove = []
for job_id, job in self._jobs.items():
if job.completed_at:
age = (cutoff - job.completed_at).total_seconds() / 3600
if age > max_age_hours:
to_remove.append(job_id)
for job_id in to_remove:
job = self._jobs.pop(job_id, None)
if job:
user_jobs = self._user_jobs.get(job.user_id, [])
if job_id in user_jobs:
user_jobs.remove(job_id)
if to_remove:
logger.info(f"Cleaned up {len(to_remove)} old background jobs")
# Global instance
job_manager = BackgroundJobManager()
async def run_post_scraping(user_id: UUID, linkedin_url: str, job_id: str):
"""Run LinkedIn post scraping in background."""
from src.database.client import DatabaseClient
from src.scraper import scraper
from src.database.models import LinkedInPost
db = DatabaseClient()
try:
await job_manager.update_job(
job_id,
status=JobStatus.RUNNING,
progress=10,
message="Starte LinkedIn-Scraping..."
)
# Scrape posts
await job_manager.update_job(
job_id,
progress=30,
message="Lade Posts von LinkedIn..."
)
raw_posts = await scraper.scrape_posts(linkedin_url, limit=50)
parsed_posts = scraper.parse_posts_data(raw_posts)
await job_manager.update_job(
job_id,
progress=60,
message=f"{len(parsed_posts)} Posts gefunden, speichere..."
)
linkedin_posts = []
profile_picture = None
for post_data in parsed_posts:
post = LinkedInPost(user_id=user_id, **post_data)
linkedin_posts.append(post)
# Extract profile picture from first post with author data
if not profile_picture and post_data.get("raw_data"):
author = post_data["raw_data"].get("author", {})
if author and isinstance(author, dict):
profile_picture = author.get("profile_picture")
if linkedin_posts:
await db.save_linkedin_posts(linkedin_posts)
# Save profile picture to profile
if profile_picture:
await db.update_profile(user_id, {"profile_picture": profile_picture})
await job_manager.update_job(
job_id,
status=JobStatus.COMPLETED,
progress=100,
message=f"{len(linkedin_posts)} Posts gespeichert!",
result={"posts_count": len(linkedin_posts), "profile_picture": profile_picture}
)
logger.info(f"Post scraping completed for user {user_id}: {len(linkedin_posts)} posts")
except Exception as e:
logger.error(f"Post scraping failed: {e}")
await job_manager.update_job(
job_id,
status=JobStatus.FAILED,
error=str(e)
)
async def run_profile_analysis(user_id: UUID, job_id: str):
"""Run profile analysis in background."""
from src.database.client import DatabaseClient
from src.agents.profile_analyzer import ProfileAnalyzerAgent
from src.database.models import ProfileAnalysis
db = DatabaseClient()
try:
await job_manager.update_job(
job_id,
status=JobStatus.RUNNING,
progress=10,
message="Lade LinkedIn-Posts..."
)
# Get posts and profile
posts = await db.get_linkedin_posts(user_id)
if not posts:
await job_manager.update_job(
job_id,
status=JobStatus.FAILED,
error="Keine Posts gefunden"
)
return
linkedin_profile = await db.get_linkedin_profile(user_id)
user_profile = await db.get_profile(user_id)
await job_manager.update_job(
job_id,
progress=30,
message=f"Analysiere {len(posts)} Posts..."
)
# Run analysis
analyzer = ProfileAnalyzerAgent()
# Prepare user data
user_data = {
"name": user_profile.display_name if user_profile else "",
"company": "",
"writing_style_notes": user_profile.writing_style_notes if user_profile else ""
}
# Create a minimal linkedin profile if none exists
if not linkedin_profile:
from src.database.models import LinkedInProfile
linkedin_profile = LinkedInProfile(
user_id=user_id,
profile_data={},
name=user_profile.display_name if user_profile else ""
)
analysis_result = await analyzer.process(linkedin_profile, posts, user_data)
await job_manager.update_job(
job_id,
progress=80,
message="Speichere Analyse..."
)
# Save analysis
profile_analysis = ProfileAnalysis(
user_id=user_id,
writing_style=analysis_result.get("writing_style", {}),
tone_analysis=analysis_result.get("tone_analysis", {}),
topic_patterns=analysis_result.get("topic_patterns", {}),
audience_insights=analysis_result.get("audience_insights", {}),
full_analysis=analysis_result
)
await db.save_profile_analysis(profile_analysis)
await job_manager.update_job(
job_id,
status=JobStatus.COMPLETED,
progress=100,
message="Profil-Analyse abgeschlossen!"
)
logger.info(f"Profile analysis completed for user {user_id}")
except Exception as e:
logger.error(f"Profile analysis failed: {e}")
await job_manager.update_job(
job_id,
status=JobStatus.FAILED,
error=str(e)
)
async def run_post_categorization(user_id: UUID, job_id: str):
"""Run automatic post categorization in background."""
from src.database.client import DatabaseClient
from src.agents.post_classifier import PostClassifierAgent
db = DatabaseClient()
try:
await job_manager.update_job(
job_id,
status=JobStatus.RUNNING,
progress=10,
message="Lade Posts und Typen..."
)
# Get posts and types
posts = await db.get_unclassified_posts(user_id)
post_types = await db.get_post_types(user_id)
if not posts:
await job_manager.update_job(
job_id,
status=JobStatus.COMPLETED,
progress=100,
message="Alle Posts sind bereits kategorisiert!"
)
return
if not post_types:
await job_manager.update_job(
job_id,
status=JobStatus.FAILED,
error="Keine Post-Typen definiert"
)
return
await job_manager.update_job(
job_id,
progress=30,
message=f"Kategorisiere {len(posts)} Posts..."
)
# Run classification
classifier = PostClassifierAgent()
classifications = await classifier.process(posts, post_types)
await job_manager.update_job(
job_id,
progress=70,
message="Speichere Kategorisierungen..."
)
# Save classifications
if classifications:
await db.update_posts_classification_bulk(classifications)
await job_manager.update_job(
job_id,
status=JobStatus.COMPLETED,
progress=100,
message=f"{len(classifications)} Posts kategorisiert!"
)
logger.info(f"Post categorization completed for user {user_id}: {len(classifications)} posts")
except Exception as e:
logger.error(f"Post categorization failed: {e}")
await job_manager.update_job(
job_id,
status=JobStatus.FAILED,
error=str(e)
)
async def run_post_type_analysis(user_id: UUID, job_id: str):
"""Run post type analysis in background."""
from src.database.client import DatabaseClient
from src.agents.post_type_analyzer import PostTypeAnalyzerAgent
db = DatabaseClient()
try:
await job_manager.update_job(
job_id,
status=JobStatus.RUNNING,
progress=10,
message="Lade Post-Typen..."
)
post_types = await db.get_post_types(user_id)
if not post_types:
await job_manager.update_job(
job_id,
status=JobStatus.FAILED,
error="Keine Post-Typen definiert"
)
return
analyzer = PostTypeAnalyzerAgent()
total = len(post_types)
for i, post_type in enumerate(post_types):
await job_manager.update_job(
job_id,
progress=int(10 + (80 * i / total)),
message=f"Analysiere '{post_type.name}'..."
)
# Get posts for this type
posts = await db.get_posts_by_type(user_id, post_type.id)
if posts:
analysis = await analyzer.process(post_type, posts)
await db.update_post_type_analysis(post_type.id, analysis, len(posts))
await job_manager.update_job(
job_id,
status=JobStatus.COMPLETED,
progress=100,
message=f"{total} Post-Typen analysiert!"
)
logger.info(f"Post type analysis completed for user {user_id}")
except Exception as e:
logger.error(f"Post type analysis failed: {e}")
await job_manager.update_job(
job_id,
status=JobStatus.FAILED,
error=str(e)
)
async def run_full_analysis_pipeline(user_id: UUID):
"""Run the full analysis pipeline in sequence: Profile -> Categorization -> Post Types."""
logger.info(f"Starting full analysis pipeline for user {user_id}")
# 1. Profile Analysis
job1 = job_manager.create_job(JobType.PROFILE_ANALYSIS, str(user_id))
await run_profile_analysis(user_id, job1.id)
if job1.status == JobStatus.FAILED:
logger.warning(f"Profile analysis failed, continuing with categorization")
# 2. Post Categorization
job2 = job_manager.create_job(JobType.POST_CATEGORIZATION, str(user_id))
await run_post_categorization(user_id, job2.id)
if job2.status == JobStatus.FAILED:
logger.warning(f"Post categorization failed, continuing with post type analysis")
# 3. Post Type Analysis
job3 = job_manager.create_job(JobType.POST_TYPE_ANALYSIS, str(user_id))
await run_post_type_analysis(user_id, job3.id)
logger.info(f"Full analysis pipeline completed for user {user_id}")

View File

@@ -0,0 +1,467 @@
"""Email service for sending post approval emails."""
import smtplib
import secrets
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from typing import Optional, Dict, Any
from datetime import datetime, timedelta
from uuid import UUID
from loguru import logger
from src.config import settings
# In-memory token store (in production, use Redis or database)
_email_tokens: Dict[str, Dict[str, Any]] = {}
def generate_token(post_id: UUID, action: str, expires_hours: int = 72) -> str:
"""Generate a unique token for email action."""
token = secrets.token_urlsafe(32)
_email_tokens[token] = {
"post_id": str(post_id),
"action": action,
"expires_at": datetime.utcnow() + timedelta(hours=expires_hours),
"used": False
}
return token
def validate_token(token: str) -> Optional[Dict[str, Any]]:
"""Validate and return token data if valid."""
if token not in _email_tokens:
return None
token_data = _email_tokens[token]
if token_data["used"]:
return None
if datetime.utcnow() > token_data["expires_at"]:
return None
return token_data
def mark_token_used(token: str):
"""Mark a token as used."""
if token in _email_tokens:
_email_tokens[token]["used"] = True
def send_email(to_email: str, subject: str, html_content: str) -> bool:
"""Send an email using SMTP."""
if not settings.smtp_host or not settings.smtp_user:
logger.warning("SMTP not configured, skipping email send")
return False
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = f"{settings.smtp_from_name} <{settings.smtp_user}>"
msg["To"] = to_email
html_part = MIMEText(html_content, "html")
msg.attach(html_part)
with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as server:
server.starttls()
server.login(settings.smtp_user, settings.smtp_password)
server.sendmail(settings.smtp_user, to_email, msg.as_string())
logger.info(f"Email sent to {to_email}: {subject}")
return True
except Exception as e:
logger.error(f"Failed to send email to {to_email}: {e}")
return False
def send_approval_request_email(
to_email: str,
post_id: UUID,
post_title: str,
post_content: str,
base_url: str,
image_url: Optional[str] = None
) -> bool:
"""Send email to customer requesting approval of a post."""
approve_token = generate_token(post_id, "approve")
reject_token = generate_token(post_id, "reject")
approve_url = f"{base_url}/api/email-action/{approve_token}"
reject_url = f"{base_url}/api/email-action/{reject_token}"
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }}
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
.header {{ background: linear-gradient(135deg, #2d3838 0%, #1a2424 100%); color: white; padding: 24px; }}
.header h1 {{ margin: 0; font-size: 20px; }}
.content {{ padding: 24px; }}
.post-preview {{ background: #f8f9fa; border-left: 4px solid #ffc700; padding: 16px; margin: 20px 0; border-radius: 0 8px 8px 0; white-space: pre-wrap; font-size: 14px; line-height: 1.6; color: #333; }}
.buttons {{ display: flex; gap: 12px; margin-top: 24px; }}
.btn {{ display: inline-block; padding: 14px 28px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 14px; text-align: center; }}
.btn-approve {{ background: #22c55e; color: white; }}
.btn-reject {{ background: #6b7280; color: white; }}
.footer {{ padding: 16px 24px; background: #f8f9fa; font-size: 12px; color: #666; text-align: center; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Neuer LinkedIn Post zur Freigabe</h1>
</div>
<div class="content">
<p>Hallo,</p>
<p>Ein neuer LinkedIn Post wurde erstellt und wartet auf deine Freigabe:</p>
<p><strong>{post_title}</strong></p>
<div class="post-preview">{post_content}</div>
{f'<img src="{image_url}" alt="Post-Bild" style="width: 100%; max-height: 400px; object-fit: cover; border-radius: 8px; margin: 16px 0;" />' if image_url else ''}
<p>Bitte entscheide, ob der Post veröffentlicht werden soll:</p>
<div class="buttons">
<a href="{approve_url}" class="btn btn-approve">Freigeben</a>
<a href="{reject_url}" class="btn btn-reject">Nochmal bearbeiten</a>
</div>
</div>
<div class="footer">
Diese Email wurde automatisch generiert. Die Links sind 72 Stunden gültig.
</div>
</div>
</body>
</html>
"""
return send_email(
to_email=to_email,
subject=f"Post zur Freigabe: {post_title}",
html_content=html_content
)
def send_invitation_email(
to_email: str,
company_name: str,
inviter_name: str,
token: str,
base_url: str
) -> bool:
"""Send invitation email to a new employee."""
invite_url = f"{base_url}/invite/{token}"
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }}
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
.header {{ background: linear-gradient(135deg, #2d3838 0%, #1a2424 100%); color: white; padding: 24px; }}
.header h1 {{ margin: 0; font-size: 20px; }}
.content {{ padding: 24px; }}
.company-badge {{ display: inline-block; padding: 8px 16px; border-radius: 20px; background: #ffc700; color: #1a2424; font-weight: 600; margin: 16px 0; }}
.btn {{ display: inline-block; padding: 14px 28px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 14px; background: #ffc700; color: #1a2424; margin-top: 16px; }}
.footer {{ padding: 16px 24px; background: #f8f9fa; font-size: 12px; color: #666; text-align: center; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Du wurdest eingeladen!</h1>
</div>
<div class="content">
<p>Hallo,</p>
<p><strong>{inviter_name}</strong> hat dich eingeladen, dem Team beizutreten:</p>
<p class="company-badge">{company_name}</p>
<p>Als Teammitglied kannst du LinkedIn-Posts erstellen, die der Unternehmensstrategie entsprechen.</p>
<a href="{invite_url}" class="btn">Einladung annehmen</a>
<p style="margin-top: 24px; font-size: 14px; color: #666;">
Oder kopiere diesen Link in deinen Browser:<br>
<code style="font-size: 12px; color: #999;">{invite_url}</code>
</p>
</div>
<div class="footer">
Diese Einladung ist 7 Tage gueltig.
</div>
</div>
</body>
</html>
"""
return send_email(
to_email=to_email,
subject=f"Einladung von {company_name}",
html_content=html_content
)
def send_email_verification(
to_email: str,
token: str,
base_url: str
) -> bool:
"""Send email verification link."""
verify_url = f"{base_url}/verify-email/{token}"
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }}
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
.header {{ background: linear-gradient(135deg, #2d3838 0%, #1a2424 100%); color: white; padding: 24px; }}
.header h1 {{ margin: 0; font-size: 20px; }}
.content {{ padding: 24px; }}
.btn {{ display: inline-block; padding: 14px 28px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 14px; background: #ffc700; color: #1a2424; margin-top: 16px; }}
.footer {{ padding: 16px 24px; background: #f8f9fa; font-size: 12px; color: #666; text-align: center; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>E-Mail bestaetigen</h1>
</div>
<div class="content">
<p>Hallo,</p>
<p>Bitte bestaetigen deine E-Mail-Adresse, um dein Konto zu aktivieren:</p>
<a href="{verify_url}" class="btn">E-Mail bestaetigen</a>
<p style="margin-top: 24px; font-size: 14px; color: #666;">
Oder kopiere diesen Link in deinen Browser:<br>
<code style="font-size: 12px; color: #999;">{verify_url}</code>
</p>
</div>
<div class="footer">
Dieser Link ist 24 Stunden gueltig. Falls du dich nicht registriert hast, ignoriere diese E-Mail.
</div>
</div>
</body>
</html>
"""
return send_email(
to_email=to_email,
subject="E-Mail-Adresse bestaetigen",
html_content=html_content
)
def send_welcome_email(
to_email: str,
user_name: str,
account_type: str,
base_url: str
) -> bool:
"""Send welcome email after registration/onboarding completion."""
login_url = f"{base_url}/login"
if account_type == "company":
account_type_text = "Unternehmens-Konto"
next_steps = """
<ul>
<li>Lade deine Teammitglieder ein</li>
<li>Verfeinere deine Unternehmensstrategie</li>
<li>Beginne mit der Post-Erstellung</li>
</ul>
"""
elif account_type == "employee":
account_type_text = "Mitarbeiter-Konto"
next_steps = """
<ul>
<li>Recherchiere neue Topics</li>
<li>Erstelle deinen ersten Post</li>
</ul>
"""
else:
account_type_text = "Ghostwriter-Konto"
next_steps = """
<ul>
<li>Recherchiere neue Topics</li>
<li>Erstelle deinen ersten Post</li>
<li>Passe deine Einstellungen an</li>
</ul>
"""
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }}
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
.header {{ background: linear-gradient(135deg, #2d3838 0%, #1a2424 100%); color: white; padding: 24px; }}
.header h1 {{ margin: 0; font-size: 20px; }}
.content {{ padding: 24px; }}
.account-badge {{ display: inline-block; padding: 8px 16px; border-radius: 20px; background: #22c55e; color: white; font-weight: 600; margin: 8px 0; }}
.btn {{ display: inline-block; padding: 14px 28px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 14px; background: #ffc700; color: #1a2424; margin-top: 16px; }}
.footer {{ padding: 16px 24px; background: #f8f9fa; font-size: 12px; color: #666; text-align: center; }}
ul {{ padding-left: 20px; }}
li {{ margin: 8px 0; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Willkommen bei LinkedIn Posts!</h1>
</div>
<div class="content">
<p>Hallo {user_name},</p>
<p>Dein Konto wurde erfolgreich eingerichtet!</p>
<p class="account-badge">{account_type_text}</p>
<p><strong>Naechste Schritte:</strong></p>
{next_steps}
<a href="{login_url}" class="btn">Zum Dashboard</a>
</div>
<div class="footer">
Viel Erfolg mit deinen LinkedIn-Posts!
</div>
</div>
</body>
</html>
"""
return send_email(
to_email=to_email,
subject="Willkommen bei LinkedIn Posts!",
html_content=html_content
)
def send_employee_removal_email(
to_email: str,
employee_name: str,
company_name: str
) -> bool:
"""Send email to notify an employee they have been removed from the company."""
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }}
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
.header {{ background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%); color: white; padding: 24px; }}
.header h1 {{ margin: 0; font-size: 20px; }}
.content {{ padding: 24px; }}
.company-badge {{ display: inline-block; padding: 8px 16px; border-radius: 20px; background: #f3f4f6; color: #374151; font-weight: 600; margin: 16px 0; }}
.footer {{ padding: 16px 24px; background: #f8f9fa; font-size: 12px; color: #666; text-align: center; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Konto entfernt</h1>
</div>
<div class="content">
<p>Hallo {employee_name},</p>
<p>dein Konto wurde aus dem Unternehmen entfernt:</p>
<p class="company-badge">{company_name}</p>
<p>Dein Zugang wurde deaktiviert und alle zugehoerigen Daten wurden geloescht.</p>
<p>Falls du Fragen hast, wende dich bitte an deinen ehemaligen Arbeitgeber.</p>
</div>
<div class="footer">
Diese E-Mail wurde automatisch generiert.
</div>
</div>
</body>
</html>
"""
return send_email(
to_email=to_email,
subject=f"Dein Konto bei {company_name} wurde entfernt",
html_content=html_content
)
def send_decision_notification_email(
to_email: str,
post_title: str,
decision: str, # "approved" or "rejected"
base_url: str,
post_id: UUID,
image_url: Optional[str] = None
) -> bool:
"""Send email to creator notifying them of the customer's decision."""
post_url = f"{base_url}/posts/{post_id}"
if decision == "approved":
decision_text = "freigegeben"
decision_color = "#22c55e"
action_text = "Der Post wurde in die Spalte 'Freigegeben' verschoben und kann nun im Kalender eingeplant werden."
else:
decision_text = "zur Überarbeitung zurückgeschickt"
decision_color = "#f59e0b"
action_text = "Der Post wurde zurück in die Spalte 'Vorschläge' verschoben. Bitte überarbeite ihn und sende ihn erneut zur Freigabe."
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }}
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
.header {{ background: linear-gradient(135deg, #2d3838 0%, #1a2424 100%); color: white; padding: 24px; }}
.header h1 {{ margin: 0; font-size: 20px; }}
.content {{ padding: 24px; }}
.decision {{ display: inline-block; padding: 8px 16px; border-radius: 20px; font-weight: 600; color: white; background: {decision_color}; margin: 16px 0; }}
.btn {{ display: inline-block; padding: 14px 28px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 14px; background: #ffc700; color: #1a2424; margin-top: 16px; }}
.footer {{ padding: 16px 24px; background: #f8f9fa; font-size: 12px; color: #666; text-align: center; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Entscheidung zu deinem Post</h1>
</div>
<div class="content">
<p>Hallo,</p>
<p>Der Kunde hat eine Entscheidung zu deinem Post getroffen:</p>
<p><strong>{post_title}</strong></p>
<p class="decision">{decision_text.upper()}</p>
{f'<img src="{image_url}" alt="Post-Bild" style="width: 100%; max-height: 400px; object-fit: cover; border-radius: 8px; margin: 16px 0;" />' if image_url else ''}
<p>{action_text}</p>
<a href="{post_url}" class="btn">Post ansehen</a>
</div>
<div class="footer">
Diese Email wurde automatisch generiert.
</div>
</div>
</body>
</html>
"""
return send_email(
to_email=to_email,
subject=f"Post {decision_text}: {post_title}",
html_content=html_content
)

View File

@@ -0,0 +1,314 @@
"""
LinkedIn API service for auto-posting.
Handles:
- UGC Posts API (v2) for posting content
- OAuth token refresh
- Image upload for posts with media
"""
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from typing import Dict, Optional
from uuid import UUID
import httpx
from src.config import settings
from src.database.client import db
from src.utils.encryption import decrypt_token, encrypt_token
logger = logging.getLogger(__name__)
class LinkedInService:
"""Service for LinkedIn API interactions."""
BASE_URL = "https://api.linkedin.com/v2"
TIMEOUT = 30.0
def __init__(self):
self.db = db
async def post_to_linkedin(
self,
linkedin_account_id: UUID,
text: str,
image_url: Optional[str] = None
) -> Dict:
"""
Post content to LinkedIn using UGC Posts API.
Args:
linkedin_account_id: ID of the linkedin_accounts record
text: Post text content
image_url: Optional image URL from Supabase storage
Returns:
Dict with 'url' key containing the LinkedIn post URL
Raises:
Exception: On API errors
"""
# Get account with decrypted token
account = await self._get_account_with_token(linkedin_account_id)
if not account:
raise ValueError(f"LinkedIn account {linkedin_account_id} not found")
# Check token expiry
if account['token_expires_at'] < datetime.now(timezone.utc):
raise ValueError("Access token expired - refresh needed")
access_token = account['access_token']
linkedin_user_id = account['linkedin_user_id']
# Build post payload
payload = {
"author": f"urn:li:person:{linkedin_user_id}",
"lifecycleState": "PUBLISHED",
"specificContent": {
"com.linkedin.ugc.ShareContent": {
"shareCommentary": {
"text": text
},
"shareMediaCategory": "NONE"
}
},
"visibility": {
"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
}
}
# Handle image upload if provided
if image_url:
try:
media_urn = await self._upload_image(access_token, linkedin_user_id, image_url)
if media_urn:
payload["specificContent"]["com.linkedin.ugc.ShareContent"]["shareMediaCategory"] = "IMAGE"
payload["specificContent"]["com.linkedin.ugc.ShareContent"]["media"] = [
{
"status": "READY",
"media": media_urn
}
]
except Exception as e:
logger.warning(f"Image upload failed for {linkedin_account_id}: {e}. Posting without image.")
# Post to LinkedIn
async with httpx.AsyncClient(timeout=self.TIMEOUT) as client:
response = await client.post(
f"{self.BASE_URL}/ugcPosts",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
"X-Restli-Protocol-Version": "2.0.0"
},
json=payload
)
if response.status_code == 201:
# Success
post_id = response.json().get("id")
await self._update_account_last_used(linkedin_account_id)
# LinkedIn post URL format (best effort)
post_url = f"https://www.linkedin.com/feed/update/{post_id}/" if post_id else None
logger.info(f"Posted to LinkedIn: {post_url}")
return {"url": post_url, "post_id": post_id}
elif response.status_code == 401:
# Token expired or invalid
await self._mark_account_error(
linkedin_account_id,
f"Token expired or invalid (401)"
)
raise Exception("LinkedIn authentication failed - token may be expired")
elif response.status_code == 429:
# Rate limit
await self._mark_account_error(
linkedin_account_id,
"Rate limit exceeded (429)"
)
raise Exception("LinkedIn rate limit exceeded")
else:
# Other error
error_msg = f"LinkedIn API error {response.status_code}: {response.text}"
await self._mark_account_error(linkedin_account_id, error_msg)
raise Exception(error_msg)
async def _upload_image(
self,
access_token: str,
linkedin_user_id: str,
image_url: str
) -> Optional[str]:
"""
Upload image to LinkedIn for use in post.
Returns media URN if successful, None otherwise.
"""
try:
# Step 1: Register upload
register_payload = {
"registerUploadRequest": {
"recipes": ["urn:li:digitalmediaRecipe:feedshare-image"],
"owner": f"urn:li:person:{linkedin_user_id}",
"serviceRelationships": [
{
"relationshipType": "OWNER",
"identifier": "urn:li:userGeneratedContent"
}
]
}
}
async with httpx.AsyncClient(timeout=self.TIMEOUT) as client:
register_response = await client.post(
f"{self.BASE_URL}/assets?action=registerUpload",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json"
},
json=register_payload
)
if register_response.status_code != 200:
logger.error(f"Image register failed: {register_response.status_code}")
return None
register_data = register_response.json()
upload_url = register_data["value"]["uploadMechanism"]["com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest"]["uploadUrl"]
asset_urn = register_data["value"]["asset"]
# Step 2: Download image from Supabase
image_response = await client.get(image_url)
if image_response.status_code != 200:
logger.error(f"Failed to download image from {image_url}")
return None
image_data = image_response.content
# Step 3: Upload to LinkedIn
upload_response = await client.put(
upload_url,
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/octet-stream"
},
content=image_data
)
if upload_response.status_code in [200, 201]:
logger.info(f"Image uploaded successfully: {asset_urn}")
return asset_urn
else:
logger.error(f"Image upload failed: {upload_response.status_code}")
return None
except Exception as e:
logger.error(f"Image upload error: {e}")
return None
async def refresh_access_token(self, linkedin_account_id: UUID) -> bool:
"""
Attempt to refresh the access token using refresh token.
Note: LinkedIn's OAuth 2.0 may not support refresh tokens for all scopes.
Check LinkedIn documentation for current support.
Returns:
True if refresh succeeded, False otherwise
"""
account = await self._get_account_with_token(linkedin_account_id)
if not account or not account.get('refresh_token'):
logger.warning(f"No refresh token for account {linkedin_account_id}")
return False
refresh_token = account['refresh_token']
try:
async with httpx.AsyncClient(timeout=self.TIMEOUT) as client:
response = await client.post(
"https://www.linkedin.com/oauth/v2/accessToken",
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": settings.linkedin_client_id,
"client_secret": settings.linkedin_client_secret
},
headers={"Content-Type": "application/x-www-form-urlencoded"}
)
if response.status_code == 200:
token_data = response.json()
new_access_token = token_data["access_token"]
expires_in = token_data.get("expires_in", 5184000) # Default 60 days
# Encrypt and update tokens
encrypted_access = encrypt_token(new_access_token)
new_refresh = token_data.get("refresh_token")
encrypted_refresh = encrypt_token(new_refresh) if new_refresh else account['refresh_token']
await self.db.update_linkedin_account(
linkedin_account_id,
{
"access_token": encrypted_access,
"refresh_token": encrypted_refresh,
"token_expires_at": datetime.now(timezone.utc) + timedelta(seconds=expires_in),
"last_error": None,
"last_error_at": None
}
)
logger.info(f"Token refreshed for account {linkedin_account_id}")
return True
else:
logger.error(f"Token refresh failed: {response.status_code} - {response.text}")
return False
except Exception as e:
logger.error(f"Token refresh error: {e}")
return False
async def _get_account_with_token(self, linkedin_account_id: UUID) -> Optional[Dict]:
"""Get account and decrypt tokens."""
account = await self.db.get_linkedin_account_by_id(linkedin_account_id)
if not account:
return None
# Decrypt tokens
account_dict = account.model_dump()
account_dict['access_token'] = decrypt_token(account.access_token)
if account.refresh_token:
account_dict['refresh_token'] = decrypt_token(account.refresh_token)
return account_dict
async def _update_account_last_used(self, linkedin_account_id: UUID):
"""Update last_used_at timestamp."""
await self.db.update_linkedin_account(
linkedin_account_id,
{
"last_used_at": datetime.now(timezone.utc),
"last_error": None,
"last_error_at": None
}
)
async def _mark_account_error(self, linkedin_account_id: UUID, error_msg: str):
"""Mark account with error."""
await self.db.update_linkedin_account(
linkedin_account_id,
{
"last_error": error_msg,
"last_error_at": datetime.now(timezone.utc)
}
)
# Global instance
linkedin_service = LinkedInService()

View File

@@ -0,0 +1,288 @@
"""Background scheduler for handling scheduled posts.
This service runs in the background and:
1. Checks for posts that are due for publishing
2. Marks them as published
3. Sends notification emails to employees
Future extension: Could integrate with LinkedIn API for automatic posting.
"""
import asyncio
from datetime import datetime, timezone
from typing import Optional
from loguru import logger
from src.database.client import DatabaseClient
from src.services.email_service import send_email
class SchedulerService:
"""Background scheduler for post publishing."""
def __init__(self, db: DatabaseClient, check_interval_seconds: int = 60):
"""Initialize the scheduler.
Args:
db: Database client instance
check_interval_seconds: How often to check for due posts (default: 60s)
"""
self.db = db
self.check_interval = check_interval_seconds
self._running = False
self._task: Optional[asyncio.Task] = None
async def start(self):
"""Start the background scheduler."""
if self._running:
logger.warning("Scheduler already running")
return
self._running = True
self._task = asyncio.create_task(self._run_loop())
logger.info(f"Scheduler started (checking every {self.check_interval}s)")
async def stop(self):
"""Stop the background scheduler."""
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
logger.info("Scheduler stopped")
async def _run_loop(self):
"""Main scheduler loop."""
while self._running:
try:
await self._process_due_posts()
except Exception as e:
logger.error(f"Scheduler error: {e}")
await asyncio.sleep(self.check_interval)
async def _process_due_posts(self):
"""Process all posts that are due for publishing."""
due_posts = await self.db.get_scheduled_posts_due()
if not due_posts:
return
logger.info(f"Processing {len(due_posts)} due posts")
for post in due_posts:
try:
await self._publish_post(post)
except Exception as e:
logger.error(f"Failed to publish post {post.id}: {e}")
async def _publish_post(self, post):
"""Publish a single post - with LinkedIn API if account linked, otherwise email."""
from uuid import UUID
# Get LinkedIn account for user
linkedin_account = await self.db.get_linkedin_account(post.user_id)
if linkedin_account:
# User has LinkedIn account linked -> Try auto-posting
try:
await self._auto_post_to_linkedin(post, linkedin_account)
return # Success - no need for email fallback
except Exception as e:
logger.error(f"LinkedIn auto-post failed for post {post.id}: {e}")
# Fall through to email notification
# No LinkedIn account or auto-post failed -> Send email notification
await self._publish_post_via_email(post)
async def _auto_post_to_linkedin(self, post, linkedin_account):
"""Auto-post to LinkedIn using linked account."""
from src.services.linkedin_service import linkedin_service
from src.utils.encryption import decrypt_token
# Check token expiry
if linkedin_account.token_expires_at < datetime.now(timezone.utc):
logger.warning(f"LinkedIn token expired for account {linkedin_account.id}")
# Try to refresh token
refreshed = await linkedin_service.refresh_access_token(linkedin_account.id)
if not refreshed:
# Token refresh failed -> Fall back to email
raise Exception("Token expired and refresh failed")
# Post to LinkedIn
result = await linkedin_service.post_to_linkedin(
linkedin_account_id=linkedin_account.id,
text=post.post_content,
image_url=post.image_url
)
# Update post as published with LinkedIn metadata
await self.db.update_generated_post(
post.id,
{
"status": "published",
"published_at": datetime.now(timezone.utc),
"metadata": {
**(post.metadata or {}),
"linkedin_post_url": result.get("url"),
"auto_posted": True,
"posted_at": datetime.now(timezone.utc).isoformat()
}
}
)
logger.info(f"✅ Post {post.id} auto-posted to LinkedIn: {result.get('url')}")
# Send success notification
profile = await self.db.get_profile(post.user_id)
if profile:
await self._send_auto_post_success_notification(post, profile, result)
async def _publish_post_via_email(self, post):
"""Fallback: Mark as published and send email notification."""
# Update post status to published
await self.db.update_generated_post(
post.id,
{
"status": "published",
"published_at": datetime.now(timezone.utc)
}
)
logger.info(f"Post {post.id} marked as published (email notification)")
# Get profile info for notification
profile = await self.db.get_profile(post.user_id)
if not profile:
logger.warning(f"No profile found for post {post.id}")
return
# Send notification email
await self._send_publish_notification(post, profile)
async def _send_publish_notification(self, post, profile):
"""Send email notification that a post is ready to be published.
Future: When LinkedIn API is integrated, this could instead
confirm that the post was automatically published.
"""
# Try to get employee email
recipient_email = profile.creator_email or profile.customer_email
if not recipient_email:
logger.warning(f"No email for profile {profile.id}, skipping notification")
return
subject = "LinkedIn Post bereit zum Veröffentlichen"
display_name = profile.display_name or "dort"
html_content = f"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #0a66c2;">Dein LinkedIn Post ist bereit! 🚀</h2>
<p>Hallo {display_name},</p>
<p>Dein geplanter LinkedIn Post ist jetzt zur Veröffentlichung bereit:</p>
<div style="background: #f3f4f6; border-radius: 8px; padding: 20px; margin: 20px 0;">
<p style="font-weight: bold; margin-bottom: 10px;">Thema: {post.topic_title}</p>
<div style="background: white; border-radius: 4px; padding: 15px; white-space: pre-wrap; font-size: 14px; line-height: 1.5;">
{post.post_content[:500]}{'...' if len(post.post_content) > 500 else ''}
</div>
{f'<img src="{post.image_url}" alt="Post-Bild" style="width: 100%; max-height: 400px; object-fit: cover; border-radius: 8px; margin-top: 15px;" />' if post.image_url else ''}
</div>
<p><strong>Nächste Schritte:</strong></p>
<ol>
<li>Öffne LinkedIn</li>
<li>Erstelle einen neuen Post</li>
<li>Kopiere den Text oben und füge ihn ein</li>
<li>Veröffentliche den Post</li>
</ol>
<p style="color: #666; font-size: 12px; margin-top: 30px;">
Diese Nachricht wurde automatisch generiert.
</p>
</div>
"""
try:
send_email(
to_email=recipient_email,
subject=subject,
html_content=html_content
)
logger.info(f"Publish notification sent to {recipient_email}")
except Exception as e:
logger.error(f"Failed to send publish notification: {e}")
async def _send_auto_post_success_notification(self, post, profile, result):
"""Send success email after auto-posting to LinkedIn."""
recipient_email = profile.creator_email or profile.customer_email
if not recipient_email:
logger.warning(f"No email for profile {profile.id}, skipping success notification")
return
subject = "✅ LinkedIn Post automatisch veröffentlicht"
display_name = profile.display_name or "dort"
linkedin_url = result.get('url', 'https://www.linkedin.com/feed/')
html_content = f"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #0a66c2;">Post erfolgreich veröffentlicht! 🎉</h2>
<p>Hallo {display_name},</p>
<p>Dein geplanter LinkedIn Post wurde automatisch veröffentlicht:</p>
<div style="background: #f3f4f6; border-radius: 8px; padding: 20px; margin: 20px 0;">
<p style="font-weight: bold; margin-bottom: 10px;">Thema: {post.topic_title}</p>
<div style="background: white; border-radius: 4px; padding: 15px; white-space: pre-wrap; font-size: 14px; line-height: 1.5;">
{post.post_content[:500]}{'...' if len(post.post_content) > 500 else ''}
</div>
{f'<img src="{post.image_url}" alt="Post-Bild" style="width: 100%; max-height: 400px; object-fit: cover; border-radius: 8px; margin-top: 15px;" />' if post.image_url else ''}
</div>
<p>
<a href="{linkedin_url}" style="display: inline-block; background: #0a66c2; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">
Auf LinkedIn ansehen →
</a>
</p>
<p style="color: #666; margin-top: 30px;">
Dein Post wurde automatisch über dein verbundenes LinkedIn-Konto veröffentlicht.
</p>
<p style="color: #666; font-size: 12px; margin-top: 30px;">
Diese Nachricht wurde automatisch generiert.
</p>
</div>
"""
try:
send_email(
to_email=recipient_email,
subject=subject,
html_content=html_content
)
logger.info(f"Auto-post success notification sent to {recipient_email}")
except Exception as e:
logger.error(f"Failed to send success notification: {e}")
# Global scheduler instance
_scheduler: Optional[SchedulerService] = None
def get_scheduler() -> Optional[SchedulerService]:
"""Get the global scheduler instance."""
return _scheduler
def init_scheduler(db: DatabaseClient, check_interval: int = 60) -> SchedulerService:
"""Initialize and return the global scheduler instance."""
global _scheduler
_scheduler = SchedulerService(db, check_interval)
return _scheduler

View File

@@ -0,0 +1,103 @@
"""Supabase Storage service for post image uploads."""
import asyncio
import uuid
from typing import Optional
from loguru import logger
from src.config import settings
ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
BUCKET_NAME = "post-images"
CONTENT_TYPE_EXTENSIONS = {
"image/jpeg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
}
class StorageService:
"""Handles image uploads to Supabase Storage."""
def __init__(self):
from supabase import create_client
key = settings.supabase_service_role_key or settings.supabase_key
self.client = create_client(settings.supabase_url, key)
self._bucket_ensured = False
def _ensure_bucket(self):
"""Create the post-images bucket if it doesn't exist."""
if self._bucket_ensured:
return
try:
self.client.storage.get_bucket(BUCKET_NAME)
except Exception:
try:
self.client.storage.create_bucket(
BUCKET_NAME,
options={"public": True}
)
logger.info(f"Created storage bucket: {BUCKET_NAME}")
except Exception as e:
if "already exists" in str(e).lower() or "Duplicate" in str(e):
pass
else:
logger.error(f"Failed to create bucket: {e}")
raise
self._bucket_ensured = True
async def upload_image(
self,
file_content: bytes,
content_type: str,
user_id: str,
) -> str:
"""Upload an image to Supabase Storage.
Returns the public URL of the uploaded image.
"""
if content_type not in ALLOWED_CONTENT_TYPES:
raise ValueError(f"Unzulässiger Dateityp: {content_type}. Erlaubt: JPEG, PNG, GIF, WebP")
if len(file_content) > MAX_FILE_SIZE:
raise ValueError(f"Datei zu groß (max. {MAX_FILE_SIZE // 1024 // 1024} MB)")
ext = CONTENT_TYPE_EXTENSIONS[content_type]
file_name = f"{user_id}/{uuid.uuid4()}.{ext}"
def _upload():
self._ensure_bucket()
self.client.storage.from_(BUCKET_NAME).upload(
path=file_name,
file=file_content,
file_options={"content-type": content_type},
)
await asyncio.to_thread(_upload)
public_url = f"{settings.supabase_url}/storage/v1/object/public/{BUCKET_NAME}/{file_name}"
logger.info(f"Uploaded image: {file_name}")
return public_url
async def delete_image(self, image_url: str) -> None:
"""Delete an image from Supabase Storage by its public URL."""
prefix = f"{settings.supabase_url}/storage/v1/object/public/{BUCKET_NAME}/"
if not image_url.startswith(prefix):
logger.warning(f"Cannot delete image, URL doesn't match bucket: {image_url}")
return
file_path = image_url[len(prefix):]
def _delete():
self._ensure_bucket()
self.client.storage.from_(BUCKET_NAME).remove([file_path])
await asyncio.to_thread(_delete)
logger.info(f"Deleted image: {file_path}")
# Global singleton
storage = StorageService()

View File

@@ -717,7 +717,7 @@ class StatusScreen(Screen):
output = ""
for customer in customers:
status = await orchestrator.get_customer_status(customer.id)
status = await orchestrator.get_user_status(customer.id)
output += f"[bold cyan]╔═══ {customer.name} ═══╗[/]\n"
output += f"[bold]Customer ID:[/] {customer.id}\n"

1
src/utils/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Utility modules."""

38
src/utils/encryption.py Normal file
View File

@@ -0,0 +1,38 @@
"""Token encryption utilities using Fernet symmetric encryption."""
from cryptography.fernet import Fernet
from src.config import settings
def encrypt_token(token: str) -> str:
"""
Encrypt a token string using Fernet symmetric encryption.
Args:
token: The plaintext token to encrypt
Returns:
Base64-encoded encrypted token string
"""
if not settings.encryption_key:
raise ValueError("ENCRYPTION_KEY not configured in environment")
cipher = Fernet(settings.encryption_key.encode())
return cipher.encrypt(token.encode()).decode()
def decrypt_token(encrypted: str) -> str:
"""
Decrypt a Fernet-encrypted token string.
Args:
encrypted: The base64-encoded encrypted token
Returns:
The decrypted plaintext token
"""
if not settings.encryption_key:
raise ValueError("ENCRYPTION_KEY not configured in environment")
cipher = Fernet(settings.encryption_key.encode())
return cipher.decrypt(encrypted.encode()).decode()

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,47 @@
"""FastAPI web frontend for LinkedIn Post Creation System."""
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import RedirectResponse
from loguru import logger
from src.config import settings
from src.web.admin import admin_router
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Manage application lifecycle - startup and shutdown."""
# Startup
logger.info("Starting LinkedIn Post Creation System...")
# Initialize and start scheduler if enabled
scheduler = None
if settings.user_frontend_enabled:
try:
from src.database.client import DatabaseClient
from src.services.scheduler_service import init_scheduler
db = DatabaseClient()
scheduler = init_scheduler(db, check_interval=60) # Check every 60 seconds
await scheduler.start()
logger.info("Scheduler service started")
except Exception as e:
logger.error(f"Failed to start scheduler: {e}")
yield # Application runs here
# Shutdown
logger.info("Shutting down LinkedIn Post Creation System...")
if scheduler:
await scheduler.stop()
logger.info("Scheduler service stopped")
# Setup
app = FastAPI(title="LinkedIn Post Creation System")
app = FastAPI(title="LinkedIn Post Creation System", lifespan=lifespan)
# Static files
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Admin - LinkedIn Posts{% endblock %}</title>
<title>{% block title %}Admin Panel{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
@@ -26,7 +26,6 @@
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; }
@@ -55,33 +54,19 @@
</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 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 == 'users' %}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="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>
Nutzerverwaltung
</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 %}">
<a href="/admin/statistics" 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 == 'statistics' %}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
Statistiken
</a>
<a href="/admin/license-keys" 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 == 'license_keys' %}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="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
Lizenzschlüssel
</a>
</nav>

View File

@@ -1,539 +0,0 @@
{% 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 %}

View File

@@ -1,10 +1,10 @@
{% extends "base.html" %}
{% block title %}Dashboard - LinkedIn Posts{% endblock %}
{% block title %}Nutzerverwaltung - Admin{% 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>
<h1 class="text-3xl font-bold text-white mb-2">Nutzerverwaltung</h1>
<p class="text-gray-400">Alle Benutzer und Firmen verwalten</p>
</div>
{% if error %}
@@ -13,85 +13,277 @@
</div>
{% endif %}
<!-- Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<!-- Summary Cards -->
<div class="grid grid-cols-1 md:grid-cols-4 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>
<p class="text-gray-400 text-sm">Gesamt Nutzer</p>
<p class="text-2xl font-bold text-white">{{ total_users }}</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-blue-600/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>
</div>
<div>
<p class="text-gray-400 text-sm">Firmen</p>
<p class="text-2xl font-bold text-white">{{ total_companies }}</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>
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></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>
<p class="text-gray-400 text-sm">Ghostwriter</p>
<p class="text-2xl font-bold text-white">{{ total_ghostwriters }}</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 class="w-12 h-12 bg-purple-600/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z"/></svg>
</div>
<div>
<p class="text-gray-400 text-sm">AI Agents</p>
<p class="text-2xl font-bold text-white">5</p>
<p class="text-gray-400 text-sm">Mitarbeiter</p>
<p class="text-2xl font-bold text-white">{{ total_employees }}</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>
<!-- Search -->
<div class="mb-6">
<input type="text" id="searchInput" placeholder="Suche nach Name oder E-Mail..."
class="w-full max-w-md input-bg border rounded-lg px-4 py-2 text-white placeholder-gray-400"
oninput="filterUsers()">
</div>
<!-- Tabs -->
<div class="flex gap-2 mb-6">
<button onclick="showTab('companies')" id="tab-companies"
class="px-5 py-2 rounded-lg font-medium transition-colors btn-primary">
Firmen ({{ total_companies }})
</button>
<button onclick="showTab('ghostwriters')" id="tab-ghostwriters"
class="px-5 py-2 rounded-lg font-medium transition-colors bg-brand-bg-light text-gray-300 hover:bg-brand-bg">
Ghostwriter ({{ total_ghostwriters }})
</button>
</div>
<!-- Companies Tab -->
<div id="content-companies" class="space-y-4">
{% for cd in company_data %}
<div class="card-bg rounded-xl border overflow-hidden user-card" data-search="{{ cd.company.name|lower }} {{ cd.owner.email|lower if cd.owner else '' }} {{ cd.owner.display_name|lower if cd.owner and cd.owner.display_name else '' }}">
<!-- Company Header (clickable to expand) -->
<div class="p-5 cursor-pointer hover:bg-brand-bg-light/50 transition-colors" onclick="toggleCompany('company-{{ cd.company.id }}')">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-10 h-10 bg-blue-600/30 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>
</div>
<div>
<h3 class="text-lg font-semibold text-white">{{ cd.company.name }}</h3>
<p class="text-sm text-gray-400">
Owner: {{ cd.owner.display_name or cd.owner.email if cd.owner else 'Unbekannt' }}
| {{ cd.employee_count }} Mitarbeiter
</p>
</div>
</div>
<div class="flex items-center gap-3">
{% if cd.owner %}
<a href="/admin/impersonate/{{ cd.owner.id }}" class="px-3 py-1.5 text-xs bg-brand-highlight/20 text-brand-highlight rounded-lg hover:bg-brand-highlight/30 transition-colors"
title="Als Owner einloggen" onclick="event.stopPropagation()">
Impersonate
</a>
{% endif %}
<svg class="w-5 h-5 text-gray-400 transition-transform" id="chevron-company-{{ cd.company.id }}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</div>
</div>
<div>
<p class="font-medium text-white">Neuer Kunde</p>
<p class="text-sm text-gray-400">Setup starten</p>
</div>
<!-- Expandable Details -->
<div id="company-{{ cd.company.id }}" class="hidden border-t border-gray-600">
<!-- Owner -->
{% if cd.owner %}
<div class="px-5 py-3 bg-brand-bg/30 border-b border-gray-600/50">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-xs font-medium px-2 py-0.5 bg-blue-600/30 text-blue-300 rounded">OWNER</span>
<span class="text-sm text-white">{{ cd.owner.display_name or cd.owner.email }}</span>
<span class="text-xs text-gray-400">{{ cd.owner.email }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400">{{ cd.owner.onboarding_status.value if cd.owner.onboarding_status and cd.owner.onboarding_status.value else cd.owner.onboarding_status }}</span>
<button onclick="deleteUser('{{ cd.owner.id }}', '{{ cd.owner.display_name or cd.owner.email }}')"
class="text-red-400 hover:text-red-300 p-1" title="Nutzer löschen">
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
</div>
</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>
{% endif %}
<!-- Employees -->
{% for emp in cd.employees %}
<div class="px-5 py-3 border-b border-gray-600/30 hover:bg-brand-bg/20">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-xs font-medium px-2 py-0.5 bg-purple-600/30 text-purple-300 rounded">EMPLOYEE</span>
<span class="text-sm text-white">{{ emp.display_name or emp.email }}</span>
<span class="text-xs text-gray-400">{{ emp.email }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400">{{ emp.onboarding_status.value if emp.onboarding_status and emp.onboarding_status.value else emp.onboarding_status }}</span>
<a href="/admin/impersonate/{{ emp.id }}" class="text-brand-highlight hover:text-brand-highlight-dark p-1" title="Als Mitarbeiter einloggen">
<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="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"/></svg>
</a>
<button onclick="deleteUser('{{ emp.id }}', '{{ emp.display_name or emp.email }}')"
class="text-red-400 hover:text-red-300 p-1" title="Nutzer löschen">
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
</div>
</div>
<div>
<p class="font-medium text-white">Research</p>
<p class="text-sm text-gray-400">Topics finden</p>
{% endfor %}
{% if cd.employees|length == 0 %}
<div class="px-5 py-3 text-sm text-gray-500 italic">Keine Mitarbeiter</div>
{% endif %}
</div>
</div>
{% endfor %}
{% if company_data|length == 0 %}
<div class="card-bg rounded-xl border p-8 text-center text-gray-400">
Keine Firmen vorhanden
</div>
{% endif %}
</div>
<!-- Ghostwriters Tab -->
<div id="content-ghostwriters" class="hidden space-y-3">
{% for gw in ghostwriters %}
{% set profile = gw %}
<div class="card-bg rounded-xl border p-5 user-card" data-search="{{ gw.display_name|lower if gw.display_name else '' }} {{ gw.email|lower }}">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-10 h-10 bg-green-600/30 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-green-400" 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>
</div>
<div>
<h3 class="text-white font-medium">{{ gw.display_name or gw.email }}</h3>
<p class="text-sm text-gray-400">
{{ gw.email }}
| Status: {{ gw.onboarding_status.value if gw.onboarding_status and gw.onboarding_status.value else gw.onboarding_status }}
</p>
</div>
</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 class="flex items-center gap-2">
<a href="/admin/impersonate/{{ gw.id }}" class="px-3 py-1.5 text-xs bg-brand-highlight/20 text-brand-highlight rounded-lg hover:bg-brand-highlight/30 transition-colors">
Impersonate
</a>
<button onclick="deleteUser('{{ gw.id }}', '{{ gw.display_name or gw.email }}')"
class="text-red-400 hover:text-red-300 p-1.5 rounded-lg hover:bg-red-400/10 transition-colors" title="Nutzer löschen">
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</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>
{% endfor %}
{% if ghostwriters|length == 0 %}
<div class="card-bg rounded-xl border p-8 text-center text-gray-400">
Keine Ghostwriter vorhanden
</div>
{% endif %}
</div>
<!-- Delete Confirmation Modal -->
<div id="deleteModal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="card-bg rounded-xl border p-6 max-w-md w-full mx-4">
<h3 class="text-lg font-semibold text-white mb-2">Nutzer löschen</h3>
<p class="text-gray-300 mb-4">Bist du sicher, dass du <strong id="deleteUserName" class="text-red-400"></strong> und alle zugehörigen Daten löschen möchtest? Dies kann nicht rückgängig gemacht werden.</p>
<div class="flex justify-end gap-3">
<button onclick="closeDeleteModal()" class="px-4 py-2 rounded-lg bg-brand-bg-light text-gray-300 hover:bg-brand-bg transition-colors">Abbrechen</button>
<button onclick="confirmDelete()" class="px-4 py-2 rounded-lg bg-red-600 text-white hover:bg-red-700 transition-colors">Löschen</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let currentTab = 'companies';
let deleteUserId = null;
function showTab(tab) {
document.getElementById('content-companies').classList.toggle('hidden', tab !== 'companies');
document.getElementById('content-ghostwriters').classList.toggle('hidden', tab !== 'ghostwriters');
document.getElementById('tab-companies').className = tab === 'companies'
? 'px-5 py-2 rounded-lg font-medium transition-colors btn-primary'
: 'px-5 py-2 rounded-lg font-medium transition-colors bg-brand-bg-light text-gray-300 hover:bg-brand-bg';
document.getElementById('tab-ghostwriters').className = tab === 'ghostwriters'
? 'px-5 py-2 rounded-lg font-medium transition-colors btn-primary'
: 'px-5 py-2 rounded-lg font-medium transition-colors bg-brand-bg-light text-gray-300 hover:bg-brand-bg';
currentTab = tab;
filterUsers();
}
function toggleCompany(id) {
const el = document.getElementById(id);
const chevron = document.getElementById('chevron-' + id);
el.classList.toggle('hidden');
chevron.style.transform = el.classList.contains('hidden') ? '' : 'rotate(180deg)';
}
function filterUsers() {
const query = document.getElementById('searchInput').value.toLowerCase().trim();
const container = document.getElementById('content-' + currentTab);
const cards = container.querySelectorAll('.user-card');
cards.forEach(card => {
const search = card.getAttribute('data-search') || '';
card.style.display = !query || search.includes(query) ? '' : 'none';
});
}
function deleteUser(userId, name) {
deleteUserId = userId;
document.getElementById('deleteUserName').textContent = name;
document.getElementById('deleteModal').classList.remove('hidden');
}
function closeDeleteModal() {
document.getElementById('deleteModal').classList.add('hidden');
deleteUserId = null;
}
async function confirmDelete() {
if (!deleteUserId) return;
try {
const resp = await fetch(`/admin/api/users/${deleteUserId}`, { method: 'DELETE' });
if (resp.ok) {
window.location.reload();
} else {
const data = await resp.json();
alert('Fehler: ' + (data.detail || 'Unbekannter Fehler'));
}
} catch (e) {
alert('Fehler: ' + e.message);
}
closeDeleteModal();
}
</script>
{% endblock %}

View File

@@ -0,0 +1,304 @@
{% extends "base.html" %}
{% block title %}Lizenzschlüssel - Admin{% endblock %}
{% block content %}
<div class="mb-8">
<h1 class="text-3xl font-bold text-white mb-2">Lizenzschlüssel</h1>
<p class="text-gray-400">Verwalte Lizenzschlüssel für Unternehmensregistrierungen</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 %}
<!-- Summary Cards -->
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="card-bg rounded-xl border p-6">
<p class="text-gray-400 text-sm mb-1">Gesamt</p>
<p class="text-3xl font-bold text-white">{{ total_keys }}</p>
</div>
<div class="card-bg rounded-xl border p-6">
<p class="text-gray-400 text-sm mb-1">Verfügbar</p>
<p class="text-3xl font-bold text-green-400">{{ available_keys }}</p>
</div>
<div class="card-bg rounded-xl border p-6">
<p class="text-gray-400 text-sm mb-1">Verwendet</p>
<p class="text-3xl font-bold text-gray-500">{{ used_keys }}</p>
</div>
</div>
<!-- Generate Button -->
<button onclick="openGenerateModal()"
class="px-6 py-3 rounded-lg font-medium transition-colors btn-primary mb-6 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 4v16m8-8H4"/>
</svg>
Neuen Schlüssel generieren
</button>
<!-- License Keys Table -->
<div class="card-bg rounded-xl border overflow-hidden">
<table class="w-full">
<thead class="bg-brand-bg-dark border-b border-brand-bg-light">
<tr>
<th class="px-6 py-4 text-left text-xs font-medium text-gray-400 uppercase">Schlüssel</th>
<th class="px-6 py-4 text-left text-xs font-medium text-gray-400 uppercase">Limits</th>
<th class="px-6 py-4 text-left text-xs font-medium text-gray-400 uppercase">Status</th>
<th class="px-6 py-4 text-left text-xs font-medium text-gray-400 uppercase">Erstellt</th>
<th class="px-6 py-4 text-right text-xs font-medium text-gray-400 uppercase">Aktionen</th>
</tr>
</thead>
<tbody class="divide-y divide-brand-bg-light">
{% for key in keys %}
<tr class="hover:bg-brand-bg/30">
<td class="px-6 py-4">
<div class="font-mono text-white font-medium">{{ key.key }}</div>
{% if key.description %}
<div class="text-sm text-gray-400 mt-1">{{ key.description }}</div>
{% endif %}
</td>
<td class="px-6 py-4">
<div class="text-sm text-gray-300 space-y-1">
<div>👥 {{ key.max_employees }} Mitarbeiter</div>
<div>📝 {{ key.max_posts_per_day }} Posts/Tag</div>
<div>🔍 {{ key.max_researches_per_day }} Researches/Tag</div>
</div>
</td>
<td class="px-6 py-4">
{% if key.used %}
<span class="px-3 py-1 bg-gray-600/30 text-gray-400 rounded-lg text-sm">
Verwendet
</span>
{% else %}
<span class="px-3 py-1 bg-green-600/30 text-green-400 rounded-lg text-sm">
Verfügbar
</span>
{% endif %}
</td>
<td class="px-6 py-4 text-sm text-gray-400">
{{ key.created_at.strftime('%d.%m.%Y') if key.created_at else '-' }}
</td>
<td class="px-6 py-4 text-right">
<button onclick="editKey('{{ key.id }}', {{ key.max_employees }}, {{ key.max_posts_per_day }}, {{ key.max_researches_per_day }}, '{{ key.description or '' }}')"
class="text-blue-400 hover:text-blue-300 p-2 rounded transition-colors"
title="Bearbeiten">
<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>
</button>
{% if not key.used %}
<button onclick="copyKey('{{ key.key }}')"
class="text-brand-highlight hover:text-brand-highlight-dark p-2 rounded transition-colors"
title="Kopieren">
<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="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>
</button>
<button onclick="deleteKey('{{ key.id }}')"
class="text-red-400 hover:text-red-300 p-2 rounded transition-colors"
title="Löschen">
<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 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Edit Modal -->
<div id="editModal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="card-bg rounded-xl border p-8 max-w-md w-full mx-4">
<h2 class="text-2xl font-bold text-white mb-6">Lizenzschlüssel bearbeiten</h2>
<form id="editForm" class="space-y-4">
<input type="hidden" id="edit_key_id" name="key_id">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Max. Mitarbeiter
</label>
<input type="number" id="edit_max_employees" name="max_employees" min="1" required
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Max. Posts pro Tag
</label>
<input type="number" id="edit_max_posts_per_day" name="max_posts_per_day" min="1" required
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Max. Researches pro Tag
</label>
<input type="number" id="edit_max_researches_per_day" name="max_researches_per_day" min="1" required
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Beschreibung (optional)
</label>
<input type="text" id="edit_description" name="description" placeholder="z.B. Starter Plan, Premium Plan..."
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
</div>
<div class="flex gap-3 pt-4">
<button type="submit" class="flex-1 px-6 py-3 btn-primary rounded-lg font-medium">
Speichern
</button>
<button type="button" onclick="closeEditModal()"
class="px-6 py-3 bg-gray-600 hover:bg-gray-700 text-white rounded-lg font-medium transition-colors">
Abbrechen
</button>
</div>
</form>
</div>
</div>
<!-- Generate Modal -->
<div id="generateModal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="card-bg rounded-xl border p-8 max-w-md w-full mx-4">
<h2 class="text-2xl font-bold text-white mb-6">Neuen Schlüssel generieren</h2>
<form id="generateForm" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Max. Mitarbeiter
</label>
<input type="number" name="max_employees" min="1" value="5" required
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Max. Posts pro Tag
</label>
<input type="number" name="max_posts_per_day" min="1" value="10" required
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Max. Researches pro Tag
</label>
<input type="number" name="max_researches_per_day" min="1" value="5" required
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Beschreibung (optional)
</label>
<input type="text" name="description" placeholder="z.B. Starter Plan, Premium Plan..."
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
</div>
<div class="flex gap-3 pt-4">
<button type="submit" class="flex-1 px-6 py-3 btn-primary rounded-lg font-medium">
Generieren
</button>
<button type="button" onclick="closeGenerateModal()"
class="px-6 py-3 bg-gray-600 hover:bg-gray-700 text-white rounded-lg font-medium transition-colors">
Abbrechen
</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function openGenerateModal() {
document.getElementById('generateModal').classList.remove('hidden');
}
function closeGenerateModal() {
document.getElementById('generateModal').classList.add('hidden');
document.getElementById('generateForm').reset();
}
function editKey(keyId, maxEmployees, maxPostsPerDay, maxResearchesPerDay, description) {
document.getElementById('edit_key_id').value = keyId;
document.getElementById('edit_max_employees').value = maxEmployees;
document.getElementById('edit_max_posts_per_day').value = maxPostsPerDay;
document.getElementById('edit_max_researches_per_day').value = maxResearchesPerDay;
document.getElementById('edit_description').value = description;
document.getElementById('editModal').classList.remove('hidden');
}
function closeEditModal() {
document.getElementById('editModal').classList.add('hidden');
document.getElementById('editForm').reset();
}
document.getElementById('editForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const keyId = formData.get('key_id');
try {
const response = await fetch(`/admin/api/license-keys/${keyId}`, {
method: 'PATCH',
body: formData
});
if (!response.ok) throw new Error('Update failed');
alert('Lizenzschlüssel aktualisiert!');
location.reload();
} catch (error) {
alert('Fehler beim Aktualisieren: ' + error.message);
}
});
document.getElementById('generateForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
try {
const response = await fetch('/admin/api/license-keys/generate', {
method: 'POST',
body: formData
});
if (!response.ok) throw new Error('Generation failed');
const data = await response.json();
alert(`Schlüssel generiert: ${data.key.key}\n\nBitte kopiere diesen Schlüssel jetzt!`);
location.reload();
} catch (error) {
alert('Fehler beim Generieren: ' + error.message);
}
});
function copyKey(key) {
navigator.clipboard.writeText(key);
alert('Schlüssel kopiert: ' + key);
}
async function deleteKey(keyId) {
if (!confirm('Schlüssel wirklich löschen?')) return;
try {
const response = await fetch(`/admin/api/license-keys/${keyId}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Delete failed');
location.reload();
} catch (error) {
alert('Fehler beim Löschen: ' + error.message);
}
}
</script>
{% endblock %}

View File

@@ -1,274 +0,0 @@
{% 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 %}

File diff suppressed because it is too large Load Diff

View File

@@ -1,152 +0,0 @@
{% 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 %}

View File

@@ -1,571 +0,0 @@
{% 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 %}

View File

@@ -0,0 +1,436 @@
{% extends "base.html" %}
{% block title %}Statistiken - Admin{% endblock %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
{% endblock %}
{% block content %}
<div class="mb-8">
<h1 class="text-3xl font-bold text-white mb-2">Statistiken</h1>
<p class="text-gray-400">API-Nutzung und Kosten</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 %}
<!-- Filters -->
<div class="flex flex-wrap items-center gap-4 mb-6">
<!-- Period Selector -->
<div class="flex gap-2">
<button onclick="loadStats('today')" id="period-today"
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-brand-bg-light text-gray-300 hover:bg-brand-bg">
Heute
</button>
<button onclick="loadStats('week')" id="period-week"
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-brand-bg-light text-gray-300 hover:bg-brand-bg">
7 Tage
</button>
<button onclick="loadStats('month')" id="period-month"
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors btn-primary">
30 Tage
</button>
<button onclick="loadStats('all')" id="period-all"
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-brand-bg-light text-gray-300 hover:bg-brand-bg">
Gesamt
</button>
</div>
<!-- User/Company Filter -->
<div class="flex items-center gap-2">
<label for="entityFilter" class="text-sm text-gray-400">Filter:</label>
<select id="entityFilter" onchange="onEntityFilterChange()"
class="input-bg border rounded-lg px-3 py-2 text-sm text-white min-w-[250px]">
<option value="">Alle</option>
{% if companies %}
<optgroup label="Firmen">
{% for cd in companies %}
<option value="company:{{ cd.company.id }}">{{ cd.company.name }}</option>
{% endfor %}
</optgroup>
{% endif %}
{% if ghostwriters %}
<optgroup label="Ghostwriter">
{% for gw in ghostwriters %}
<option value="user:{{ gw.id }}">{{ gw.display_name or gw.email }}</option>
{% endfor %}
</optgroup>
{% endif %}
{% if companies %}
<optgroup label="Firmen-Mitglieder">
{% for cd in companies %}
{% for emp in cd.employees %}
<option value="user:{{ emp.id }}">{{ emp.display_name or emp.email }} ({{ cd.company.name }})</option>
{% endfor %}
{% endfor %}
</optgroup>
{% endif %}
</select>
</div>
</div>
<!-- Overview Cards Row 1 -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-4">
<div class="card-bg rounded-xl border p-5 text-center">
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center mx-auto 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="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>
</div>
<p class="text-gray-400 text-xs">Tokens</p>
<p class="text-xl font-bold text-white" id="stat-tokens">-</p>
</div>
<div class="card-bg rounded-xl border p-5 text-center">
<div class="w-10 h-10 bg-green-600/20 rounded-lg flex items-center justify-center mx-auto mb-2">
<svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div>
<p class="text-gray-400 text-xs">Kosten (USD)</p>
<p class="text-xl font-bold text-white" id="stat-cost">-</p>
</div>
<div class="card-bg rounded-xl border p-5 text-center">
<div class="w-10 h-10 bg-blue-600/20 rounded-lg flex items-center justify-center mx-auto mb-2">
<svg class="w-5 h-5 text-blue-400" 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>
<p class="text-gray-400 text-xs">API Calls</p>
<p class="text-xl font-bold text-white" id="stat-calls">-</p>
</div>
<div class="card-bg rounded-xl border p-5 text-center">
<div class="w-10 h-10 bg-purple-600/20 rounded-lg flex items-center justify-center mx-auto mb-2">
<svg class="w-5 h-5 text-purple-400" 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>
<p class="text-gray-400 text-xs">Erstellt</p>
<p class="text-xl font-bold text-white" id="stat-created">-</p>
</div>
<div class="card-bg rounded-xl border p-5 text-center">
<div class="w-10 h-10 bg-amber-600/20 rounded-lg flex items-center justify-center mx-auto mb-2">
<svg class="w-5 h-5 text-amber-400" 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>
</div>
<p class="text-gray-400 text-xs">Freigegeben</p>
<p class="text-xl font-bold text-white" id="stat-approved">-</p>
</div>
<div class="card-bg rounded-xl border p-5 text-center">
<div class="w-10 h-10 bg-emerald-600/20 rounded-lg flex items-center justify-center mx-auto mb-2">
<svg class="w-5 h-5 text-emerald-400" 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>
<p class="text-gray-400 text-xs">Veröffentlicht</p>
<p class="text-xl font-bold text-white" id="stat-published">-</p>
</div>
</div>
<!-- Overview Cards Row 2 -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
<div class="card-bg rounded-xl border p-5 text-center">
<div class="w-10 h-10 bg-orange-600/20 rounded-lg flex items-center justify-center mx-auto mb-2">
<svg class="w-5 h-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/></svg>
</div>
<p class="text-gray-400 text-xs">Kosten/Post</p>
<p class="text-xl font-bold text-white" id="stat-avg-cost">-</p>
</div>
<div class="card-bg rounded-xl border p-5 text-center">
<div class="w-10 h-10 bg-rose-600/20 rounded-lg flex items-center justify-center mx-auto mb-2">
<svg class="w-5 h-5 text-rose-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
</div>
<p class="text-gray-400 text-xs">Monatsprognose</p>
<p class="text-xl font-bold text-white" id="stat-projection">-</p>
</div>
<div class="card-bg rounded-xl border p-5 text-center">
<div class="w-10 h-10 bg-cyan-600/20 rounded-lg flex items-center justify-center mx-auto mb-2">
<svg class="w-5 h-5 text-cyan-400" 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>
</div>
<p class="text-gray-400 text-xs">Tokens/Post</p>
<p class="text-xl font-bold text-white" id="stat-avg-tokens">-</p>
</div>
<div class="card-bg rounded-xl border p-5 text-center">
<div class="w-10 h-10 bg-amber-600/20 rounded-lg flex items-center justify-center mx-auto mb-2">
<svg class="w-5 h-5 text-amber-400" 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>
</div>
<p class="text-gray-400 text-xs">Bis Freigabe</p>
<p class="text-xl font-bold text-white" id="stat-time-approval">-</p>
</div>
<div class="card-bg rounded-xl border p-5 text-center">
<div class="w-10 h-10 bg-emerald-600/20 rounded-lg flex items-center justify-center mx-auto mb-2">
<svg class="w-5 h-5 text-emerald-400" 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>
</div>
<p class="text-gray-400 text-xs">Bis Veröffentl.</p>
<p class="text-xl font-bold text-white" id="stat-time-publish">-</p>
</div>
</div>
<!-- Charts Row 1 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Daily Token Usage -->
<div class="card-bg rounded-xl border p-6">
<h3 class="text-lg font-semibold text-white mb-4">Token-Verbrauch pro Tag</h3>
<div id="chart-daily-tokens"></div>
</div>
<!-- Daily Cost -->
<div class="card-bg rounded-xl border p-6">
<h3 class="text-lg font-semibold text-white mb-4">Kosten pro Tag (USD)</h3>
<div id="chart-daily-cost"></div>
</div>
</div>
<!-- Charts Row 2 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- By Model (Pie) -->
<div class="card-bg rounded-xl border p-6">
<h3 class="text-lg font-semibold text-white mb-4">Nutzung nach Modell</h3>
<div id="chart-by-model"></div>
</div>
<!-- By Operation (Bar) -->
<div class="card-bg rounded-xl border p-6">
<h3 class="text-lg font-semibold text-white mb-4">Nutzung nach Operation</h3>
<div id="chart-by-operation"></div>
</div>
</div>
<!-- Charts Row 3: Posts -->
<div class="grid grid-cols-1 gap-6 mb-6">
<div class="card-bg rounded-xl border p-6">
<h3 class="text-lg font-semibold text-white mb-4">Posts pro Tag</h3>
<div id="chart-posts-daily"></div>
</div>
</div>
<!-- Post Pipeline Funnel -->
<div class="card-bg rounded-xl border p-6 mb-6">
<h3 class="text-lg font-semibold text-white mb-4">Post-Pipeline</h3>
<div class="flex items-center justify-center gap-2">
<div class="text-center flex-1">
<div class="bg-purple-600/20 rounded-lg py-4 px-2">
<p class="text-2xl font-bold text-purple-400" id="funnel-created">-</p>
<p class="text-xs text-gray-400 mt-1">Erstellt</p>
</div>
</div>
<div class="text-center flex-shrink-0">
<p class="text-sm font-semibold text-amber-400" id="funnel-approval-rate">-</p>
<svg class="w-6 h-6 text-gray-500 mx-auto" 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>
<div class="text-center flex-1">
<div class="bg-amber-600/20 rounded-lg py-4 px-2">
<p class="text-2xl font-bold text-amber-400" id="funnel-approved">-</p>
<p class="text-xs text-gray-400 mt-1">Freigegeben</p>
</div>
</div>
<div class="text-center flex-shrink-0">
<p class="text-sm font-semibold text-emerald-400" id="funnel-publish-rate">-</p>
<svg class="w-6 h-6 text-gray-500 mx-auto" 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>
<div class="text-center flex-1">
<div class="bg-emerald-600/20 rounded-lg py-4 px-2">
<p class="text-2xl font-bold text-emerald-400" id="funnel-published">-</p>
<p class="text-xs text-gray-400 mt-1">Veröffentlicht</p>
</div>
</div>
</div>
</div>
<!-- No Data Message -->
<div id="no-data-msg" class="hidden card-bg rounded-xl border p-8 text-center text-gray-400 mb-6">
Noch keine API-Nutzungsdaten vorhanden. Statistiken werden automatisch erfasst, sobald Posts erstellt oder Research durchgeführt wird.
</div>
{% endblock %}
{% block scripts %}
<script>
let currentPeriod = 'month';
let filterType = ''; // '' | 'user' | 'company'
let filterId = ''; // UUID
let charts = {};
const chartTheme = {
chart: { background: 'transparent', foreColor: '#9CA3AF' },
grid: { borderColor: '#4a5858' },
tooltip: { theme: 'dark' },
colors: ['#ffc700', '#60A5FA', '#34D399', '#A78BFA', '#F87171']
};
function formatTokens(n) {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
return n.toString();
}
function formatHours(h) {
if (h === null || h === undefined || h === 0) return '0 Std';
if (h < 1) return Math.round(h * 60) + ' Min';
if (h >= 24) return (h / 24).toFixed(1) + ' Tage';
return h.toFixed(1) + ' Std';
}
function destroyCharts() {
Object.values(charts).forEach(c => { try { c.destroy(); } catch(e) {} });
charts = {};
}
function renderCharts(data) {
destroyCharts();
const hasData = data.total_calls > 0 || data.total_created > 0;
document.getElementById('no-data-msg').classList.toggle('hidden', hasData);
// Update summary cards
document.getElementById('stat-tokens').textContent = formatTokens(data.total_tokens);
document.getElementById('stat-cost').textContent = '$' + data.total_cost.toFixed(4);
document.getElementById('stat-calls').textContent = data.total_calls.toString();
document.getElementById('stat-created').textContent = (data.total_created || 0).toString();
document.getElementById('stat-approved').textContent = (data.total_approved || 0).toString();
document.getElementById('stat-published').textContent = (data.total_published || 0).toString();
// Derived metrics cards
document.getElementById('stat-avg-cost').textContent = '$' + (data.avg_cost_per_post || 0).toFixed(4);
document.getElementById('stat-projection').textContent = '$' + (data.monthly_projection || 0).toFixed(2);
document.getElementById('stat-avg-tokens').textContent = formatTokens(data.avg_tokens_per_post || 0);
document.getElementById('stat-time-approval').textContent = formatHours(data.avg_hours_to_approval);
document.getElementById('stat-time-publish').textContent = formatHours(data.avg_hours_to_publish);
// Funnel
document.getElementById('funnel-created').textContent = (data.total_created || 0).toString();
document.getElementById('funnel-approved').textContent = (data.total_approved || 0).toString();
document.getElementById('funnel-published').textContent = (data.total_published || 0).toString();
document.getElementById('funnel-approval-rate').textContent = (data.approval_rate || 0) + '%';
document.getElementById('funnel-publish-rate').textContent = (data.publish_rate || 0) + '%';
// Daily tokens line chart (total + per model)
if (data.daily.length > 0) {
const tokenSeries = [{ name: 'Gesamt', data: data.daily.map(d => ({ x: d.date, y: d.tokens })) }];
const costSeries = [{ name: 'Gesamt', data: data.daily.map(d => ({ x: d.date, y: parseFloat(d.cost.toFixed(4)) })) }];
const modelColors = { 'gpt-4o': '#60A5FA', 'gpt-4o-mini': '#A78BFA', 'sonar': '#F87171' };
if (data.daily_by_model) {
for (const [model, days] of Object.entries(data.daily_by_model)) {
tokenSeries.push({ name: model, data: days.map(d => ({ x: d.date, y: d.tokens })) });
costSeries.push({ name: model, data: days.map(d => ({ x: d.date, y: parseFloat(d.cost.toFixed(4)) })) });
}
}
const seriesColors = ['#ffc700'].concat(tokenSeries.slice(1).map(s => modelColors[s.name] || '#9CA3AF'));
const strokeWidths = [2].concat(tokenSeries.slice(1).map(() => 2));
// Gesamt = filled gradient, model lines = no fill
const fillOpacity = [0.3].concat(tokenSeries.slice(1).map(() => 0));
charts.dailyTokens = new ApexCharts(document.getElementById('chart-daily-tokens'), {
...chartTheme,
chart: { ...chartTheme.chart, type: 'area', height: 300 },
series: tokenSeries,
xaxis: { type: 'category', labels: { rotate: -45 } },
yaxis: { labels: { formatter: formatTokens } },
stroke: { curve: 'smooth', width: strokeWidths },
fill: { opacity: fillOpacity },
colors: seriesColors,
legend: { position: 'top', labels: { colors: '#9CA3AF' } },
dataLabels: { enabled: false }
});
charts.dailyTokens.render();
const costColors = ['#34D399'].concat(costSeries.slice(1).map(s => modelColors[s.name] || '#9CA3AF'));
charts.dailyCost = new ApexCharts(document.getElementById('chart-daily-cost'), {
...chartTheme,
chart: { ...chartTheme.chart, type: 'area', height: 300 },
series: costSeries,
xaxis: { type: 'category', labels: { rotate: -45 } },
yaxis: { labels: { formatter: v => '$' + v.toFixed(4) } },
stroke: { curve: 'smooth', width: strokeWidths },
fill: { opacity: fillOpacity },
colors: costColors,
legend: { position: 'top', labels: { colors: '#9CA3AF' } },
dataLabels: { enabled: false }
});
charts.dailyCost.render();
}
// By model pie chart
if (data.by_model.length > 0) {
charts.byModel = new ApexCharts(document.getElementById('chart-by-model'), {
...chartTheme,
chart: { ...chartTheme.chart, type: 'donut', height: 280 },
series: data.by_model.map(m => m.tokens),
labels: data.by_model.map(m => m.model),
legend: { position: 'bottom', labels: { colors: '#9CA3AF' } },
plotOptions: { pie: { donut: { size: '60%' } } }
});
charts.byModel.render();
}
// By operation bar chart
if (data.by_operation.length > 0) {
charts.byOperation = new ApexCharts(document.getElementById('chart-by-operation'), {
...chartTheme,
chart: { ...chartTheme.chart, type: 'bar', height: 280 },
series: [{ name: 'Tokens', data: data.by_operation.map(o => o.tokens) }],
xaxis: { categories: data.by_operation.map(o => o.operation) },
yaxis: { labels: { formatter: formatTokens } },
plotOptions: { bar: { borderRadius: 4, horizontal: false } },
dataLabels: { enabled: false }
});
charts.byOperation.render();
}
// Posts daily chart (created / approved / published)
if (data.posts_daily && data.posts_daily.length > 0) {
charts.postsByDay = new ApexCharts(document.getElementById('chart-posts-daily'), {
...chartTheme,
chart: { ...chartTheme.chart, type: 'bar', height: 300 },
series: [
{ name: 'Erstellt', data: data.posts_daily.map(d => ({ x: d.date, y: d.created })) },
{ name: 'Freigegeben', data: data.posts_daily.map(d => ({ x: d.date, y: d.approved })) },
{ name: 'Veröffentlicht', data: data.posts_daily.map(d => ({ x: d.date, y: d.published })) }
],
xaxis: { type: 'category', labels: { rotate: -45 } },
yaxis: { labels: { formatter: v => Math.round(v).toString() }, forceNiceScale: true },
plotOptions: { bar: { borderRadius: 3, columnWidth: '60%' } },
colors: ['#A78BFA', '#FBBF24', '#34D399'],
legend: { position: 'top', labels: { colors: '#9CA3AF' } },
dataLabels: { enabled: false }
});
charts.postsByDay.render();
}
}
function onEntityFilterChange() {
const val = document.getElementById('entityFilter').value;
if (!val) {
filterType = '';
filterId = '';
} else {
const parts = val.split(':');
filterType = parts[0]; // 'user' or 'company'
filterId = parts[1];
}
loadStats(currentPeriod);
}
async function loadStats(period) {
currentPeriod = period;
// Update period button styles
['today', 'week', 'month', 'all'].forEach(p => {
const btn = document.getElementById('period-' + p);
btn.className = p === period
? 'px-4 py-2 rounded-lg text-sm font-medium transition-colors btn-primary'
: 'px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-brand-bg-light text-gray-300 hover:bg-brand-bg';
});
try {
let url = `/admin/api/statistics?period=${period}`;
if (filterType === 'user' && filterId) {
url += `&user_id=${filterId}`;
} else if (filterType === 'company' && filterId) {
url += `&company_id=${filterId}`;
}
const resp = await fetch(url);
const data = await resp.json();
renderCharts(data);
} catch (e) {
console.error('Failed to load stats:', e);
}
}
// Load initial data
loadStats('month');
</script>
{% endblock %}

View File

@@ -1,159 +0,0 @@
{% 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 %}

View File

@@ -14,7 +14,7 @@
<!-- 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">
<select name="user_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>
@@ -229,7 +229,7 @@ customerSelect.addEventListener('change', async () => {
// Load post types
try {
const ptResponse = await fetch(`/api/customers/${customerId}/post-types`);
const ptResponse = await fetch(`/api/users/${customerId}/post-types`);
const ptData = await ptResponse.json();
if (ptData.post_types && ptData.post_types.length > 0) {
@@ -258,7 +258,7 @@ customerSelect.addEventListener('change', async () => {
// Load topics
try {
const response = await fetch(`/api/customers/${customerId}/topics`);
const response = await fetch(`/api/users/${customerId}/topics`);
const data = await response.json();
if (data.topics && data.topics.length > 0) {
@@ -317,7 +317,7 @@ async function loadTopicsForPostType(customerId, postTypeId) {
topicsList.innerHTML = '<p class="text-gray-500">Lade Topics...</p>';
try {
let url = `/api/customers/${customerId}/topics`;
let url = `/api/users/${customerId}/topics`;
if (postTypeId) {
url += `?post_type_id=${postTypeId}`;
}
@@ -437,7 +437,7 @@ form.addEventListener('submit', async (e) => {
postResult.innerHTML = '<p class="text-gray-400">Post wird generiert...</p>';
const formData = new FormData();
formData.append('customer_id', customerId);
formData.append('user_id', customerId);
formData.append('topic_json', JSON.stringify(topic));
if (selectedPostTypeIdInput.value) {
formData.append('post_type_id', selectedPostTypeIdInput.value);

View File

@@ -0,0 +1,260 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Datenschutzerklärung - LinkedIn Workflow</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
max-width: 800px;
margin: 0 auto;
padding: 20px;
color: #333;
}
h1 { color: #0a66c2; }
h2 { color: #0a66c2; margin-top: 30px; }
.last-updated { color: #666; font-style: italic; }
</style>
</head>
<body>
<h1>Datenschutzerklärung / Privacy Policy</h1>
<p class="last-updated">Letzte Aktualisierung: {{ current_date }}</p>
<h2>1. Überblick</h2>
<p>
Diese Datenschutzerklärung beschreibt, wie LinkedIn Workflow ("wir", "uns", "unsere App")
Ihre Informationen sammelt, verwendet und schützt, wenn Sie unsere LinkedIn-Content-Scheduling-Plattform nutzen.
</p>
<h2>2. Welche Daten sammeln wir?</h2>
<h3>2.1 LinkedIn-Profildaten (via LinkedIn OAuth)</h3>
<ul>
<li>Ihr Name und LinkedIn-Profil-ID</li>
<li>Ihre E-Mail-Adresse</li>
<li>Ihr Profilbild (optional)</li>
<li>LinkedIn Access Token (verschlüsselt gespeichert)</li>
</ul>
<h3>2.2 Von Ihnen erstellte Inhalte</h3>
<ul>
<li>Entwürfe und geplante LinkedIn-Posts</li>
<li>Hochgeladene Bilder für Posts</li>
<li>Zeitpläne für Veröffentlichungen</li>
</ul>
<h3>2.3 Nutzungsdaten</h3>
<ul>
<li>Anmeldezeitpunkte</li>
<li>Erstellte und veröffentlichte Posts</li>
</ul>
<h2>3. Wie verwenden wir Ihre Daten?</h2>
<p>Wir verwenden Ihre Daten ausschließlich für folgende Zwecke:</p>
<ul>
<li><strong>Authentifizierung:</strong> Um Sie sicher anzumelden</li>
<li><strong>Content-Posting:</strong> Um Ihre genehmigten Posts auf LinkedIn zu veröffentlichen</li>
<li><strong>Content-Management:</strong> Um Ihre Post-Entwürfe und Zeitpläne zu speichern</li>
<li><strong>Service-Bereitstellung:</strong> Um die Kernfunktionen unserer App bereitzustellen</li>
</ul>
<h2>4. Datenspeicherung und Sicherheit</h2>
<ul>
<li><strong>Verschlüsselung:</strong> LinkedIn Access Tokens werden verschlüsselt gespeichert (AES-256)</li>
<li><strong>Sichere Datenbank:</strong> Alle Daten werden in einer sicheren Supabase-Datenbank gespeichert</li>
<li><strong>Zugriffskontrolle:</strong> Nur Sie können auf Ihre eigenen Daten zugreifen</li>
<li><strong>HTTPS:</strong> Alle Datenübertragungen erfolgen verschlüsselt über HTTPS</li>
</ul>
<h2>5. Datenweitergabe an Dritte</h2>
<p>Wir geben Ihre Daten <strong>NICHT</strong> an Dritte weiter, mit folgenden Ausnahmen:</p>
<ul>
<li><strong>LinkedIn:</strong> Wenn Sie einen Post planen, senden wir den Inhalt an LinkedIn's API, um ihn zu veröffentlichen (nur auf Ihre ausdrückliche Anweisung)</li>
<li><strong>Infrastruktur-Anbieter:</strong> Supabase (Datenbank-Hosting) und andere technische Dienstleister, die unsere App betreiben</li>
<li><strong>Gesetzliche Verpflichtungen:</strong> Nur wenn gesetzlich vorgeschrieben</li>
</ul>
<h2>6. Verwendung der LinkedIn-Daten</h2>
<p>
Unsere App verwendet die LinkedIn-API, um Posts in Ihrem Namen zu veröffentlichen.
Wir halten uns strikt an die
<a href="https://legal.linkedin.com/api-terms-of-use" target="_blank">LinkedIn API Terms of Use</a>.
</p>
<ul>
<li>Posts werden NUR veröffentlicht, wenn Sie dies ausdrücklich genehmigt haben</li>
<li>Wir posten KEINEN automatischen oder Spam-Inhalt</li>
<li>Sie behalten die volle Kontrolle über alle veröffentlichten Inhalte</li>
</ul>
<h2>7. Ihre Rechte</h2>
<p>Sie haben folgende Rechte bezüglich Ihrer Daten:</p>
<ul>
<li><strong>Zugriff:</strong> Sie können alle Ihre gespeicherten Daten jederzeit einsehen</li>
<li><strong>Bearbeitung:</strong> Sie können Ihre Daten jederzeit ändern oder löschen</li>
<li><strong>Löschung:</strong> Sie können Ihr Konto und alle zugehörigen Daten vollständig löschen</li>
<li><strong>Widerruf:</strong> Sie können die LinkedIn-Verbindung jederzeit in den Einstellungen trennen</li>
<li><strong>Export:</strong> Sie können Ihre Daten exportieren (auf Anfrage)</li>
</ul>
<h2>8. LinkedIn-Verbindung trennen</h2>
<p>
Sie können die LinkedIn-Verbindung jederzeit trennen durch:
</p>
<ul>
<li>Einstellungen → LinkedIn-Konto → "Verbindung trennen"</li>
<li>LinkedIn.com → Einstellungen → Apps → Unsere App entfernen</li>
</ul>
<p>
Nach der Trennung löschen wir Ihre LinkedIn Access Tokens sofort.
Ihre gespeicherten Post-Entwürfe bleiben erhalten (können aber nicht mehr veröffentlicht werden).
</p>
<h2>9. Cookies</h2>
<p>Wir verwenden folgende Cookies:</p>
<ul>
<li><strong>Session-Cookie:</strong> Zur Authentifizierung (erforderlich)</li>
<li><strong>OAuth-State-Cookie:</strong> Zur sicheren LinkedIn-Authentifizierung (temporär)</li>
</ul>
<h2>10. Kinder</h2>
<p>
Unsere App richtet sich nicht an Personen unter 16 Jahren.
Wir sammeln wissentlich keine Daten von Kindern.
</p>
<h2>11. Änderungen dieser Datenschutzerklärung</h2>
<p>
Wir können diese Datenschutzerklärung gelegentlich aktualisieren.
Änderungen werden auf dieser Seite veröffentlicht und das "Letzte Aktualisierung"-Datum wird aktualisiert.
</p>
<h2>12. Kontakt</h2>
<p>
Bei Fragen zu dieser Datenschutzerklärung oder Ihren Daten kontaktieren Sie uns bitte:
</p>
<p>
<strong>E-Mail:</strong> [IHRE-EMAIL-ADRESSE]<br>
</p>
<hr>
<h1>Privacy Policy (English)</h1>
<p class="last-updated">Last Updated: {{ current_date }}</p>
<h2>1. Overview</h2>
<p>
This Privacy Policy describes how LinkedIn Workflow ("we", "us", "our app")
collects, uses, and protects your information when you use our LinkedIn content scheduling platform.
</p>
<h2>2. What Data We Collect</h2>
<h3>2.1 LinkedIn Profile Data (via LinkedIn OAuth)</h3>
<ul>
<li>Your name and LinkedIn profile ID</li>
<li>Your email address</li>
<li>Your profile picture (optional)</li>
<li>LinkedIn access token (encrypted)</li>
</ul>
<h3>2.2 Content You Create</h3>
<ul>
<li>Draft and scheduled LinkedIn posts</li>
<li>Uploaded images for posts</li>
<li>Publishing schedules</li>
</ul>
<h3>2.3 Usage Data</h3>
<ul>
<li>Login timestamps</li>
<li>Created and published posts</li>
</ul>
<h2>3. How We Use Your Data</h2>
<p>We use your data exclusively for:</p>
<ul>
<li><strong>Authentication:</strong> To securely log you in</li>
<li><strong>Content Posting:</strong> To publish your approved posts to LinkedIn</li>
<li><strong>Content Management:</strong> To store your post drafts and schedules</li>
<li><strong>Service Provision:</strong> To provide our app's core functionality</li>
</ul>
<h2>4. Data Storage and Security</h2>
<ul>
<li><strong>Encryption:</strong> LinkedIn access tokens are encrypted (AES-256)</li>
<li><strong>Secure Database:</strong> All data stored in secure Supabase database</li>
<li><strong>Access Control:</strong> Only you can access your own data</li>
<li><strong>HTTPS:</strong> All data transmissions encrypted via HTTPS</li>
</ul>
<h2>5. Data Sharing</h2>
<p>We do <strong>NOT</strong> share your data with third parties, except:</p>
<ul>
<li><strong>LinkedIn:</strong> When you schedule a post, we send content to LinkedIn's API to publish it (only at your explicit request)</li>
<li><strong>Infrastructure Providers:</strong> Supabase (database hosting) and other technical service providers</li>
<li><strong>Legal Obligations:</strong> Only when legally required</li>
</ul>
<h2>6. LinkedIn Data Usage</h2>
<p>
Our app uses the LinkedIn API to post on your behalf.
We strictly comply with
<a href="https://legal.linkedin.com/api-terms-of-use" target="_blank">LinkedIn API Terms of Use</a>.
</p>
<ul>
<li>Posts are ONLY published when you explicitly approve them</li>
<li>We do NOT post automatic or spam content</li>
<li>You maintain full control over all published content</li>
</ul>
<h2>7. Your Rights</h2>
<p>You have the following rights regarding your data:</p>
<ul>
<li><strong>Access:</strong> View all your stored data at any time</li>
<li><strong>Edit:</strong> Modify or delete your data at any time</li>
<li><strong>Deletion:</strong> Completely delete your account and all associated data</li>
<li><strong>Revoke:</strong> Disconnect LinkedIn connection in settings at any time</li>
<li><strong>Export:</strong> Request data export</li>
</ul>
<h2>8. Disconnect LinkedIn</h2>
<p>You can disconnect LinkedIn at any time via:</p>
<ul>
<li>Settings → LinkedIn Account → "Disconnect"</li>
<li>LinkedIn.com → Settings → Apps → Remove our app</li>
</ul>
<p>
After disconnection, we immediately delete your LinkedIn access tokens.
Your saved post drafts remain (but cannot be published).
</p>
<h2>9. Cookies</h2>
<p>We use the following cookies:</p>
<ul>
<li><strong>Session Cookie:</strong> For authentication (required)</li>
<li><strong>OAuth State Cookie:</strong> For secure LinkedIn authentication (temporary)</li>
</ul>
<h2>10. Children</h2>
<p>
Our app is not directed at persons under 16 years old.
We do not knowingly collect data from children.
</p>
<h2>11. Changes to Privacy Policy</h2>
<p>
We may update this Privacy Policy occasionally.
Changes will be posted on this page with an updated "Last Updated" date.
</p>
<h2>12. Contact</h2>
<p>
For questions about this Privacy Policy or your data, please contact us:
</p>
<p>
<strong>Email:</strong> [YOUR-EMAIL-ADDRESS]<br>
</p>
</body>
</html>

View File

@@ -13,7 +13,7 @@
<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">
<select name="user_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>
@@ -96,7 +96,7 @@ customerSelect.addEventListener('change', async () => {
}
try {
const response = await fetch(`/api/customers/${customerId}/post-types`);
const response = await fetch(`/api/users/${customerId}/post-types`);
const data = await response.json();
if (data.post_types && data.post_types.length > 0) {
@@ -150,7 +150,7 @@ form.addEventListener('submit', async (e) => {
progressArea.classList.remove('hidden');
const formData = new FormData();
formData.append('customer_id', customerId);
formData.append('user_id', customerId);
if (selectedPostTypeId.value) {
formData.append('post_type_id', selectedPostTypeId.value);
}

View File

@@ -210,7 +210,7 @@ customerSelect.addEventListener('change', async () => {
async function loadCustomerData(customerId) {
// Load post types
try {
const ptResponse = await fetch(`/api/customers/${customerId}/post-types`);
const ptResponse = await fetch(`/api/users/${customerId}/post-types`);
const ptData = await ptResponse.json();
currentPostTypes = ptData.post_types || [];
@@ -231,7 +231,7 @@ async function loadCustomerData(customerId) {
// Load posts
try {
const response = await fetch(`/api/customers/${customerId}/linkedin-posts`);
const response = await fetch(`/api/users/${customerId}/linkedin-posts`);
const data = await response.json();
console.log('API Response:', data);
@@ -484,7 +484,7 @@ classifyAllBtn.addEventListener('click', async () => {
progressArea.classList.remove('hidden');
try {
const response = await fetch(`/api/customers/${customerId}/classify-posts`, {
const response = await fetch(`/api/users/${customerId}/classify-posts`, {
method: 'POST'
});
const data = await response.json();
@@ -512,7 +512,7 @@ analyzeTypesBtn.addEventListener('click', async () => {
progressArea.classList.remove('hidden');
try {
const response = await fetch(`/api/customers/${customerId}/analyze-post-types`, {
const response = await fetch(`/api/users/${customerId}/analyze-post-types`, {
method: 'POST'
});
const data = await response.json();

View File

@@ -57,14 +57,20 @@
<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">
<img src="{{ session.linkedin_picture }}" alt="{{ session.display_name or 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>
<span class="text-brand-bg-dark font-bold">{{ (session.display_name or session.linkedin_name or 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>
<p class="text-white font-medium text-sm truncate">{{ session.display_name or session.linkedin_name or 'Benutzer' }}</p>
{% if session.account_type == 'ghostwriter' and session.customer_name %}
<p class="text-gray-400 text-xs truncate">schreibt für: {{ session.customer_name }}</p>
{% elif session.account_type == 'employee' and session.company_name %}
<p class="text-gray-400 text-xs truncate">Mitarbeiter bei: {{ session.company_name }}</p>
{% else %}
<p class="text-gray-400 text-xs truncate">{{ session.email or '' }}</p>
{% endif %}
</div>
</div>
</div>
@@ -87,13 +93,35 @@
<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="/post-types" 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 == 'post_types' %}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>
{% if session and session.account_type == 'company' %}
<a href="/company/accounts" 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 == 'accounts' %}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="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>
Konten
</a>
{% endif %}
{% if session and session.account_type == 'employee' %}
<a href="/employee/strategy" 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 == 'strategy' %}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 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/>
</svg>
Unternehmensstrategie
</a>
{% endif %}
</nav>
<div class="p-4 border-t border-gray-600">
<div class="p-4 border-t border-gray-600 space-y-2">
<a href="/settings" class="flex items-center gap-2 text-gray-400 hover:text-gray-200 text-sm transition-colors {% if page == 'settings' %}text-brand-highlight{% endif %}">
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
Einstellungen
</a>
<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
@@ -108,6 +136,122 @@
</div>
</main>
<!-- Toast Container -->
<div id="toast-container" class="fixed bottom-4 right-4 z-50 space-y-2"></div>
<!-- Background Jobs Script -->
<script>
(function() {
let eventSource = null;
function connectToJobUpdates() {
if (eventSource) {
eventSource.close();
}
eventSource = new EventSource('/api/job-updates');
eventSource.onmessage = function(event) {
const job = JSON.parse(event.data);
showJobToast(job);
};
eventSource.onerror = function() {
// Reconnect after 5 seconds
setTimeout(connectToJobUpdates, 5000);
};
}
function showJobToast(job) {
const container = document.getElementById('toast-container');
let toast = document.getElementById('toast-' + job.id);
if (!toast) {
toast = document.createElement('div');
toast.id = 'toast-' + job.id;
toast.className = 'bg-brand-bg-dark border border-gray-600 rounded-lg shadow-lg p-4 min-w-80 transform transition-all duration-300';
container.appendChild(toast);
}
const statusColors = {
'pending': 'text-gray-400',
'running': 'text-brand-highlight',
'completed': 'text-green-400',
'failed': 'text-red-400'
};
const statusIcons = {
'pending': '<svg class="w-5 h-5 animate-pulse" 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>',
'running': '<svg class="w-5 h-5 animate-spin" 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>',
'completed': '<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 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
'failed': '<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 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>'
};
const jobTypeNames = {
'profile_analysis': 'Profil-Analyse',
'post_categorization': 'Kategorisierung',
'post_type_analysis': 'Post-Typen-Analyse'
};
toast.innerHTML = `
<div class="flex items-start gap-3">
<span class="${statusColors[job.status]}">${statusIcons[job.status]}</span>
<div class="flex-1">
<p class="font-medium text-white text-sm">${jobTypeNames[job.job_type] || job.job_type}</p>
<p class="text-gray-400 text-xs mt-1">${job.message || ''}</p>
${job.status === 'running' ? `
<div class="mt-2 bg-gray-700 rounded-full h-1.5 overflow-hidden">
<div class="bg-brand-highlight h-full transition-all duration-300" style="width: ${job.progress}%"></div>
</div>
` : ''}
${job.error ? `<p class="text-red-400 text-xs mt-1">${job.error}</p>` : ''}
</div>
${job.status === 'completed' || job.status === 'failed' ? `
<button onclick="this.parentElement.parentElement.remove()" class="text-gray-500 hover:text-gray-300">
<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="M6 18L18 6M6 6l12 12"/></svg>
</button>
` : ''}
</div>
`;
// Auto-remove completed toasts after 10 seconds
if (job.status === 'completed') {
setTimeout(() => {
if (toast.parentElement) {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}
}, 10000);
}
}
// Connect when page loads
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', connectToJobUpdates);
} else {
connectToJobUpdates();
}
// Expose for manual toasts
window.showToast = function(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
const colors = {
'info': 'bg-brand-highlight text-brand-bg-dark',
'success': 'bg-green-500 text-white',
'error': 'bg-red-500 text-white'
};
toast.className = `${colors[type]} px-6 py-3 rounded-lg shadow-lg`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}, 5000);
};
})();
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,185 @@
{% extends "base.html" %}
{% block title %}Mitarbeiter verwalten{% endblock %}
{% block content %}
<div class="mb-8">
<h1 class="text-2xl font-bold text-white mb-2">Mitarbeiter verwalten</h1>
<p class="text-gray-400">Verwalte die Konten deiner Teammitglieder.</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">
{{ error }}
</div>
{% endif %}
{% if success %}
<div class="bg-green-900/50 border border-green-500 text-green-200 px-4 py-3 rounded-lg mb-6">
{{ success }}
</div>
{% endif %}
<!-- Invite New Employee -->
<div class="card-bg rounded-xl border p-6 mb-8">
<h2 class="text-lg font-medium text-white mb-4">Neuen Mitarbeiter einladen</h2>
<form id="invite-form" class="flex gap-4">
<input type="email" id="invite-email"
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white"
placeholder="email@beispiel.de" required>
<button type="submit" class="btn-primary py-2 px-6 rounded-lg transition-colors">
Einladung senden
</button>
</form>
</div>
<!-- Pending Invitations -->
{% if pending_invitations and pending_invitations|length > 0 %}
<div class="card-bg rounded-xl border p-6 mb-8">
<h2 class="text-lg font-medium text-white mb-4">Offene Einladungen</h2>
<div class="space-y-3">
{% for invitation in pending_invitations %}
<div class="flex items-center justify-between p-3 bg-brand-bg border border-gray-600 rounded-lg">
<div>
<p class="text-white">{{ invitation.email }}</p>
<p class="text-xs text-gray-500">Eingeladen am {{ invitation.created_at.strftime('%d.%m.%Y') }}</p>
</div>
<div class="flex items-center gap-2">
<span class="text-xs px-2 py-1 rounded-full bg-yellow-900/50 text-yellow-300">Ausstehend</span>
<button onclick="cancelInvitation('{{ invitation.id }}')"
class="text-gray-500 hover:text-red-400 p-1"
title="Einladung zurückziehen">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Current Employees -->
<div class="card-bg rounded-xl border p-6">
<h2 class="text-lg font-medium text-white mb-4">Aktive Mitarbeiter ({{ employees|length }})</h2>
{% if employees and employees|length > 0 %}
<div class="space-y-3">
{% for employee in employees %}
<div class="flex items-center justify-between p-4 bg-brand-bg border border-gray-600 rounded-lg">
<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 employee.linkedin_picture %}
<img src="{{ employee.linkedin_picture }}" alt="{{ employee.linkedin_name }}" class="w-full h-full object-cover" referrerpolicy="no-referrer">
{% else %}
<span class="text-brand-bg-dark font-bold">{{ (employee.linkedin_name or employee.email)[0] | upper }}</span>
{% endif %}
</div>
<div>
<p class="text-white font-medium">{{ employee.linkedin_name or employee.email }}</p>
<p class="text-xs text-gray-500">{{ employee.email }}</p>
</div>
</div>
<div class="flex items-center gap-3">
<span class="text-xs px-2 py-1 rounded-full
{% if employee.onboarding_status == 'completed' or employee.onboarding_status.value == 'completed' %}
bg-green-900/50 text-green-300
{% else %}
bg-yellow-900/50 text-yellow-300
{% endif %}">
{% if employee.onboarding_status == 'completed' or employee.onboarding_status.value == 'completed' %}Aktiv{% else %}Onboarding{% endif %}
</span>
<button onclick="removeEmployee('{{ employee.id }}')"
class="text-gray-500 hover:text-red-400 p-1"
title="Mitarbeiter entfernen">
<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 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-8">
<div class="w-16 h-16 bg-gray-600/30 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-gray-500" 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>
<p class="text-gray-400">Noch keine Mitarbeiter</p>
<p class="text-sm text-gray-500">Lade dein erstes Teammitglied ein.</p>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
// Send invitation
document.getElementById('invite-form').addEventListener('submit', async function(e) {
e.preventDefault();
const email = document.getElementById('invite-email').value;
try {
const response = await fetch('/api/company/invite', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({email})
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
}
} catch (e) {
alert('Fehler beim Senden der Einladung');
}
});
// Cancel invitation
async function cancelInvitation(invitationId) {
if (!confirm('Einladung wirklich zurückziehen?')) return;
try {
const response = await fetch('/api/company/invitations/' + invitationId, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
}
} catch (e) {
alert('Fehler beim Zurückziehen der Einladung');
}
}
// Remove employee
async function removeEmployee(userId) {
if (!confirm('Mitarbeiter wirklich entfernen? Der Zugriff wird sofort entzogen.')) return;
try {
const response = await fetch('/api/company/employees/' + userId, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
}
} catch (e) {
alert('Fehler beim Entfernen des Mitarbeiters');
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,214 @@
{% extends "company_base.html" %}
{% block title %}Mitarbeiter - {{ session.company_name }}{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<div class="mb-8">
<h1 class="text-2xl font-bold text-white mb-2">Mitarbeiter verwalten</h1>
<p class="text-gray-400">Verwalte die Konten deiner Teammitglieder.</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">
{{ error }}
</div>
{% endif %}
{% if success %}
<div class="bg-green-900/50 border border-green-500 text-green-200 px-4 py-3 rounded-lg mb-6">
{{ success }}
</div>
{% endif %}
<!-- Invite New Employee -->
<div class="card-bg rounded-xl border p-6 mb-8">
<h2 class="text-lg font-medium text-white mb-4">Neuen Mitarbeiter einladen</h2>
<form id="invite-form" class="flex gap-4">
<input type="email" id="invite-email"
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white"
placeholder="email@beispiel.de" required>
<button type="submit" id="invite-submit-btn"
class="btn-primary py-2 px-6 rounded-lg transition-colors flex items-center gap-2">
<svg id="invite-spinner" class="hidden animate-spin h-5 w-5 text-brand-bg-dark" xmlns="http://www.w3.org/2000/svg" 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>
<span id="invite-btn-text">Einladung senden</span>
</button>
</form>
</div>
<!-- Pending Invitations -->
{% if pending_invitations and pending_invitations|length > 0 %}
<div class="card-bg rounded-xl border p-6 mb-8">
<h2 class="text-lg font-medium text-white mb-4">Offene Einladungen</h2>
<div class="space-y-3">
{% for invitation in pending_invitations %}
<div class="flex items-center justify-between p-3 bg-brand-bg border border-gray-600 rounded-lg">
<div>
<p class="text-white">{{ invitation.email }}</p>
<p class="text-xs text-gray-500">Eingeladen am {{ invitation.created_at.strftime('%d.%m.%Y') }}</p>
</div>
<div class="flex items-center gap-2">
<span class="text-xs px-2 py-1 rounded-full bg-yellow-900/50 text-yellow-300">Ausstehend</span>
<button onclick="cancelInvitation('{{ invitation.id }}')"
class="text-gray-500 hover:text-red-400 p-1"
title="Einladung zurückziehen">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Current Employees -->
<div class="card-bg rounded-xl border p-6">
<h2 class="text-lg font-medium text-white mb-4">Aktive Mitarbeiter ({{ employees|length }})</h2>
{% if employees and employees|length > 0 %}
<div class="space-y-3">
{% for employee in employees %}
<div class="flex items-center justify-between p-4 bg-brand-bg border border-gray-600 rounded-lg">
<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 employee.linkedin_picture %}
<img src="{{ employee.linkedin_picture }}" alt="{{ employee.linkedin_name }}" class="w-full h-full object-cover" referrerpolicy="no-referrer">
{% else %}
<span class="text-brand-bg-dark font-bold">{{ (employee.display_name or employee.linkedin_name or employee.email)[0] | upper }}</span>
{% endif %}
</div>
<div>
<p class="text-white font-medium">{{ employee.display_name or employee.linkedin_name or employee.email }}</p>
<p class="text-xs text-gray-500">{{ employee.email }}</p>
</div>
</div>
<div class="flex items-center gap-3">
{% set status = employee.onboarding_status.value if employee.onboarding_status.value is defined else employee.onboarding_status %}
<span class="text-xs px-2 py-1 rounded-full {% if status == 'completed' %}bg-green-900/50 text-green-300{% else %}bg-yellow-900/50 text-yellow-300{% endif %}">
{% if status == 'completed' %}Aktiv{% else %}Onboarding{% endif %}
</span>
<button onclick="removeEmployee('{{ employee.id }}')"
class="text-gray-500 hover:text-red-400 p-1"
title="Mitarbeiter entfernen">
<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 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-8">
<div class="w-16 h-16 bg-gray-600/30 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-gray-500" 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>
<p class="text-gray-400">Noch keine Mitarbeiter</p>
<p class="text-sm text-gray-500">Lade dein erstes Teammitglied ein.</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Send invitation
document.getElementById('invite-form').addEventListener('submit', async function(e) {
e.preventDefault();
const email = document.getElementById('invite-email').value;
const submitBtn = document.getElementById('invite-submit-btn');
const btnText = document.getElementById('invite-btn-text');
const spinner = document.getElementById('invite-spinner');
const emailInput = document.getElementById('invite-email');
// Show loading state
submitBtn.disabled = true;
emailInput.disabled = true;
spinner.classList.remove('hidden');
btnText.textContent = 'Sende...';
submitBtn.classList.add('opacity-75', 'cursor-not-allowed');
try {
const response = await fetch('/api/company/invite', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({email})
});
const data = await response.json();
if (data.success) {
btnText.textContent = 'Erfolgreich!';
setTimeout(() => location.reload(), 500);
} else {
// Reset button state
submitBtn.disabled = false;
emailInput.disabled = false;
spinner.classList.add('hidden');
btnText.textContent = 'Einladung senden';
submitBtn.classList.remove('opacity-75', 'cursor-not-allowed');
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
}
} catch (e) {
// Reset button state
submitBtn.disabled = false;
emailInput.disabled = false;
spinner.classList.add('hidden');
btnText.textContent = 'Einladung senden';
submitBtn.classList.remove('opacity-75', 'cursor-not-allowed');
alert('Fehler beim Senden der Einladung');
}
});
// Cancel invitation
async function cancelInvitation(invitationId) {
if (!confirm('Einladung wirklich zurückziehen?')) return;
try {
const response = await fetch('/api/company/invitations/' + invitationId, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
}
} catch (e) {
alert('Fehler beim Zurückziehen der Einladung');
}
}
// Remove employee
async function removeEmployee(userId) {
if (!confirm('Mitarbeiter wirklich entfernen? Der Zugriff wird sofort entzogen.')) return;
try {
const response = await fetch('/api/company/employees/' + userId, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
}
} catch (e) {
alert('Fehler beim Entfernen des Mitarbeiters');
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,114 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ session.company_name or 'Unternehmen' }} - 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; }
.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>
<!-- Company 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-lg overflow-hidden bg-brand-highlight flex items-center justify-center">
<svg class="w-6 h-6 text-brand-bg-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-white font-medium text-sm truncate">{{ session.company_name or 'Unternehmen' }}</p>
<p class="text-gray-400 text-xs truncate">{{ session.email or '' }}</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="/company/accounts" 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 == 'accounts' %}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="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>
Mitarbeiter
</a>
<a href="/company/strategy" 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 == 'strategy' %}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 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>
Strategie
</a>
<div class="pt-4 mt-4 border-t border-gray-600">
<p class="px-4 text-xs text-gray-500 uppercase tracking-wider mb-2">Mitarbeiter-Aktionen</p>
<a href="/company/manage" 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 == 'manage' %}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="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>
Inhalte verwalten
</a>
<a href="/company/calendar" 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 == 'calendar' %}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="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>
Posting-Kalender
</a>
</div>
</nav>
<div class="p-4 border-t border-gray-600 space-y-2">
<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>

View File

@@ -0,0 +1,668 @@
{% extends "company_base.html" %}
{% block title %}Posting-Kalender - {{ session.company_name }}{% endblock %}
{% block head %}
<style>
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
background: #5a6868;
}
.calendar-cell {
background: #4a5858;
min-height: 120px;
padding: 8px;
}
.calendar-cell.other-month {
background: #3d4848;
opacity: 0.5;
}
.calendar-cell.today {
border: 2px solid #ffc700;
}
.post-chip {
font-size: 11px;
padding: 4px 8px;
border-radius: 4px;
margin-bottom: 4px;
cursor: pointer;
transition: transform 0.1s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.post-chip:hover {
transform: scale(1.02);
}
.post-chip.scheduled {
background: #ffc700;
color: #2d3838;
}
.post-chip.published {
background: #6b7280;
color: #9ca3af;
opacity: 0.6;
cursor: pointer;
}
.post-chip.approved {
background: #3b82f6;
color: white;
}
.employee-color-1 { border-left: 3px solid #f472b6; }
.employee-color-2 { border-left: 3px solid #60a5fa; }
.employee-color-3 { border-left: 3px solid #34d399; }
.employee-color-4 { border-left: 3px solid #fbbf24; }
.employee-color-5 { border-left: 3px solid #a78bfa; }
.time-slot {
font-size: 10px;
color: #9ca3af;
margin-bottom: 2px;
}
.view-toggle .active {
background: #ffc700;
color: #2d3838;
}
/* Timeline Week View */
.timeline-header {
display: grid;
grid-template-columns: 50px repeat(7, 1fr);
background: #3d4848;
}
.timeline-header-cell {
padding: 8px 4px;
text-align: center;
font-size: 13px;
color: #9ca3af;
border-left: 1px solid #5a6868;
}
.timeline-header-cell.today {
color: #ffc700;
font-weight: bold;
}
.timeline-grid {
display: grid;
grid-template-columns: 50px repeat(7, 1fr);
position: relative;
}
.timeline-hour-label {
font-size: 11px;
color: #6b7280;
text-align: right;
padding-right: 8px;
height: 60px;
line-height: 1;
padding-top: 0;
transform: translateY(-6px);
}
.timeline-day-col {
position: relative;
height: 60px;
border-top: 1px solid #5a6868;
border-left: 1px solid #5a6868;
}
.timeline-day-col:hover {
background: rgba(255, 199, 0, 0.03);
}
.timeline-day-col.past-slot {
cursor: default !important;
opacity: 0.4;
}
.timeline-day-col.past-slot:hover {
background: none;
}
.calendar-cell .schedule-btn.past-slot {
display: none !important;
}
.timeline-post {
position: absolute;
left: 2px;
right: 2px;
height: 36px;
border-radius: 4px;
padding: 2px 6px;
font-size: 10px;
cursor: pointer;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
z-index: 5;
transition: transform 0.1s, z-index 0s;
line-height: 1.4;
}
.timeline-post:hover {
transform: scale(1.03);
z-index: 20;
}
.timeline-post.scheduled {
background: #ffc700;
color: #2d3838;
}
.timeline-post.approved {
background: #3b82f6;
color: white;
}
.timeline-post.published {
background: #374151;
color: #9ca3af;
border: 1px solid #6b7280;
}
.timeline-post .timeline-post-time {
font-weight: 600;
font-size: 10px;
}
.timeline-post .timeline-post-title {
font-size: 10px;
opacity: 0.9;
}
</style>
{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-white">Posting-Kalender</h1>
<p class="text-gray-400 mt-1">Plane und verwalte Posts aller Mitarbeiter</p>
</div>
<div class="flex items-center gap-4">
<!-- View Toggle -->
<div class="view-toggle flex rounded-lg overflow-hidden border border-gray-600">
<a href="/company/calendar?month={{ month }}&year={{ year }}&view=month"
class="px-3 py-1.5 text-sm transition-colors {% if view == 'month' %}active{% else %}text-gray-300 hover:bg-brand-bg-dark{% endif %}">
Monat
</a>
<a href="/company/calendar?view=week&month={{ month }}&year={{ year }}"
class="px-3 py-1.5 text-sm transition-colors {% if view == 'week' %}active{% else %}text-gray-300 hover:bg-brand-bg-dark{% endif %}">
Woche
</a>
</div>
<!-- Navigation -->
<div class="flex items-center gap-2">
{% if view == 'week' %}
<a href="/company/calendar?view=week&week_start={{ prev_week_start }}&month={{ month }}&year={{ year }}" class="p-2 rounded-lg bg-brand-bg-dark hover:bg-brand-bg-light transition-colors">
<svg class="w-5 h-5 text-gray-300" 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>
</a>
<span class="text-white font-medium min-w-[200px] text-center">{{ week_label }}</span>
<a href="/company/calendar?view=week&week_start={{ next_week_start }}&month={{ month }}&year={{ year }}" class="p-2 rounded-lg bg-brand-bg-dark hover:bg-brand-bg-light transition-colors">
<svg class="w-5 h-5 text-gray-300" 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>
</a>
{% else %}
<a href="/company/calendar?month={{ prev_month }}&year={{ prev_year }}&view=month" class="p-2 rounded-lg bg-brand-bg-dark hover:bg-brand-bg-light transition-colors">
<svg class="w-5 h-5 text-gray-300" 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>
</a>
<span class="text-white font-medium min-w-[150px] text-center">{{ month_name }} {{ year }}</span>
<a href="/company/calendar?month={{ next_month }}&year={{ next_year }}&view=month" class="p-2 rounded-lg bg-brand-bg-dark hover:bg-brand-bg-light transition-colors">
<svg class="w-5 h-5 text-gray-300" 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>
</a>
{% endif %}
</div>
{% if view == 'week' %}
<a href="/company/calendar?view=week" class="px-4 py-2 text-sm bg-brand-bg-dark hover:bg-brand-bg-light rounded-lg text-gray-300 transition-colors">
Heute
</a>
{% else %}
<a href="/company/calendar?month={{ current_month }}&year={{ current_year }}" class="px-4 py-2 text-sm bg-brand-bg-dark hover:bg-brand-bg-light rounded-lg text-gray-300 transition-colors">
Heute
</a>
{% endif %}
</div>
</div>
<!-- Employee Legend -->
{% if employees %}
<div class="card-bg border rounded-xl p-4 mb-6">
<p class="text-sm text-gray-400 mb-3">Mitarbeiter</p>
<div class="flex flex-wrap gap-3">
{% for emp in employees %}
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded employee-color-{{ loop.index % 5 + 1 }}" style="background: currentColor;"></div>
<span class="text-sm text-white">{{ emp.name }}</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Calendar -->
{% if view == 'week' %}
<!-- Week Timeline View -->
<div class="card-bg border rounded-xl overflow-hidden">
<!-- Day Headers -->
<div class="timeline-header">
<div class="timeline-header-cell" style="border-left: none;"></div>
{% for day in calendar_weeks[0] %}
<div class="timeline-header-cell {% if day.is_today %}today{% endif %}">
{{ day.full_date }}
</div>
{% endfor %}
</div>
<!-- Timeline Grid -->
<div class="timeline-grid" id="timelineGrid">
{% for hour in range(0, 24) %}
<!-- Hour row -->
<div class="timeline-hour-label">{{ '%02d'|format(hour) }}:00</div>
{% for day in calendar_weeks[0] %}
<div class="timeline-day-col"
data-date="{{ day.date }}" data-hour="{{ hour }}"
onclick="openScheduleModal('{{ day.date }}', '{{ '%02d'|format(hour) }}:00')"
style="cursor: pointer;">
{% for post in day.posts %}
{% set post_hour = post.time[:2]|int %}
{% if post_hour == hour %}
<div class="timeline-post {{ post.status }} employee-color-{{ post.employee_index % 5 + 1 }}"
onclick="event.stopPropagation(); openPostModal('{{ post.id }}')"
title="{{ post.employee_name }}: {{ post.topic_title }}"
data-day="{{ day.date }}"
data-abs-min="{{ post.time[:2]|int * 60 + post.time[3:]|int }}"
style="top: {% if post.status == 'published' %}{{ post.time[3:]|int - 36 }}{% else %}{{ post.time[3:]|int }}{% endif %}px;">
<div class="timeline-post-time">{{ post.time }}</div>
<div class="timeline-post-title">{{ post.topic_title[:15] }}{% if post.topic_title|length > 15 %}...{% endif %}</div>
</div>
{% endif %}
{% endfor %}
</div>
{% endfor %}
{% endfor %}
</div>
</div>
{% else %}
<!-- Month Grid View -->
<div class="card-bg border rounded-xl overflow-hidden">
<!-- Weekday Headers -->
<div class="grid grid-cols-7 bg-brand-bg-dark">
<div class="p-3 text-center text-gray-400 text-sm font-medium">Mo</div>
<div class="p-3 text-center text-gray-400 text-sm font-medium">Di</div>
<div class="p-3 text-center text-gray-400 text-sm font-medium">Mi</div>
<div class="p-3 text-center text-gray-400 text-sm font-medium">Do</div>
<div class="p-3 text-center text-gray-400 text-sm font-medium">Fr</div>
<div class="p-3 text-center text-gray-400 text-sm font-medium">Sa</div>
<div class="p-3 text-center text-gray-400 text-sm font-medium">So</div>
</div>
<!-- Calendar Grid -->
<div class="calendar-grid">
{% for week in calendar_weeks %}
{% for day in week %}
<div class="calendar-cell {% if day.other_month %}other-month{% endif %} {% if day.is_today %}today{% endif %}">
<div class="text-sm {% if day.is_today %}text-brand-highlight font-bold{% else %}text-gray-400{% endif %} mb-2">
{{ day.day }}
</div>
<!-- Posts for this day -->
{% for post in day.posts %}
<div class="post-chip {{ post.status }} employee-color-{{ post.employee_index % 5 + 1 }}"
onclick="openPostModal('{{ post.id }}')"
title="{{ post.employee_name }}: {{ post.topic_title }}">
<span class="time-slot">{{ post.time }}</span>
{{ post.topic_title[:20] }}{% if post.topic_title|length > 20 %}...{% endif %}
</div>
{% endfor %}
<!-- Add post button for empty slots -->
{% if not day.other_month %}
<button onclick="openScheduleModal('{{ day.date }}')"
data-date="{{ day.date }}"
class="schedule-btn w-full mt-1 p-1 text-xs text-gray-500 hover:text-brand-highlight hover:bg-brand-bg-dark rounded transition-colors opacity-0 hover:opacity-100">
+ Post planen
</button>
{% endif %}
</div>
{% endfor %}
{% endfor %}
</div>
</div>
{% endif %}
<!-- Unscheduled Posts -->
{% if unscheduled_posts %}
<div class="card-bg border rounded-xl p-6 mt-6">
<h2 class="text-lg font-semibold text-white mb-4">Nicht geplante Posts (bereit zum Einplanen)</h2>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for post in unscheduled_posts %}
<div class="bg-brand-bg-dark rounded-lg p-4" draggable="true" data-post-id="{{ post.id }}">
<div class="flex items-start justify-between mb-2">
<span class="text-xs px-2 py-1 rounded bg-blue-500/20 text-blue-400">{{ post.employee_name }}</span>
<span class="text-xs text-gray-500">{{ post.created_at.strftime('%d.%m.') }}</span>
</div>
<p class="text-white text-sm font-medium mb-1">{{ post.topic_title }}</p>
<p class="text-gray-400 text-xs line-clamp-2">{{ post.post_content[:100] }}...</p>
<button onclick="openScheduleModalForPost('{{ post.id }}', '{{ post.topic_title|e }}')"
class="mt-3 w-full py-2 text-xs bg-brand-highlight text-brand-bg-dark rounded hover:bg-brand-highlight-dark transition-colors">
Einplanen
</button>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
<!-- Schedule Modal -->
<div id="scheduleModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-brand-bg-light rounded-xl p-6 w-full max-w-md mx-4">
<h3 class="text-lg font-semibold text-white mb-4">Post einplanen</h3>
<form id="scheduleForm" onsubmit="submitSchedule(event)">
<input type="hidden" id="schedulePostId" name="post_id">
<!-- Select Post (if no post pre-selected) -->
<div id="postSelectContainer" class="mb-4">
<label class="block text-sm text-gray-300 mb-2">Post auswahlen</label>
<select id="schedulePostSelect" name="post_id_select" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
<option value="">-- Post auswahlen --</option>
{% for post in unscheduled_posts %}
<option value="{{ post.id }}">{{ post.employee_name }}: {{ post.topic_title[:40] }}...</option>
{% endfor %}
</select>
</div>
<!-- Date -->
<div class="mb-4">
<label class="block text-sm text-gray-300 mb-2">Datum</label>
<input type="date" id="scheduleDate" name="date" required
class="w-full input-bg border rounded-lg px-4 py-2 text-white">
</div>
<!-- Time -->
<div class="mb-6">
<label class="block text-sm text-gray-300 mb-2">Uhrzeit</label>
<input type="time" id="scheduleTime" name="time" value="09:00" required
class="w-full input-bg border rounded-lg px-4 py-2 text-white">
</div>
<div class="flex gap-3">
<button type="button" onclick="closeScheduleModal()" class="flex-1 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-500 transition-colors">
Abbrechen
</button>
<button type="submit" class="flex-1 py-2 bg-brand-highlight text-brand-bg-dark rounded-lg hover:bg-brand-highlight-dark transition-colors">
Einplanen
</button>
</div>
</form>
</div>
</div>
<!-- Post Detail Modal -->
<div id="postModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-brand-bg-light rounded-xl p-6 w-full max-w-lg mx-4">
<div class="flex items-start justify-between mb-4">
<h3 id="postModalTitle" class="text-lg font-semibold text-white">Post Details</h3>
<button onclick="closePostModal()" class="text-gray-400 hover:text-white">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div id="postModalContent" class="text-gray-300">
<!-- Filled via JS -->
</div>
<div id="postModalActions" class="mt-6 flex gap-3">
<!-- Filled via JS -->
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function isInPast(date, time) {
const now = new Date();
const check = time ? new Date(date + 'T' + time) : new Date(date + 'T23:59:59');
return check < now;
}
function openScheduleModal(date, time) {
if (isInPast(date, time)) return;
document.getElementById('schedulePostId').value = '';
document.getElementById('postSelectContainer').style.display = 'block';
document.getElementById('scheduleDate').value = date;
if (time) {
document.getElementById('scheduleTime').value = time;
}
document.getElementById('scheduleModal').classList.remove('hidden');
document.getElementById('scheduleModal').classList.add('flex');
}
function openScheduleModalForPost(postId, title) {
document.getElementById('schedulePostId').value = postId;
document.getElementById('postSelectContainer').style.display = 'none';
// Set default date to today
const today = new Date().toISOString().split('T')[0];
document.getElementById('scheduleDate').value = today;
document.getElementById('scheduleModal').classList.remove('hidden');
document.getElementById('scheduleModal').classList.add('flex');
}
function closeScheduleModal() {
document.getElementById('scheduleModal').classList.add('hidden');
document.getElementById('scheduleModal').classList.remove('flex');
}
async function submitSchedule(event) {
event.preventDefault();
const postId = document.getElementById('schedulePostId').value || document.getElementById('schedulePostSelect').value;
const date = document.getElementById('scheduleDate').value;
const time = document.getElementById('scheduleTime').value;
if (!postId) {
alert('Bitte wahlen Sie einen Post aus');
return;
}
if (isInPast(date, time)) {
alert('Der gewählte Zeitpunkt liegt in der Vergangenheit.');
return;
}
try {
// Convert local time to UTC for backend
const localDateTime = new Date(`${date}T${time}:00`);
const utcDateTime = localDateTime.toISOString();
const formData = new FormData();
formData.append('scheduled_at', utcDateTime);
const response = await fetch(`/api/posts/${postId}/schedule`, {
method: 'POST',
body: formData,
credentials: 'same-origin'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Fehler beim Einplanen');
}
window.location.reload();
} catch (error) {
alert('Fehler: ' + error.message);
}
}
async function openPostModal(postId) {
document.getElementById('postModal').classList.remove('hidden');
document.getElementById('postModal').classList.add('flex');
document.getElementById('postModalContent').innerHTML = '<p class="text-gray-400">Laden...</p>';
document.getElementById('postModalActions').innerHTML = '';
// Fetch post details
try {
const response = await fetch(`/api/posts/${postId}`, {
credentials: 'same-origin'
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `HTTP ${response.status}`);
}
const post = await response.json();
document.getElementById('postModalTitle').textContent = post.topic_title || 'Post Details';
const scheduledAt = post.scheduled_at ? new Date(post.scheduled_at).toLocaleString('de-DE') : 'Nicht geplant';
const statusLabel = {
'scheduled': 'Geplant',
'published': 'Veroffentlicht',
'approved': 'Bereit',
'draft': 'Entwurf'
}[post.status] || post.status;
document.getElementById('postModalContent').innerHTML = `
<div class="mb-4">
<span class="text-xs px-2 py-1 rounded ${post.status === 'scheduled' ? 'bg-yellow-500/20 text-yellow-400' : post.status === 'published' ? 'bg-green-500/20 text-green-400' : 'bg-blue-500/20 text-blue-400'}">
${statusLabel}
</span>
${post.status === 'scheduled' ? `<span class="text-xs text-gray-400 ml-2">${scheduledAt}</span>` : ''}
</div>
<div class="bg-brand-bg-dark rounded-lg p-4 max-h-64 overflow-y-auto">
<p class="text-sm whitespace-pre-wrap">${post.post_content}</p>
</div>
`;
let actionsHtml = `
<a href="/company/manage/post/${postId}" class="flex-1 py-2 text-center bg-brand-bg-dark text-white rounded-lg hover:bg-gray-600 transition-colors">
Details ansehen
</a>
`;
if (post.status === 'scheduled') {
actionsHtml += `
<button onclick="unschedulePost('${postId}')" class="flex-1 py-2 bg-red-500/20 text-red-400 rounded-lg hover:bg-red-500/30 transition-colors">
Planung aufheben
</button>
`;
} else if (post.status === 'approved') {
actionsHtml += `
<button onclick="closePostModal(); openScheduleModalForPost('${postId}', '')" class="flex-1 py-2 bg-brand-highlight text-brand-bg-dark rounded-lg hover:bg-brand-highlight-dark transition-colors">
Einplanen
</button>
`;
}
document.getElementById('postModalActions').innerHTML = actionsHtml;
} catch (error) {
console.error('Error loading post:', error);
document.getElementById('postModalContent').innerHTML = `<p class="text-red-400">Fehler beim Laden: ${error.message}</p>`;
}
}
function closePostModal() {
document.getElementById('postModal').classList.add('hidden');
document.getElementById('postModal').classList.remove('flex');
}
async function unschedulePost(postId) {
if (!confirm('Mochten Sie die Planung dieses Posts wirklich aufheben?')) return;
try {
const response = await fetch(`/api/posts/${postId}/unschedule`, {
method: 'POST',
credentials: 'same-origin'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Fehler beim Aufheben der Planung');
}
window.location.reload();
} catch (error) {
alert('Fehler: ' + error.message);
}
}
// Close modals on outside click
document.getElementById('scheduleModal').addEventListener('click', function(e) {
if (e.target === this) closeScheduleModal();
});
document.getElementById('postModal').addEventListener('click', function(e) {
if (e.target === this) closePostModal();
});
// Layout overlapping timeline posts side by side
(function() {
const grid = document.getElementById('timelineGrid');
if (!grid) return;
const POST_HEIGHT = 36;
const dayPosts = {};
grid.querySelectorAll('.timeline-post').forEach(el => {
const day = el.dataset.day;
const absMin = parseInt(el.dataset.absMin);
const isPublished = el.classList.contains('published');
const top = isPublished ? absMin - POST_HEIGHT : absMin;
if (!dayPosts[day]) dayPosts[day] = [];
dayPosts[day].push({ el, top, bottom: top + POST_HEIGHT });
});
Object.values(dayPosts).forEach(posts => {
if (posts.length <= 1) return;
posts.sort((a, b) => a.top - b.top);
// Group overlapping posts
const groups = [];
let group = [posts[0]];
for (let i = 1; i < posts.length; i++) {
const overlaps = group.some(p => posts[i].top < p.bottom && posts[i].bottom > p.top);
if (overlaps) {
group.push(posts[i]);
} else {
groups.push(group);
group = [posts[i]];
}
}
groups.push(group);
groups.forEach(g => {
if (g.length <= 1) return;
const w = 100 / g.length;
g.forEach((p, idx) => {
p.el.style.left = (idx * w) + '%';
p.el.style.width = w + '%';
p.el.style.right = 'auto';
});
});
});
})();
// Dim past slots and hide past schedule buttons
(function() {
const now = new Date();
const todayStr = now.toISOString().split('T')[0];
const currentHour = now.getHours();
// Week view: dim past hour cells
document.querySelectorAll('.timeline-day-col').forEach(cell => {
const date = cell.dataset.date;
const hour = parseInt(cell.dataset.hour);
if (date < todayStr || (date === todayStr && hour <= currentHour)) {
cell.classList.add('past-slot');
cell.removeAttribute('onclick');
}
});
// Month view: hide "+ Post planen" for past dates
document.querySelectorAll('.schedule-btn').forEach(btn => {
const date = btn.dataset.date;
if (date < todayStr) {
btn.classList.add('past-slot');
}
});
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,180 @@
{% extends "company_base.html" %}
{% block title %}Dashboard - {{ session.company_name }}{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<h1 class="text-2xl font-bold text-white mb-6">Dashboard</h1>
<!-- Stats Cards -->
<div class="grid md:grid-cols-3 gap-6 mb-8">
<!-- Employees Card -->
<div class="card-bg border rounded-xl 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>
{% if license_key %}
<p class="text-3xl font-bold text-white">{{ total_employees }}<span class="text-xl text-gray-500">/{{ license_key.max_employees }}</span></p>
<p class="text-gray-400 text-sm">Mitarbeiter</p>
<p class="text-xs text-gray-500 mt-1">
{% set remaining = license_key.max_employees - total_employees %}
{% if remaining > 0 %}
Noch {{ remaining }} verfügbar
{% else %}
<span class="text-yellow-400">Limit erreicht</span>
{% endif %}
</p>
{% else %}
<p class="text-3xl font-bold text-white">{{ total_employees }}</p>
<p class="text-gray-400 text-sm">Mitarbeiter</p>
<p class="text-xs text-green-400 mt-1">Unbegrenzt</p>
{% endif %}
</div>
</div>
</div>
<!-- Pending Invitations Card -->
<div class="card-bg border rounded-xl p-6">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
</div>
<div>
<p class="text-3xl font-bold text-white">{{ pending_invitations | length }}</p>
<p class="text-gray-400 text-sm">Offene Einladungen</p>
</div>
</div>
</div>
<!-- Strategy Status Card -->
<div class="card-bg border rounded-xl p-6">
<div class="flex items-center gap-4">
<div class="w-12 h-12 {% if company.company_strategy %}bg-green-500/20{% else %}bg-yellow-500/20{% endif %} rounded-lg flex items-center justify-center">
{% if company.company_strategy and company.company_strategy.mission %}
<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 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{% else %}
<svg class="w-6 h-6 text-yellow-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>
{% endif %}
</div>
<div>
{% if company.company_strategy and company.company_strategy.mission %}
<p class="text-lg font-bold text-green-400">Aktiv</p>
<p class="text-gray-400 text-sm">Strategie definiert</p>
{% else %}
<p class="text-lg font-bold text-yellow-400">Ausstehend</p>
<p class="text-gray-400 text-sm">Strategie fehlt</p>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Daily Quota Section -->
{% if quota and license_key %}
<div class="card-bg border rounded-xl p-6 mb-8">
<h2 class="text-lg font-semibold text-white mb-4">Tägliche Limits</h2>
<div class="grid md:grid-cols-2 gap-4">
<!-- Posts Quota -->
<div class="bg-brand-bg/30 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-gray-400">Posts heute</span>
<span class="text-xs text-gray-500">{{ quota.posts_created | default(0) }}/{{ license_key.max_posts_per_day }}</span>
</div>
<div class="w-full bg-brand-bg rounded-full h-2">
{% set posts_pct = ((quota.posts_created / license_key.max_posts_per_day * 100) if license_key.max_posts_per_day > 0 else 0) | round %}
<div class="bg-brand-highlight h-2 rounded-full transition-all"
style="width: {{ posts_pct }}%"></div>
</div>
</div>
<!-- Researches Quota -->
<div class="bg-brand-bg/30 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-gray-400">Researches heute</span>
<span class="text-xs text-gray-500">{{ quota.researches_created | default(0) }}/{{ license_key.max_researches_per_day }}</span>
</div>
<div class="w-full bg-brand-bg rounded-full h-2">
{% set researches_pct = ((quota.researches_created / license_key.max_researches_per_day * 100) if license_key.max_researches_per_day > 0 else 0) | round %}
<div class="bg-blue-500 h-2 rounded-full transition-all"
style="width: {{ researches_pct }}%"></div>
</div>
</div>
</div>
<p class="text-xs text-gray-500 mt-3">
Limits werden täglich um Mitternacht zurückgesetzt. (Lizenz: {{ license_key.key }})
</p>
</div>
{% elif quota %}
<!-- No license key - show info message -->
<div class="bg-blue-900/50 border border-blue-500 text-blue-200 px-4 py-3 rounded-lg mb-6">
<strong>Info:</strong> Dieser Account hat keinen Lizenzschlüssel und ist daher unbegrenzt.
</div>
{% endif %}
<!-- Quick Actions -->
<div class="card-bg border rounded-xl p-6 mb-8">
<h2 class="text-lg font-semibold text-white mb-4">Schnellzugriff</h2>
<div class="grid md:grid-cols-2 gap-4">
<a href="/company/accounts" 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 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
<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="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>
</div>
<div>
<p class="text-white font-medium">Mitarbeiter einladen</p>
<p class="text-gray-400 text-sm">Neue Teammitglieder hinzufügen</p>
</div>
</a>
<a href="/company/strategy" 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 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
<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="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="text-white font-medium">Strategie bearbeiten</p>
<p class="text-gray-400 text-sm">Brand Voice und Richtlinien anpassen</p>
</div>
</a>
</div>
</div>
<!-- Recent Employees -->
{% if employees %}
<div class="card-bg border rounded-xl p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-white">Mitarbeiter</h2>
<a href="/company/accounts" class="text-brand-highlight text-sm hover:underline">Alle anzeigen</a>
</div>
<div class="space-y-3">
{% for emp in employees[:5] %}
<div class="flex items-center gap-3 p-3 bg-brand-bg rounded-lg">
<div class="w-8 h-8 rounded-full bg-brand-highlight/20 flex items-center justify-center">
<span class="text-brand-highlight text-sm font-medium">{{ (emp.display_name or emp.email)[0] | upper }}</span>
</div>
<div class="flex-1">
<p class="text-white text-sm">{{ emp.display_name or emp.email }}</p>
<p class="text-gray-500 text-xs">{{ emp.email }}</p>
</div>
<span class="text-xs px-2 py-1 rounded {% if emp.onboarding_status == 'completed' %}bg-green-500/20 text-green-400{% else %}bg-yellow-500/20 text-yellow-400{% endif %}">
{% if emp.onboarding_status == 'completed' %}Aktiv{% else %}Onboarding{% endif %}
</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,195 @@
{% extends "company_base.html" %}
{% block title %}Inhalte verwalten - {{ session.company_name }}{% endblock %}
{% block content %}
<div class="max-w-6xl mx-auto">
<h1 class="text-2xl font-bold text-white mb-6">Inhalte verwalten</h1>
<!-- Employee Selector -->
<div class="card-bg border rounded-xl p-6 mb-8">
<label class="block text-sm font-medium text-gray-300 mb-3">Mitarbeiter auswählen</label>
{% if active_employees %}
<div class="flex flex-wrap gap-3">
{% for emp in active_employees %}
<a href="/company/manage?employee_id={{ emp.id }}"
class="flex items-center gap-3 px-4 py-3 rounded-lg transition-colors {% if selected_employee and selected_employee.id == emp.id %}bg-brand-highlight text-brand-bg-dark{% else %}bg-brand-bg hover:bg-brand-bg-light text-white{% endif %}">
<div class="w-8 h-8 rounded-full {% if selected_employee and selected_employee.id == emp.id %}bg-brand-bg-dark/20{% else %}bg-brand-highlight/20{% endif %} flex items-center justify-center">
{% if emp.linkedin_picture %}
<img src="{{ emp.linkedin_picture }}" alt="" class="w-8 h-8 rounded-full">
{% else %}
<span class="{% if selected_employee and selected_employee.id == emp.id %}text-brand-bg-dark{% else %}text-brand-highlight{% endif %} text-sm font-medium">{{ (emp.display_name or emp.email)[0] | upper }}</span>
{% endif %}
</div>
<span class="font-medium">{{ emp.display_name or emp.email }}</span>
</a>
{% endfor %}
</div>
{% else %}
<div class="text-center py-8">
<div class="w-16 h-16 bg-gray-600/30 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-gray-500" 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>
<p class="text-gray-400 mb-4">Keine aktiven Mitarbeiter vorhanden</p>
<p class="text-gray-500 text-sm">Mitarbeiter erscheinen hier, sobald sie ihr Onboarding abgeschlossen haben.</p>
<a href="/company/accounts" class="inline-block mt-4 text-brand-highlight hover:underline">Mitarbeiter einladen</a>
</div>
{% endif %}
</div>
{% if selected_employee %}
<!-- Selected Employee Info -->
<div class="card-bg border rounded-xl p-6 mb-8">
<div class="flex items-center gap-4">
<div class="w-16 h-16 rounded-full bg-brand-highlight/20 flex items-center justify-center overflow-hidden">
{% if selected_employee.linkedin_picture %}
<img src="{{ selected_employee.linkedin_picture }}" alt="" class="w-16 h-16 rounded-full">
{% else %}
<span class="text-brand-highlight text-2xl font-medium">{{ (selected_employee.display_name or selected_employee.email)[0] | upper }}</span>
{% endif %}
</div>
<div>
<h2 class="text-xl font-bold text-white">{{ selected_employee.display_name or selected_employee.email }}</h2>
<p class="text-gray-400">{{ selected_employee.email }}</p>
</div>
</div>
</div>
<!-- Stats -->
<div class="grid md:grid-cols-3 gap-6 mb-8">
<div class="card-bg border rounded-xl 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="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-3xl font-bold text-white">{{ employee_posts | length }}</p>
<p class="text-gray-400 text-sm">Posts gesamt</p>
</div>
</div>
</div>
<div class="card-bg border rounded-xl p-6">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-yellow-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-yellow-400" 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>
</div>
<div>
<p class="text-3xl font-bold text-white">{{ pending_posts }}</p>
<p class="text-gray-400 text-sm">Ausstehend</p>
</div>
</div>
</div>
<div class="card-bg border rounded-xl p-6">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-green-500/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 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div>
<p class="text-3xl font-bold text-white">{{ approved_posts }}</p>
<p class="text-gray-400 text-sm">Genehmigt</p>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="card-bg border rounded-xl p-6 mb-8">
<h3 class="text-lg font-semibold text-white mb-4">Aktionen</h3>
<div class="grid md:grid-cols-3 gap-4">
<a href="/company/manage/research?employee_id={{ selected_employee.id }}" 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 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</div>
<div>
<p class="text-white font-medium">Research Topics</p>
<p class="text-gray-400 text-sm">Themen recherchieren</p>
</div>
</a>
<a href="/company/manage/create?employee_id={{ selected_employee.id }}" 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 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
<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 4v16m8-8H4"/>
</svg>
</div>
<div>
<p class="text-white font-medium">Neuer Post</p>
<p class="text-gray-400 text-sm">KI-generiert erstellen</p>
</div>
</a>
<a href="/company/manage/posts?employee_id={{ selected_employee.id }}" 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 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
<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="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="text-white font-medium">Alle Posts</p>
<p class="text-gray-400 text-sm">Posts anzeigen</p>
</div>
</a>
</div>
</div>
<!-- Recent Posts -->
{% if employee_posts %}
<div class="card-bg border rounded-xl p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-white">Letzte Posts</h3>
<a href="/company/manage/posts?employee_id={{ selected_employee.id }}" class="text-brand-highlight text-sm hover:underline">Alle anzeigen</a>
</div>
<div class="space-y-3">
{% for post in employee_posts[:5] %}
<a href="/company/manage/post/{{ post.id }}?employee_id={{ selected_employee.id }}" class="block p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
<div class="flex items-start gap-3">
<div class="flex-1">
<p class="text-white text-sm line-clamp-2">{{ post.post_content[:150] }}{% if post.post_content|length > 150 %}...{% endif %}</p>
<p class="text-gray-500 text-xs mt-2">{{ post.created_at.strftime('%d.%m.%Y %H:%M') }}</p>
</div>
<span class="text-xs px-2 py-1 rounded flex-shrink-0 {% if post.status == 'approved' %}bg-green-500/20 text-green-400{% elif post.status == 'rejected' %}bg-red-500/20 text-red-400{% else %}bg-yellow-500/20 text-yellow-400{% endif %}">
{% if post.status == 'approved' %}Genehmigt{% elif post.status == 'rejected' %}Abgelehnt{% else %}Ausstehend{% endif %}
</span>
</div>
</a>
{% endfor %}
</div>
</div>
{% else %}
<div class="card-bg border rounded-xl p-6 text-center">
<div class="w-16 h-16 bg-gray-600/30 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-gray-500" 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>
<p class="text-gray-400">Noch keine Posts vorhanden</p>
<a href="/company/manage/create?employee_id={{ selected_employee.id }}" class="inline-block mt-4 text-brand-highlight hover:underline">Ersten Post erstellen</a>
</div>
{% endif %}
{% else %}
<!-- No Employee Selected -->
{% if active_employees %}
<div class="card-bg border rounded-xl p-6 text-center">
<div class="w-16 h-16 bg-brand-highlight/20 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122"/>
</svg>
</div>
<p class="text-white text-lg font-medium mb-2">Mitarbeiter auswählen</p>
<p class="text-gray-400">Wähle oben einen Mitarbeiter aus, um dessen Inhalte zu verwalten.</p>
</div>
{% endif %}
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,979 @@
{% extends "company_base.html" %}
{% block title %}Post erstellen - {{ employee_name }} - {{ session.company_name }}{% endblock %}
{% block content %}
<!-- Breadcrumb -->
<div class="mb-6">
<nav class="flex items-center gap-2 text-sm">
<a href="/company/manage" class="text-gray-400 hover:text-white transition-colors">Inhalte verwalten</a>
<svg class="w-4 h-4 text-gray-600" 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>
<a href="/company/manage?employee_id={{ employee_id }}" class="text-gray-400 hover:text-white transition-colors">{{ employee_name }}</a>
<svg class="w-4 h-4 text-gray-600" 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>
<span class="text-white">Post erstellen</span>
</nav>
</div>
<!-- Limit Warning -->
{% if limit_reached %}
<div class="max-w-2xl mx-auto mb-8">
<div class="bg-red-900/50 border border-red-500 text-red-200 px-6 py-4 rounded-xl">
<div class="flex items-center gap-3">
<svg class="w-6 h-6 flex-shrink-0" 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>
<strong class="font-semibold">Limit erreicht</strong>
<p class="text-sm mt-1">{{ limit_message }}</p>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Wizard Container (hidden during generation) -->
<div id="wizardContainer" {% if limit_reached %}style="pointer-events: none; opacity: 0.5;"{% endif %}>
<div class="max-w-2xl mx-auto">
<div class="mb-8 text-center">
<h1 class="text-3xl font-bold text-white mb-2">Post erstellen</h1>
<p class="text-gray-400">Generiere einen neuen LinkedIn Post für {{ employee_name }}</p>
</div>
<!-- Wizard Steps Indicator -->
<div class="mb-8">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div id="stepIndicator1" class="w-10 h-10 rounded-full bg-brand-highlight text-brand-bg-dark flex items-center justify-center font-bold">1</div>
<span class="ml-2 text-white font-medium hidden sm:inline">Post-Typ</span>
</div>
<div class="flex-1 h-1 mx-4 bg-brand-bg-light rounded">
<div id="progressLine1" class="h-full bg-brand-highlight rounded transition-all duration-300" style="width: 0%"></div>
</div>
<div class="flex items-center">
<div id="stepIndicator2" class="w-10 h-10 rounded-full bg-brand-bg-light text-gray-400 flex items-center justify-center font-bold">2</div>
<span class="ml-2 text-gray-400 font-medium hidden sm:inline">Thema</span>
</div>
<div class="flex-1 h-1 mx-4 bg-brand-bg-light rounded">
<div id="progressLine2" class="h-full bg-brand-highlight rounded transition-all duration-300" style="width: 0%"></div>
</div>
<div class="flex items-center">
<div id="stepIndicator3" class="w-10 h-10 rounded-full bg-brand-bg-light text-gray-400 flex items-center justify-center font-bold">3</div>
<span class="ml-2 text-gray-400 font-medium hidden sm:inline">Gedanken</span>
</div>
<div class="flex-1 h-1 mx-4 bg-brand-bg-light rounded">
<div id="progressLine3" class="h-full bg-brand-highlight rounded transition-all duration-300" style="width: 0%"></div>
</div>
<div class="flex items-center">
<div id="stepIndicator4" class="w-10 h-10 rounded-full bg-brand-bg-light text-gray-400 flex items-center justify-center font-bold">4</div>
<span class="ml-2 text-gray-400 font-medium hidden sm:inline">Hook</span>
</div>
</div>
</div>
<!-- Step 1: Post-Typ -->
<div id="step1" class="wizard-step card-bg rounded-xl border p-8">
<h2 class="text-xl font-semibold text-white mb-2">Post-Typ auswählen</h2>
<p class="text-gray-400 mb-6">Wähle einen Post-Typ, um die Topics zu filtern und den Stil anzupassen. <span class="text-gray-500">(optional)</span></p>
<div id="postTypeCards" class="flex flex-wrap gap-3 mb-8">
<p class="text-gray-500">Lade Post-Typen...</p>
</div>
<input type="hidden" id="selectedPostTypeId" value="">
<div class="flex justify-center">
<button onclick="goToStep(2)" class="px-8 py-3 rounded-lg font-medium bg-brand-bg hover:bg-brand-bg-light text-white transition-colors">
Überspringen
</button>
</div>
</div>
<!-- Step 2: Thema -->
<div id="step2" class="wizard-step card-bg rounded-xl border p-8 hidden">
<h2 class="text-xl font-semibold text-white mb-2">Thema auswählen</h2>
<p class="text-gray-400 mb-6">Wähle ein recherchiertes Topic oder gib ein eigenes ein.</p>
<div id="topicsList" class="space-y-2 max-h-72 overflow-y-auto mb-6">
<p class="text-gray-500">Lade Topics...</p>
</div>
<div class="border-t border-brand-bg-light pt-6 mt-6">
<label class="block text-sm font-medium text-gray-300 mb-3">Oder eigenes Topic eingeben</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-3 text-white">
<textarea id="customTopicFact" rows="3" placeholder="Fakt / Kernaussage zum Topic..." class="w-full input-bg border rounded-lg px-4 py-3 text-white"></textarea>
<input type="text" id="customTopicSource" placeholder="Quelle (optional)" class="w-full input-bg border rounded-lg px-4 py-3 text-white">
</div>
</div>
<div class="flex justify-between gap-3 mt-8">
<button onclick="goToStep(1)" class="px-6 py-3 rounded-lg font-medium bg-brand-bg hover:bg-brand-bg-light text-white transition-colors">
Zurück
</button>
<button onclick="validateAndGoToStep3()" class="btn-primary px-8 py-3 rounded-lg font-medium">
Weiter
</button>
</div>
</div>
<!-- Step 3: Gedanken -->
<div id="step3" class="wizard-step card-bg rounded-xl border p-8 hidden">
<h2 class="text-xl font-semibold text-white mb-2">Gedanken zum Thema</h2>
<p class="text-gray-400 mb-6">Was soll {{ employee_name }} zu diesem Thema sagen? Persönliche Meinung, Erfahrungen oder Einsichten.</p>
<div class="relative">
<textarea id="userThoughts" rows="8" placeholder="Schreibe die Gedanken ein...
z.B. 'Ich habe letzte Woche selbst erfahren, dass...'
oder 'Meine Meinung dazu ist...'"
class="w-full input-bg border rounded-lg px-4 py-4 text-white pr-16 text-lg"></textarea>
<!-- Speech-to-Text Button -->
<button id="speechBtn" type="button"
class="absolute right-4 top-4 p-3 rounded-lg bg-brand-bg hover:bg-brand-highlight hover:text-brand-bg-dark transition-colors group"
title="Spracheingabe starten">
<svg id="micIcon" class="w-6 h-6 text-gray-400 group-hover:text-brand-bg-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"/>
</svg>
<svg id="micIconRecording" class="w-6 h-6 text-red-500 hidden animate-pulse" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.91-3c-.49 0-.9.36-.98.85C16.52 14.2 14.47 16 12 16s-4.52-1.8-4.93-4.15c-.08-.49-.49-.85-.98-.85-.61 0-1.09.54-1 1.14.49 3 2.89 5.35 5.91 5.78V20c0 .55.45 1 1 1s1-.45 1-1v-2.08c3.02-.43 5.42-2.78 5.91-5.78.1-.6-.39-1.14-1-1.14z"/>
</svg>
</button>
</div>
<div class="flex items-center justify-between mt-2">
<p id="speechSupport" class="text-xs text-gray-500"></p>
<p id="speechStatus" class="text-sm text-brand-highlight hidden"></p>
</div>
<div class="flex justify-between gap-3 mt-8">
<button onclick="goToStep(2)" class="px-6 py-3 rounded-lg font-medium bg-brand-bg hover:bg-brand-bg-light text-white transition-colors">
Zurück
</button>
<button onclick="goToStep(4)" class="btn-primary px-8 py-3 rounded-lg font-medium">
Weiter
</button>
</div>
</div>
<!-- Step 4: Hook-Auswahl -->
<div id="step4" class="wizard-step card-bg rounded-xl border p-8 hidden">
<h2 class="text-xl font-semibold text-white mb-2">Hook auswählen</h2>
<p class="text-gray-400 mb-6">Der Hook ist der erste Satz des Posts. Wähle einen KI-generierten oder schreibe einen eigenen.</p>
<div id="hookLoading" class="text-center py-12">
<svg class="w-10 h-10 animate-spin mx-auto text-brand-highlight" 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>
<p class="text-gray-400 mt-4">Generiere Hooks...</p>
</div>
<div id="hookOptions" class="space-y-3 hidden">
<!-- Hook options will be inserted here -->
</div>
<div id="hookError" class="text-red-400 text-center py-6 hidden"></div>
<div class="border-t border-brand-bg-light pt-6 mt-6">
<label class="block text-sm font-medium text-gray-300 mb-3">Oder eigenen Hook eingeben</label>
<textarea id="customHook" rows="2" placeholder="Eigener Hook-Text..."
class="w-full input-bg border rounded-lg px-4 py-3 text-white"></textarea>
</div>
<div class="flex justify-between gap-3 mt-8">
<button onclick="goToStep(3)" class="px-6 py-3 rounded-lg font-medium bg-brand-bg hover:bg-brand-bg-light text-white transition-colors">
Zurück
</button>
<button onclick="startPostCreation()" id="createPostBtn" class="btn-primary px-8 py-3 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="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
</button>
</div>
</div>
</div>
</div>
<!-- Generation & Result Container (shown after clicking "Post erstellen") -->
<div id="generationContainer" class="hidden">
<div class="max-w-4xl mx-auto">
<div class="mb-8 text-center">
<h1 class="text-3xl font-bold text-white mb-2">Post wird generiert</h1>
<p class="text-gray-400" id="topicTitle"></p>
</div>
<!-- Progress Section -->
<div id="generationProgress" class="card-bg rounded-xl border p-8 mb-8">
<div class="max-w-xl mx-auto">
<div class="flex items-center justify-between mb-3">
<span id="progressMessage" class="text-gray-300">Starte Post-Erstellung...</span>
<span id="progressPercent" class="text-gray-400 font-medium">0%</span>
</div>
<div class="w-full bg-brand-bg-dark rounded-full h-3">
<div id="progressBar" class="bg-brand-highlight h-3 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<div id="iterationInfo" class="mt-3 text-sm text-gray-400 text-center"></div>
</div>
</div>
<!-- Live Versions Display -->
<div id="liveVersions" class="hidden mb-8">
<div class="card-bg rounded-xl border p-8">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-semibold text-white">Live-Vorschau</h3>
<div id="versionTabs" class="flex gap-2"></div>
</div>
<div id="versionsContainer"></div>
</div>
</div>
<!-- Final Result -->
<div id="finalResult" class="hidden">
<div class="card-bg rounded-xl border p-8">
<div id="resultHeader" class="flex items-center justify-between mb-6"></div>
<div id="postResult" class="bg-brand-bg/50 rounded-lg p-6 mb-6"></div>
<!-- Image Upload Area -->
<div id="resultImageArea" class="mb-6 hidden">
<div id="resultImageUploadZone" class="border-2 border-dashed border-brand-bg-light rounded-xl p-6 text-center cursor-pointer hover:border-brand-highlight transition-colors">
<input type="file" id="resultImageInput" accept="image/jpeg,image/png,image/gif,image/webp" class="hidden">
<svg class="w-8 h-8 mx-auto mb-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
<p class="text-sm text-gray-400">Bild hierher ziehen oder <span class="text-brand-highlight">durchsuchen</span></p>
<p class="text-xs text-gray-500 mt-1">JPEG, PNG, GIF, WebP - max. 5 MB</p>
</div>
<div id="resultImageProgress" class="hidden mt-2">
<div style="height:4px;background:rgba(61,72,72,0.8);border-radius:2px;overflow:hidden;">
<div id="resultImageProgressBar" style="height:100%;background:#ffc700;border-radius:2px;transition:width 0.3s;width:0%"></div>
</div>
<p class="text-xs text-gray-400 mt-1 text-center">Wird hochgeladen...</p>
</div>
<div id="resultImagePreview" class="hidden mt-3">
<img id="resultImageImg" src="" alt="Post-Bild" class="rounded-lg w-full max-h-64 object-cover mb-2">
<div class="flex gap-2 justify-center">
<button onclick="document.getElementById('resultImageReplaceInput').click()" class="px-3 py-1.5 bg-brand-bg hover:bg-brand-bg-light text-gray-300 rounded-lg text-sm transition-colors">Ersetzen</button>
<button onclick="removeResultImage()" id="removeResultImageBtn" class="px-3 py-1.5 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg text-sm transition-colors">Entfernen</button>
</div>
<input type="file" id="resultImageReplaceInput" accept="image/jpeg,image/png,image/gif,image/webp" class="hidden">
</div>
</div>
<div id="resultActions" class="flex gap-3 flex-wrap justify-center"></div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// ==================== CONFIG ====================
const USER_ID = '{{ user_id }}';
const EMPLOYEE_ID = '{{ employee_id }}';
const EMPLOYEE_NAME = '{{ employee_name }}';
// ==================== STATE ====================
let currentStep = 1;
let selectedTopic = null;
let currentPostTypes = [];
let currentTopics = [];
let selectedHook = null;
let generatedHooks = [];
let isRecording = false;
let currentVersionIndex = 0;
// ==================== ELEMENTS ====================
const steps = [1, 2, 3, 4];
const wizardContainer = document.getElementById('wizardContainer');
const generationContainer = document.getElementById('generationContainer');
const selectedPostTypeIdInput = document.getElementById('selectedPostTypeId');
const postTypeCards = document.getElementById('postTypeCards');
const topicsList = document.getElementById('topicsList');
const userThoughtsInput = document.getElementById('userThoughts');
const speechBtn = document.getElementById('speechBtn');
const micIcon = document.getElementById('micIcon');
const micIconRecording = document.getElementById('micIconRecording');
const speechStatus = document.getElementById('speechStatus');
const speechSupport = document.getElementById('speechSupport');
const hookLoading = document.getElementById('hookLoading');
const hookOptions = document.getElementById('hookOptions');
const hookError = document.getElementById('hookError');
const customHook = document.getElementById('customHook');
const generationProgress = document.getElementById('generationProgress');
const progressBar = document.getElementById('progressBar');
const progressMessage = document.getElementById('progressMessage');
const progressPercent = document.getElementById('progressPercent');
const iterationInfo = document.getElementById('iterationInfo');
const liveVersions = document.getElementById('liveVersions');
const versionTabs = document.getElementById('versionTabs');
const versionsContainer = document.getElementById('versionsContainer');
const finalResult = document.getElementById('finalResult');
const resultHeader = document.getElementById('resultHeader');
const postResult = document.getElementById('postResult');
const resultActions = document.getElementById('resultActions');
const topicTitleEl = document.getElementById('topicTitle');
// ==================== WIZARD NAVIGATION ====================
function goToStep(step) {
document.querySelectorAll('.wizard-step').forEach(el => el.classList.add('hidden'));
document.getElementById(`step${step}`).classList.remove('hidden');
steps.forEach(s => {
const indicator = document.getElementById(`stepIndicator${s}`);
const progressLine = document.getElementById(`progressLine${s}`);
if (s < step) {
indicator.className = 'w-10 h-10 rounded-full bg-brand-highlight text-brand-bg-dark flex items-center justify-center font-bold';
if (progressLine) progressLine.style.width = '100%';
} else if (s === step) {
indicator.className = 'w-10 h-10 rounded-full bg-brand-highlight text-brand-bg-dark flex items-center justify-center font-bold';
if (progressLine) progressLine.style.width = '0%';
} else {
indicator.className = 'w-10 h-10 rounded-full bg-brand-bg-light text-gray-400 flex items-center justify-center font-bold';
if (progressLine) progressLine.style.width = '0%';
}
});
currentStep = step;
if (step === 4) loadHooks();
}
function validateAndGoToStep3() {
const customTitle = document.getElementById('customTopicTitle').value.trim();
const customFact = document.getElementById('customTopicFact').value.trim();
if (!selectedTopic && (!customTitle || !customFact)) {
alert('Bitte wähle ein Topic aus oder gib ein eigenes ein (Titel und Fakt sind erforderlich).');
return;
}
if (customTitle && customFact) {
selectedTopic = {
title: customTitle,
fact: customFact,
source: document.getElementById('customTopicSource').value.trim() || null,
category: 'Custom'
};
}
goToStep(3);
}
// ==================== LOAD DATA ====================
async function loadData() {
try {
const ptResponse = await fetch(`/api/post-types?user_id=${USER_ID}`);
const ptData = await ptResponse.json();
if (ptData.post_types && ptData.post_types.length > 0) {
currentPostTypes = ptData.post_types;
postTypeCards.innerHTML = `
<button type="button" onclick="selectPostType('')" id="ptc_all"
class="px-5 py-3 rounded-lg border text-sm transition-colors bg-brand-highlight/20 border-brand-highlight text-white font-medium">
Alle Typen
</button>
` + ptData.post_types.map(pt => `
<button type="button" onclick="selectPostType('${pt.id}')" id="ptc_${pt.id}"
class="px-5 py-3 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 {
postTypeCards.innerHTML = '<p class="text-gray-500">Keine Post-Typen verfügbar. Du kannst diesen Schritt überspringen.</p>';
}
} catch (error) {
console.error('Failed to load post types:', error);
postTypeCards.innerHTML = '<p class="text-gray-500">Keine Post-Typen verfügbar. Du kannst diesen Schritt überspringen.</p>';
}
loadTopics();
}
async function loadTopics(postTypeId = null) {
topicsList.innerHTML = '<p class="text-gray-500">Lade Topics...</p>';
try {
let url = `/api/topics?user_id=${USER_ID}`;
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-6">
<p class="text-gray-400 mb-2">Alle ${data.used_count} Topics wurden bereits verwendet.</p>
<a href="/company/manage/research?employee_id=${EMPLOYEE_ID}" class="text-brand-highlight hover:underline">Neue Topics recherchieren</a>
</div>`;
} else {
message = `<div class="text-center py-6">
<p class="text-gray-400 mb-2">Keine Topics gefunden${postTypeId ? ' für diesen Post-Typ' : ''}.</p>
<a href="/company/manage/research?employee_id=${EMPLOYEE_ID}" class="text-brand-highlight hover:underline">Recherche starten</a>
</div>`;
}
topicsList.innerHTML = message;
}
} catch (error) {
topicsList.innerHTML = `<p class="text-red-400">Fehler beim Laden: ${error.message}</p>`;
}
}
function selectPostType(typeId) {
selectedPostTypeIdInput.value = typeId;
document.querySelectorAll('[id^="ptc_"]').forEach(card => {
if (card.id === `ptc_${typeId}` || (typeId === '' && card.id === 'ptc_all')) {
card.className = 'px-5 py-3 rounded-lg border text-sm transition-colors bg-brand-highlight/20 border-brand-highlight text-white font-medium';
} else {
card.className = 'px-5 py-3 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white';
}
});
loadTopics(typeId);
goToStep(2);
}
function renderTopicsList(data) {
currentTopics = data.topics;
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-4 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>
</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.fact ? `<p class="text-sm text-gray-400 mt-1">${escapeHtml(topic.fact.substring(0, 120))}${topic.fact.length > 120 ? '...' : ''}</p>` : ''}
</div>
</label>
`).join('');
document.querySelectorAll('input[name="topic"]').forEach(radio => {
radio.addEventListener('change', () => {
const index = parseInt(radio.dataset.topicIndex, 10);
selectedTopic = currentTopics[index];
document.getElementById('customTopicTitle').value = '';
document.getElementById('customTopicFact').value = '';
document.getElementById('customTopicSource').value = '';
});
});
}
['customTopicTitle', 'customTopicFact', 'customTopicSource'].forEach(id => {
document.getElementById(id).addEventListener('input', () => {
selectedTopic = null;
document.querySelectorAll('input[name="topic"]').forEach(radio => radio.checked = false);
});
});
// ==================== SPEECH TO TEXT ====================
let mediaRecorder = null;
let audioChunks = [];
function initSpeechRecognition() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
speechSupport.textContent = 'Mikrofon-Zugriff wird von diesem Browser nicht unterstützt.';
speechBtn.classList.add('opacity-50', 'cursor-not-allowed');
speechBtn.disabled = true;
return;
}
speechSupport.textContent = 'Klicke zum Aufnehmen, nochmal klicken zum Stoppen.';
speechBtn.onclick = async () => {
if (isRecording) {
stopRecording();
} else {
startRecording();
}
};
}
async function startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
audioChunks = [];
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
mediaRecorder = new MediaRecorder(stream, { mimeType });
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) audioChunks.push(event.data);
};
mediaRecorder.onstop = async () => {
stream.getTracks().forEach(track => track.stop());
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
speechStatus.textContent = 'Transkribiere...';
try {
const text = await transcribeAudio(audioBlob);
if (text && text.trim()) {
if (userThoughtsInput.value && !userThoughtsInput.value.endsWith(' ')) {
userThoughtsInput.value += ' ';
}
userThoughtsInput.value += text.trim();
}
speechStatus.classList.add('hidden');
} catch (error) {
speechStatus.textContent = `Fehler: ${error.message}`;
setTimeout(() => speechStatus.classList.add('hidden'), 3000);
}
};
mediaRecorder.start();
isRecording = true;
micIcon.classList.add('hidden');
micIconRecording.classList.remove('hidden');
speechStatus.innerHTML = '<span class="inline-block w-2 h-2 bg-red-500 rounded-full animate-pulse mr-2"></span>Aufnahme läuft...';
speechStatus.classList.remove('hidden');
speechBtn.classList.add('bg-red-500/20');
} catch (error) {
console.error('Failed to start recording:', error);
speechStatus.textContent = 'Mikrofon-Zugriff verweigert.';
speechStatus.classList.remove('hidden');
setTimeout(() => speechStatus.classList.add('hidden'), 3000);
}
}
function stopRecording() {
if (mediaRecorder && mediaRecorder.state === 'recording') mediaRecorder.stop();
isRecording = false;
micIcon.classList.remove('hidden');
micIconRecording.classList.add('hidden');
speechBtn.classList.remove('bg-red-500/20');
}
async function transcribeAudio(audioBlob) {
const formData = new FormData();
formData.append('audio', audioBlob, 'recording.webm');
const response = await fetch('/api/transcribe', { method: 'POST', body: formData });
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Transkription fehlgeschlagen');
}
const data = await response.json();
return data.text;
}
// ==================== HOOK GENERATION ====================
async function loadHooks() {
hookLoading.classList.remove('hidden');
hookOptions.classList.add('hidden');
hookError.classList.add('hidden');
generatedHooks = [];
selectedHook = null;
const formData = new FormData();
formData.append('topic_json', JSON.stringify(selectedTopic));
formData.append('user_thoughts', userThoughtsInput.value.trim());
formData.append('user_id', USER_ID);
if (selectedPostTypeIdInput.value) {
formData.append('post_type_id', selectedPostTypeIdInput.value);
}
try {
const response = await fetch('/api/hooks', { method: 'POST', body: formData });
if (!response.ok) throw new Error('Hook-Generierung fehlgeschlagen');
const data = await response.json();
generatedHooks = data.hooks || [];
if (generatedHooks.length === 0) throw new Error('Keine Hooks generiert');
renderHookOptions();
hookLoading.classList.add('hidden');
hookOptions.classList.remove('hidden');
} catch (error) {
console.error('Hook generation failed:', error);
hookLoading.classList.add('hidden');
hookError.textContent = `Fehler: ${error.message}. Du kannst trotzdem einen eigenen Hook eingeben.`;
hookError.classList.remove('hidden');
}
}
function renderHookOptions() {
hookOptions.innerHTML = generatedHooks.map((hook, i) => `
<label class="flex items-start gap-3 p-4 bg-brand-bg/50 rounded-lg cursor-pointer hover:bg-brand-bg transition-colors border-2 ${selectedHook === hook.hook ? 'border-brand-highlight bg-brand-highlight/10' : 'border-transparent hover:border-brand-highlight/30'}">
<input type="radio" name="hook" value="${i}" class="mt-1 text-brand-highlight" ${selectedHook === hook.hook ? 'checked' : ''}>
<div class="flex-1">
<span class="inline-block px-2 py-0.5 text-xs font-medium bg-brand-bg-light text-gray-300 rounded mb-2">${escapeHtml(hook.style)}</span>
<p class="text-white">${escapeHtml(hook.hook)}</p>
</div>
</label>
`).join('');
document.querySelectorAll('input[name="hook"]').forEach(radio => {
radio.addEventListener('change', () => {
const index = parseInt(radio.value, 10);
selectedHook = generatedHooks[index].hook;
customHook.value = '';
renderHookOptions();
});
});
}
customHook.addEventListener('input', () => {
if (customHook.value.trim()) {
selectedHook = null;
document.querySelectorAll('input[name="hook"]').forEach(radio => radio.checked = false);
}
});
// ==================== POST CREATION ====================
async function startPostCreation() {
const finalHook = customHook.value.trim() || selectedHook || '';
wizardContainer.classList.add('hidden');
generationContainer.classList.remove('hidden');
topicTitleEl.textContent = selectedTopic?.title || 'Benutzerdefiniertes Topic';
generationProgress.classList.remove('hidden');
liveVersions.classList.add('hidden');
finalResult.classList.add('hidden');
currentVersionIndex = 0;
const formData = new FormData();
formData.append('topic_json', JSON.stringify(selectedTopic));
formData.append('user_thoughts', userThoughtsInput.value.trim());
formData.append('selected_hook', finalHook);
formData.append('user_id', USER_ID);
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;
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} von ${status.max_iterations}`;
}
if (status.versions && status.versions.length > 0) {
window.lastProgressData = status;
if (status.versions.length > currentVersionIndex + 1) {
currentVersionIndex = status.versions.length - 1;
}
renderVersions(status.versions, status.feedback_list || []);
}
if (status.status === 'completed') {
clearInterval(pollInterval);
showFinalResult(status.result);
} else if (status.status === 'error') {
clearInterval(pollInterval);
showError(status.message);
}
}, 1000);
} catch (error) {
showError(error.message);
}
}
function showFinalResult(result) {
generationProgress.classList.add('hidden');
liveVersions.classList.add('hidden');
finalResult.classList.remove('hidden');
resultHeader.innerHTML = `
<div class="flex items-center gap-3 flex-wrap">
<span class="px-3 py-1.5 rounded-lg text-sm font-medium ${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: <span class="text-white font-medium">${result.final_score}/100</span></span>
<span class="text-gray-400">Iterationen: <span class="text-white">${result.iterations}</span></span>
</div>
`;
postResult.innerHTML = `<pre class="whitespace-pre-wrap text-gray-200 font-sans text-lg leading-relaxed">${result.final_post}</pre>`;
// Store post ID for image upload and show image area
window.currentResultPostId = result.post_id;
const imageArea = document.getElementById('resultImageArea');
imageArea.classList.remove('hidden');
document.getElementById('resultImageUploadZone').classList.remove('hidden');
document.getElementById('resultImagePreview').classList.add('hidden');
document.getElementById('resultImageProgress').classList.add('hidden');
initResultImageUpload();
resultActions.innerHTML = `
<button onclick="copyPost()" class="px-6 py-3 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-white transition-colors 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="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>
<a href="/company/manage/post/${result.post_id}?employee_id=${EMPLOYEE_ID}" class="px-6 py-3 bg-brand-highlight hover:bg-brand-highlight/90 rounded-lg text-brand-bg-dark font-medium transition-colors 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="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>
Post öffnen
</a>
<button onclick="resetWizard()" class="px-6 py-3 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-white transition-colors 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 4v16m8-8H4"/></svg>
Neuen Post erstellen
</button>
`;
}
function showError(message) {
generationProgress.classList.add('hidden');
finalResult.classList.remove('hidden');
resultHeader.innerHTML = `<span class="text-red-400 font-medium">Fehler bei der Generierung</span>`;
postResult.innerHTML = `<p class="text-red-400">${message}</p>`;
resultActions.innerHTML = `
<button onclick="resetWizard()" class="px-6 py-3 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-white transition-colors">
Zurück zum Wizard
</button>
`;
}
function resetWizard() {
currentStep = 1;
selectedTopic = null;
selectedHook = null;
generatedHooks = [];
currentVersionIndex = 0;
document.getElementById('customTopicTitle').value = '';
document.getElementById('customTopicFact').value = '';
document.getElementById('customTopicSource').value = '';
userThoughtsInput.value = '';
customHook.value = '';
document.querySelectorAll('input[name="topic"]').forEach(radio => radio.checked = false);
document.querySelectorAll('input[name="hook"]').forEach(radio => radio.checked = false);
generationContainer.classList.add('hidden');
wizardContainer.classList.remove('hidden');
goToStep(1);
}
// ==================== VERSION DISPLAY ====================
function renderVersions(versions, feedbackList) {
if (!versions || versions.length === 0) {
liveVersions.classList.add('hidden');
return;
}
liveVersions.classList.remove('hidden');
versionTabs.innerHTML = versions.map((_, i) => `
<button onclick="showVersion(${i})" id="versionTab${i}"
class="px-4 py-2 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 opacity-75">(${feedbackList[i].overall_score || '?'})</span>` : ''}
</button>
`).join('');
const currentVersion = versions[currentVersionIndex];
const currentFeedback = feedbackList[currentVersionIndex];
versionsContainer.innerHTML = `
<div class="grid grid-cols-1 ${currentFeedback ? 'lg:grid-cols-2' : ''} gap-6">
<div class="bg-brand-bg/50 rounded-lg p-5">
<div class="flex items-center justify-between mb-3">
<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-80 overflow-y-auto">${currentVersion}</pre>
</div>
${currentFeedback ? `
<div class="bg-brand-bg/30 rounded-lg p-5 border border-brand-bg-light">
<span class="text-sm font-medium text-gray-300 block mb-3">Kritik</span>
<p class="text-sm text-gray-400 mb-4">${currentFeedback.feedback || 'Keine Kritik'}</p>
${currentFeedback.improvements && currentFeedback.improvements.length > 0 ? `
<div>
<span class="text-xs font-medium text-gray-400">Verbesserungen:</span>
<ul class="mt-2 space-y-1">
${currentFeedback.improvements.map(imp => `
<li class="text-xs text-gray-500 flex items-start gap-2">
<span class="text-yellow-500 mt-0.5">•</span> ${imp}
</li>
`).join('')}
</ul>
</div>
` : ''}
</div>
` : ''}
</div>
`;
}
function showVersion(index) {
currentVersionIndex = index;
const cachedData = window.lastProgressData;
if (cachedData) renderVersions(cachedData.versions, cachedData.feedback_list);
}
function copyPost() {
const postText = document.querySelector('#postResult pre').textContent;
navigator.clipboard.writeText(postText).then(() => {
const btn = document.querySelector('[onclick="copyPost()"]');
const originalText = btn.innerHTML;
btn.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="M5 13l4 4L19 7"/></svg> Kopiert!';
setTimeout(() => { btn.innerHTML = originalText; }, 2000);
});
}
// ==================== HELPERS ====================
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `fixed bottom-4 right-4 px-4 py-3 rounded-lg shadow-lg z-50 transition-all transform translate-y-0 opacity-100 ${
type === 'success' ? 'bg-green-600 text-white' :
type === 'error' ? 'bg-red-600 text-white' :
'bg-brand-bg-light text-white'
}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-y-2');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// ==================== RESULT IMAGE UPLOAD ====================
let resultImageInitialized = false;
async function handleResultImageUpload(file) {
if (!file || !window.currentResultPostId) return;
const uploadZone = document.getElementById('resultImageUploadZone');
const progressEl = document.getElementById('resultImageProgress');
const progressBar = document.getElementById('resultImageProgressBar');
const previewEl = document.getElementById('resultImagePreview');
uploadZone.classList.add('hidden');
progressEl.classList.remove('hidden');
progressBar.style.width = '30%';
try {
const formData = new FormData();
formData.append('image', file);
progressBar.style.width = '60%';
const response = await fetch(`/api/posts/${window.currentResultPostId}/image`, {
method: 'POST',
body: formData
});
progressBar.style.width = '90%';
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Upload fehlgeschlagen');
}
const result = await response.json();
progressBar.style.width = '100%';
document.getElementById('resultImageImg').src = result.image_url;
setTimeout(() => {
progressEl.classList.add('hidden');
previewEl.classList.remove('hidden');
}, 300);
showToast('Bild erfolgreich hochgeladen!', 'success');
} catch (error) {
console.error('Image upload error:', error);
showToast('Fehler: ' + error.message, 'error');
progressEl.classList.add('hidden');
uploadZone.classList.remove('hidden');
}
}
async function removeResultImage() {
if (!window.currentResultPostId) return;
const btn = document.getElementById('removeResultImageBtn');
const originalHTML = btn.innerHTML;
btn.innerHTML = 'Wird entfernt...';
btn.disabled = true;
try {
const response = await fetch(`/api/posts/${window.currentResultPostId}/image`, { method: 'DELETE' });
if (!response.ok) throw new Error('Löschen fehlgeschlagen');
document.getElementById('resultImagePreview').classList.add('hidden');
document.getElementById('resultImageUploadZone').classList.remove('hidden');
showToast('Bild entfernt.', 'success');
} catch (error) {
console.error('Image delete error:', error);
showToast('Fehler: ' + error.message, 'error');
} finally {
btn.innerHTML = originalHTML;
btn.disabled = false;
}
}
function initResultImageUpload() {
if (resultImageInitialized) return;
resultImageInitialized = true;
const uploadZone = document.getElementById('resultImageUploadZone');
const fileInput = document.getElementById('resultImageInput');
const replaceInput = document.getElementById('resultImageReplaceInput');
if (!uploadZone) return;
uploadZone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => { if (e.target.files[0]) handleResultImageUpload(e.target.files[0]); });
if (replaceInput) {
replaceInput.addEventListener('change', (e) => { if (e.target.files[0]) handleResultImageUpload(e.target.files[0]); });
}
uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.classList.add('border-brand-highlight', 'bg-brand-highlight/5'); });
uploadZone.addEventListener('dragleave', () => { uploadZone.classList.remove('border-brand-highlight', 'bg-brand-highlight/5'); });
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
uploadZone.classList.remove('border-brand-highlight', 'bg-brand-highlight/5');
if (e.dataTransfer.files[0]) handleResultImageUpload(e.dataTransfer.files[0]);
});
}
// ==================== INIT ====================
document.addEventListener('DOMContentLoaded', () => {
loadData();
initSpeechRecognition();
});
</script>
{% endblock %}

View File

@@ -0,0 +1,393 @@
{% extends "company_base.html" %}
{% block title %}{{ post.topic_title }} - {{ employee_name }} - {{ session.company_name }}{% endblock %}
{% block head %}
<style>
.section-card {
background: rgba(61, 72, 72, 0.3);
border: 1px solid rgba(61, 72, 72, 0.6);
}
.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;
}
.linkedin-user-info { flex: 1; min-width: 0; }
.linkedin-name { font-weight: 600; font-size: 14px; color: rgba(0, 0, 0, 0.9); }
.linkedin-headline { font-size: 12px; color: rgba(0, 0, 0, 0.6); margin-top: 2px; }
.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; }
.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); }
.linkedin-action-btn svg { width: 20px; height: 20px; }
.linkedin-post-image { width: 100%; max-height: 400px; object-fit: cover; }
.image-upload-zone { border: 2px dashed rgba(61, 72, 72, 0.8); border-radius: 0.75rem; padding: 1.5rem; text-align: center; cursor: pointer; transition: all 0.2s; }
.image-upload-zone:hover, .image-upload-zone.dragover { border-color: #ffc700; background: rgba(255, 199, 0, 0.05); }
.image-upload-zone input[type="file"] { display: none; }
.image-preview-container { position: relative; border-radius: 0.75rem; overflow: hidden; }
.image-preview-container img { width: 100%; border-radius: 0.75rem; }
.image-upload-progress { height: 4px; background: rgba(61, 72, 72, 0.8); border-radius: 2px; overflow: hidden; margin-top: 0.5rem; }
.image-upload-progress-bar { height: 100%; background: #ffc700; border-radius: 2px; transition: width 0.3s; }
.loading-spinner { border: 2px solid rgba(255, 199, 0, 0.2); border-top-color: #ffc700; border-radius: 50%; width: 20px; height: 20px; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
{% endblock %}
{% block content %}
<!-- Breadcrumb -->
<div class="mb-6">
<nav class="flex items-center gap-2 text-sm">
<a href="/company/manage" class="text-gray-400 hover:text-white transition-colors">Inhalte verwalten</a>
<svg class="w-4 h-4 text-gray-600" 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>
<a href="/company/manage?employee_id={{ employee_id }}" class="text-gray-400 hover:text-white transition-colors">{{ employee_name }}</a>
<svg class="w-4 h-4 text-gray-600" 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>
<a href="/company/manage/posts?employee_id={{ employee_id }}" class="text-gray-400 hover:text-white transition-colors">Posts</a>
<svg class="w-4 h-4 text-gray-600" 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>
<span class="text-white truncate max-w-xs">{{ post.topic_title or 'Post' }}</span>
</nav>
</div>
<!-- Header -->
<div class="mb-6">
<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">{{ 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>
<span class="text-gray-600">|</span>
<span>{{ employee_name }}</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' if post.status == 'draft' else 'bg-blue-600/30 text-blue-300 border border-blue-600/50' }}">
{% if post.status == 'draft' %}Vorschlag{% elif post.status == 'approved' %}Bearbeitet{% elif post.status == 'published' %}Veröffentlicht{% else %}{{ post.status | capitalize }}{% endif %}
</span>
</div>
</div>
</div>
<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>
LinkedIn Post
</h2>
<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>
<!-- LinkedIn Preview -->
<div class="linkedin-preview shadow-lg">
<div class="linkedin-header">
<div class="linkedin-avatar">{{ employee_name[:2] | upper if employee_name else 'UN' }}</div>
<div class="linkedin-user-info">
<div class="linkedin-name">{{ employee_name }}</div>
<div class="linkedin-headline">{{ session.company_name }}</div>
<div class="linkedin-timestamp">
<span>{{ post.created_at.strftime('%d.%m.%Y') if post.created_at else '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>
<div class="linkedin-content">{{ post.post_content }}</div>
{% if post.image_url %}
<img id="linkedinPostImage" src="{{ post.image_url }}" alt="Post image" class="linkedin-post-image">
{% else %}
<img id="linkedinPostImage" src="" alt="Post image" class="linkedin-post-image" style="display: none;">
{% endif %}
<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>
<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>
</div>
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- Actions -->
<div class="section-card rounded-xl p-6">
<h3 class="font-semibold text-white mb-4">Aktionen</h3>
<div class="space-y-3">
<button onclick="updateStatus('approved')" class="w-full px-4 py-3 bg-blue-600/20 hover:bg-blue-600/30 text-blue-300 rounded-lg transition-colors flex items-center justify-center gap-2 {% if post.status == 'approved' %}ring-2 ring-blue-500{% 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>
Als bearbeitet markieren
</button>
<button onclick="updateStatus('published')" class="w-full px-4 py-3 bg-green-600/20 hover:bg-green-600/30 text-green-300 rounded-lg transition-colors flex items-center justify-center gap-2 {% if post.status == 'published' %}ring-2 ring-green-500{% 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 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
Als veröffentlicht markieren
</button>
<button onclick="updateStatus('draft')" class="w-full px-4 py-3 bg-yellow-600/20 hover:bg-yellow-600/30 text-yellow-300 rounded-lg transition-colors flex items-center justify-center gap-2 {% if post.status == 'draft' %}ring-2 ring-yellow-500{% 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.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
Zurück zu Vorschlag
</button>
</div>
</div>
<!-- Image Upload -->
<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="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
Bild
</h3>
<div id="imageUploadZone" class="image-upload-zone {% if post.image_url %}hidden{% endif %}">
<input type="file" id="imageFileInput" accept="image/jpeg,image/png,image/gif,image/webp">
<svg class="w-8 h-8 mx-auto mb-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/></svg>
<p class="text-sm text-gray-400">Bild hierher ziehen oder <span class="text-brand-highlight cursor-pointer">durchsuchen</span></p>
<p class="text-xs text-gray-500 mt-1">JPEG, PNG, GIF, WebP - max. 5 MB</p>
</div>
<div id="imageUploadProgress" class="hidden">
<div class="image-upload-progress">
<div id="imageProgressBar" class="image-upload-progress-bar" style="width: 0%"></div>
</div>
<p class="text-xs text-gray-400 mt-1 text-center">Wird hochgeladen...</p>
</div>
<div id="imagePreviewSection" class="{% if not post.image_url %}hidden{% endif %}">
<div class="image-preview-container mb-3">
<img id="sidebarImagePreview" src="{{ post.image_url or '' }}" alt="Post-Bild">
</div>
<div class="flex gap-2">
<button onclick="document.getElementById('imageReplaceInput').click()" class="flex-1 px-3 py-2 bg-brand-bg hover:bg-brand-bg-light text-gray-300 rounded-lg transition-colors text-sm 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="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>
Ersetzen
</button>
<button onclick="removeImage()" id="removeImageBtn" class="flex-1 px-3 py-2 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg transition-colors text-sm 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
Entfernen
</button>
</div>
<input type="file" id="imageReplaceInput" accept="image/jpeg,image/png,image/gif,image/webp" class="hidden">
</div>
</div>
<!-- Post Info -->
<div class="section-card rounded-xl p-6">
<h3 class="font-semibold text-white mb-4">Details</h3>
<div class="space-y-3 text-sm">
<div class="flex justify-between">
<span class="text-gray-400">Erstellt</span>
<span class="text-white">{{ post.created_at.strftime('%d.%m.%Y') if post.created_at else 'N/A' }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">Iterationen</span>
<span class="text-white">{{ post.iterations }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">Zeichen</span>
<span class="text-white">{{ post.post_content | length }}</span>
</div>
{% if post.topic_title %}
<div class="pt-3 border-t border-brand-bg-light">
<span class="text-gray-400 block mb-1">Topic</span>
<span class="text-white">{{ post.topic_title }}</span>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const POST_ID = '{{ post.id }}';
const EMPLOYEE_ID = '{{ employee_id }}';
function copyToClipboard() {
const content = document.querySelector('.linkedin-content').textContent;
navigator.clipboard.writeText(content).then(() => {
const btn = document.querySelector('[onclick="copyToClipboard()"]');
const original = 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 = original; }, 2000);
});
}
async function updateStatus(newStatus) {
try {
const formData = new FormData();
formData.append('status', newStatus);
const response = await fetch(`/api/posts/${POST_ID}/status`, {
method: 'PATCH',
body: formData
});
if (response.ok) {
location.reload();
} else {
alert('Fehler beim Aktualisieren des Status');
}
} catch (error) {
console.error('Error:', error);
alert('Fehler beim Aktualisieren des Status');
}
}
// ==================== IMAGE UPLOAD ====================
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `fixed bottom-4 right-4 px-4 py-3 rounded-lg shadow-lg z-50 transition-all transform translate-y-0 opacity-100 ${
type === 'success' ? 'bg-green-600 text-white' :
type === 'error' ? 'bg-red-600 text-white' :
'bg-brand-bg-light text-white'
}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-y-2');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
async function handleImageUpload(file) {
if (!file) return;
const uploadZone = document.getElementById('imageUploadZone');
const progressEl = document.getElementById('imageUploadProgress');
const progressBar = document.getElementById('imageProgressBar');
const previewSection = document.getElementById('imagePreviewSection');
uploadZone.classList.add('hidden');
progressEl.classList.remove('hidden');
progressBar.style.width = '30%';
try {
const formData = new FormData();
formData.append('image', file);
progressBar.style.width = '60%';
const response = await fetch(`/api/posts/${POST_ID}/image`, {
method: 'POST',
body: formData
});
progressBar.style.width = '90%';
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Upload fehlgeschlagen');
}
const result = await response.json();
progressBar.style.width = '100%';
document.getElementById('linkedinPostImage').src = result.image_url;
document.getElementById('linkedinPostImage').style.display = 'block';
document.getElementById('sidebarImagePreview').src = result.image_url;
setTimeout(() => {
progressEl.classList.add('hidden');
previewSection.classList.remove('hidden');
}, 300);
showToast('Bild erfolgreich hochgeladen!', 'success');
} catch (error) {
console.error('Image upload error:', error);
showToast('Fehler: ' + error.message, 'error');
progressEl.classList.add('hidden');
uploadZone.classList.remove('hidden');
}
}
async function removeImage() {
const btn = document.getElementById('removeImageBtn');
const originalHTML = btn.innerHTML;
btn.innerHTML = '<div class="loading-spinner" style="width:16px;height:16px;"></div>';
btn.disabled = true;
try {
const response = await fetch(`/api/posts/${POST_ID}/image`, { method: 'DELETE' });
if (!response.ok) throw new Error('Löschen fehlgeschlagen');
document.getElementById('linkedinPostImage').style.display = 'none';
document.getElementById('imagePreviewSection').classList.add('hidden');
document.getElementById('imageUploadZone').classList.remove('hidden');
showToast('Bild entfernt.', 'success');
} catch (error) {
console.error('Image delete error:', error);
showToast('Fehler: ' + error.message, 'error');
} finally {
btn.innerHTML = originalHTML;
btn.disabled = false;
}
}
document.addEventListener('DOMContentLoaded', () => {
const uploadZone = document.getElementById('imageUploadZone');
const fileInput = document.getElementById('imageFileInput');
const replaceInput = document.getElementById('imageReplaceInput');
if (uploadZone) {
uploadZone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => { if (e.target.files[0]) handleImageUpload(e.target.files[0]); });
if (replaceInput) {
replaceInput.addEventListener('change', (e) => { if (e.target.files[0]) handleImageUpload(e.target.files[0]); });
}
uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.classList.add('dragover'); });
uploadZone.addEventListener('dragleave', () => { uploadZone.classList.remove('dragover'); });
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
uploadZone.classList.remove('dragover');
if (e.dataTransfer.files[0]) handleImageUpload(e.dataTransfer.files[0]);
});
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,286 @@
{% extends "company_base.html" %}
{% block title %}Posts - {{ employee_name }} - {{ session.company_name }}{% endblock %}
{% macro render_post_card(post) %}
<div class="post-card"
draggable="true"
data-post-id="{{ post.id }}"
ondragstart="handleDragStart(event)"
ondragend="handleDragEnd(event)"
onclick="window.location.href='/company/manage/post/{{ post.id }}?employee_id={{ employee_id }}'">
<div class="flex items-start justify-between gap-2 mb-2">
<h4 class="post-card-title">{{ post.topic_title or 'Untitled' }}</h4>
{% if post.critic_feedback and post.critic_feedback | length > 0 %}
{% set score = post.critic_feedback[-1].overall_score %}
<span class="score-badge flex-shrink-0 {{ 'score-high' if score >= 85 else 'score-medium' if score >= 70 else 'score-low' }}">
{{ score }}
</span>
{% endif %}
</div>
<div class="post-card-meta">
<span class="flex items-center gap-1">
<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">
<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 }}x
</span>
</div>
{% if post.post_content %}
<p class="post-card-preview">{{ post.post_content[:150] }}{% if post.post_content | length > 150 %}...{% endif %}</p>
{% endif %}
</div>
{% endmacro %}
{% block head %}
<style>
.kanban-board {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
min-height: calc(100vh - 300px);
}
@media (max-width: 1024px) {
.kanban-board { grid-template-columns: 1fr; }
}
.kanban-column {
background: rgba(45, 56, 56, 0.3);
border: 1px solid rgba(61, 72, 72, 0.6);
border-radius: 1rem;
display: flex;
flex-direction: column;
min-height: 400px;
}
.kanban-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(61, 72, 72, 0.6);
display: flex;
align-items: center;
justify-content: space-between;
}
.kanban-header h3 { font-weight: 600; display: flex; align-items: center; gap: 0.5rem; }
.kanban-count { background: rgba(61, 72, 72, 0.8); padding: 0.125rem 0.5rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 500; }
.kanban-body { flex: 1; padding: 1rem; overflow-y: auto; min-height: 100px; }
.kanban-body.drag-over { background: rgba(255, 199, 0, 0.05); border: 2px dashed rgba(255, 199, 0, 0.3); border-radius: 0.5rem; margin: 0.5rem; }
.post-card {
background: linear-gradient(135deg, rgba(61, 72, 72, 0.5) 0%, rgba(45, 56, 56, 0.6) 100%);
border: 1px solid rgba(61, 72, 72, 0.8);
border-radius: 0.75rem;
padding: 1rem;
margin-bottom: 0.75rem;
cursor: grab;
transition: all 0.2s ease;
}
.post-card:hover { border-color: rgba(255, 199, 0, 0.4); transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); }
.post-card.dragging { opacity: 0.5; cursor: grabbing; }
.post-card-title { font-weight: 500; color: white; margin-bottom: 0.5rem; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.post-card-meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.75rem; color: #9ca3af; }
.post-card-preview { font-size: 0.8rem; color: #9ca3af; margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid rgba(61, 72, 72, 0.6); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.4; }
.score-badge { display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.125rem 0.5rem; border-radius: 9999px; font-size: 0.7rem; font-weight: 600; }
.score-high { background: rgba(34, 197, 94, 0.2); color: #86efac; }
.score-medium { background: rgba(234, 179, 8, 0.2); color: #fde047; }
.score-low { background: rgba(239, 68, 68, 0.2); color: #fca5a5; }
.column-draft .kanban-header { border-left: 3px solid #f59e0b; }
.column-approved .kanban-header { border-left: 3px solid #3b82f6; }
.column-ready .kanban-header { border-left: 3px solid #22c55e; }
.empty-column { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem; color: #6b7280; text-align: center; }
.empty-column svg { width: 3rem; height: 3rem; margin-bottom: 0.75rem; opacity: 0.5; }
</style>
{% endblock %}
{% block content %}
<!-- Breadcrumb -->
<div class="mb-6">
<nav class="flex items-center gap-2 text-sm">
<a href="/company/manage" class="text-gray-400 hover:text-white transition-colors">Inhalte verwalten</a>
<svg class="w-4 h-4 text-gray-600" 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>
<a href="/company/manage?employee_id={{ employee_id }}" class="text-gray-400 hover:text-white transition-colors">{{ employee_name }}</a>
<svg class="w-4 h-4 text-gray-600" 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>
<span class="text-white">Posts</span>
</nav>
</div>
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-white mb-1">Posts von {{ employee_name }}</h1>
<p class="text-gray-400 text-sm">Ziehe Posts zwischen den Spalten um den Status zu ändern</p>
</div>
<a href="/company/manage/create?employee_id={{ employee_id }}" 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>
<div class="kanban-board">
<!-- Column: Vorschlag (draft) -->
<div class="kanban-column column-draft">
<div class="kanban-header">
<h3 class="text-yellow-400">
<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.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
Vorschlag
</h3>
<span class="kanban-count" id="count-draft">{{ posts | selectattr('status', 'equalto', 'draft') | list | length }}</span>
</div>
<div class="kanban-body" data-status="draft" ondragover="handleDragOver(event)" ondrop="handleDrop(event)" ondragleave="handleDragLeave(event)">
{% for post in posts if post.status == 'draft' %}
{{ render_post_card(post) }}
{% else %}
<div class="empty-column" id="empty-draft">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
<p>Keine Vorschläge</p>
</div>
{% endfor %}
</div>
</div>
<!-- Column: Bearbeitet (approved) - waiting for customer approval -->
<div class="kanban-column column-approved">
<div class="kanban-header">
<h3 class="text-blue-400">
<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>
Bearbeitet
</h3>
<span class="kanban-count" id="count-approved">{{ posts | selectattr('status', 'equalto', 'approved') | list | length }}</span>
</div>
<div class="kanban-body" data-status="approved" ondragover="handleDragOver(event)" ondrop="handleDrop(event)" ondragleave="handleDragLeave(event)">
{% for post in posts if post.status == 'approved' %}
{{ render_post_card(post) }}
{% else %}
<div class="empty-column" id="empty-approved">
<svg 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>
<p>Keine bearbeiteten Posts</p>
</div>
{% endfor %}
</div>
</div>
<!-- Column: Freigegeben (ready) - approved by customer, ready for calendar scheduling -->
<div class="kanban-column column-ready">
<div class="kanban-header">
<h3 class="text-green-400">
<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 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
Freigegeben
</h3>
<span class="kanban-count" id="count-ready">{{ posts | selectattr('status', 'equalto', 'ready') | list | length }}</span>
</div>
<div class="kanban-body" data-status="ready" ondragover="handleDragOver(event)" ondrop="handleDrop(event)" ondragleave="handleDragLeave(event)">
{% for post in posts if post.status == 'ready' %}
{{ render_post_card(post) }}
{% else %}
<div class="empty-column" id="empty-ready">
<svg 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>
<p>Keine freigegebenen Posts</p>
</div>
{% endfor %}
</div>
</div>
</div>
{% if not posts %}
<div class="card-bg rounded-xl border p-12 text-center mt-6">
<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 den ersten LinkedIn Post für {{ employee_name }}.</p>
<a href="/company/manage/create?employee_id={{ employee_id }}" 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 %}
{% block scripts %}
<script>
let draggedElement = null;
let sourceStatus = null;
function handleDragStart(e) {
draggedElement = e.target;
sourceStatus = e.target.closest('.kanban-body').dataset.status;
e.target.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', e.target.dataset.postId);
}
function handleDragEnd(e) {
e.target.classList.remove('dragging');
document.querySelectorAll('.kanban-body').forEach(body => body.classList.remove('drag-over'));
}
function handleDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const kanbanBody = e.target.closest('.kanban-body');
if (kanbanBody) kanbanBody.classList.add('drag-over');
}
function handleDragLeave(e) {
const kanbanBody = e.target.closest('.kanban-body');
if (kanbanBody && !kanbanBody.contains(e.relatedTarget)) kanbanBody.classList.remove('drag-over');
}
async function handleDrop(e) {
e.preventDefault();
const kanbanBody = e.target.closest('.kanban-body');
if (!kanbanBody || !draggedElement) return;
kanbanBody.classList.remove('drag-over');
const newStatus = kanbanBody.dataset.status;
const postId = draggedElement.dataset.postId;
if (sourceStatus === newStatus) return;
const emptyPlaceholder = kanbanBody.querySelector('.empty-column');
if (emptyPlaceholder) emptyPlaceholder.remove();
kanbanBody.appendChild(draggedElement);
const sourceBody = document.querySelector(`.kanban-body[data-status="${sourceStatus}"]`);
if (sourceBody && sourceBody.querySelectorAll('.post-card').length === 0) {
addEmptyPlaceholder(sourceBody, sourceStatus);
}
updateCounts();
try {
const formData = new FormData();
formData.append('status', newStatus);
const response = await fetch(`/api/posts/${postId}/status`, {
method: 'PATCH',
body: formData
});
if (!response.ok) throw new Error('Failed to update status');
} catch (error) {
console.error('Error updating status:', error);
location.reload();
}
}
function addEmptyPlaceholder(container, status) {
const icons = {
'draft': '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>',
'approved': '<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"/>',
'ready': '<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"/>'
};
const labels = { 'draft': 'Keine Vorschläge', 'approved': 'Keine bearbeiteten Posts', 'ready': 'Keine freigegebenen Posts' };
const placeholder = document.createElement('div');
placeholder.className = 'empty-column';
placeholder.id = `empty-${status}`;
placeholder.innerHTML = `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">${icons[status]}</svg><p>${labels[status]}</p>`;
container.appendChild(placeholder);
}
function updateCounts() {
['draft', 'approved', 'ready'].forEach(status => {
const count = document.querySelectorAll(`.kanban-body[data-status="${status}"] .post-card`).length;
document.getElementById(`count-${status}`).textContent = count;
});
}
</script>
{% endblock %}

View File

@@ -1,31 +1,47 @@
{% extends "base.html" %}
{% block title %}Research Topics - LinkedIn Posts{% endblock %}
{% extends "company_base.html" %}
{% block title %}Research - {{ employee_name }} - {{ session.company_name }}{% endblock %}
{% block content %}
<!-- Breadcrumb -->
<div class="mb-6">
<nav class="flex items-center gap-2 text-sm">
<a href="/company/manage" class="text-gray-400 hover:text-white transition-colors">Inhalte verwalten</a>
<svg class="w-4 h-4 text-gray-600" 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>
<a href="/company/manage?employee_id={{ employee_id }}" class="text-gray-400 hover:text-white transition-colors">{{ employee_name }}</a>
<svg class="w-4 h-4 text-gray-600" 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>
<span class="text-white">Research</span>
</nav>
</div>
<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>
<p class="text-gray-400">Recherchiere neue Content-Themen für {{ employee_name }}</p>
</div>
<!-- Limit Warning -->
{% if limit_reached %}
<div class="bg-red-900/50 border border-red-500 text-red-200 px-6 py-4 rounded-xl mb-8">
<div class="flex items-center gap-3">
<svg class="w-6 h-6 flex-shrink-0" 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>
<strong class="font-semibold">Limit erreicht</strong>
<p class="text-sm mt-1">{{ limit_message }}</p>
</div>
</div>
</div>
{% endif %}
<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">
<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">
<!-- Post type cards will be loaded here -->
<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="">
@@ -44,17 +60,12 @@
</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">
<button type="submit" id="submitBtn" {% if limit_reached %}disabled{% endif %}
class="w-full btn-primary font-medium py-3 rounded-lg transition-colors flex items-center justify-center gap-2 {% if limit_reached %}opacity-50 cursor-not-allowed{% 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 starten
{% if limit_reached %}Limit erreicht{% else %}Research starten{% endif %}
</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 -->
@@ -71,6 +82,9 @@
{% block scripts %}
<script>
const USER_ID = '{{ user_id }}';
const EMPLOYEE_ID = '{{ employee_id }}';
const form = document.getElementById('researchForm');
const submitBtn = document.getElementById('submitBtn');
const progressArea = document.getElementById('progressArea');
@@ -78,30 +92,19 @@ 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;
}
// Load post types on page load
async function loadPostTypes() {
try {
const response = await fetch(`/admin/api/customers/${customerId}/post-types`);
const response = await fetch(`/api/post-types?user_id=${USER_ID}`);
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"
@@ -118,18 +121,17 @@ customerSelect.addEventListener('change', async () => {
</button>
`).join('');
} else {
postTypeArea.classList.add('hidden');
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);
postTypeArea.classList.add('hidden');
postTypeCards.innerHTML = '<p class="text-red-400 text-sm col-span-2">Fehler beim Laden.</p>';
}
});
}
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';
@@ -142,21 +144,18 @@ function selectPostType(typeId) {
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);
formData.append('user_id', USER_ID);
if (selectedPostTypeId.value) {
formData.append('post_type_id', selectedPostTypeId.value);
}
try {
const response = await fetch('/admin/api/research', {
const response = await fetch('/api/research', {
method: 'POST',
body: formData
});
@@ -164,7 +163,7 @@ form.addEventListener('submit', async (e) => {
const taskId = data.task_id;
const pollInterval = setInterval(async () => {
const statusResponse = await fetch(`/admin/api/tasks/${taskId}`);
const statusResponse = await fetch(`/api/tasks/${taskId}`);
const status = await statusResponse.json();
progressBar.style.width = `${status.progress}%`;
@@ -177,7 +176,6 @@ form.addEventListener('submit', async (e) => {
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">
@@ -211,5 +209,8 @@ form.addEventListener('submit', async (e) => {
topicsList.innerHTML = `<p class="text-red-400">Fehler: ${error.message}</p>`;
}
});
// Load post types on page load
loadPostTypes();
</script>
{% endblock %}

View File

@@ -0,0 +1,260 @@
{% extends "company_base.html" %}
{% block title %}Strategie - {{ session.company_name }}{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto">
<h1 class="text-2xl font-bold text-white mb-2">Unternehmensstrategie</h1>
<p class="text-gray-400 mb-8">Diese Strategie wird bei der Erstellung aller LinkedIn-Posts deiner Mitarbeiter berücksichtigt.</p>
{% if success %}
<div class="bg-green-900/50 border border-green-500 text-green-200 px-4 py-3 rounded-lg mb-6">
Strategie erfolgreich gespeichert!
</div>
{% endif %}
{% if error %}
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
{{ error }}
</div>
{% endif %}
<form method="POST" action="/company/strategy" class="space-y-6">
<!-- Mission & Vision -->
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-white mb-4">Mission & Vision</h2>
<div class="space-y-4">
<div>
<label for="mission" class="block text-sm font-medium text-gray-300 mb-1">Mission</label>
<textarea id="mission" name="mission" rows="2"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Was ist der Zweck deines Unternehmens?">{{ strategy.mission or '' }}</textarea>
</div>
<div>
<label for="vision" class="block text-sm font-medium text-gray-300 mb-1">Vision</label>
<textarea id="vision" name="vision" rows="2"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Wo soll dein Unternehmen in Zukunft stehen?">{{ strategy.vision or '' }}</textarea>
</div>
</div>
</div>
<!-- Brand Voice -->
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-white mb-4">Brand Voice</h2>
<div class="space-y-4">
<div>
<label for="brand_voice" class="block text-sm font-medium text-gray-300 mb-1">Markenstimme</label>
<textarea id="brand_voice" name="brand_voice" rows="2"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Wie soll deine Marke klingen? (z.B. professionell, freundlich, innovativ)">{{ strategy.brand_voice or '' }}</textarea>
</div>
<div>
<label for="tone_guidelines" class="block text-sm font-medium text-gray-300 mb-1">Tonalität-Richtlinien</label>
<textarea id="tone_guidelines" name="tone_guidelines" rows="3"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Spezifische Anweisungen zur Tonalität (z.B. Duzen/Siezen, Fachsprache, etc.)">{{ strategy.tone_guidelines or '' }}</textarea>
</div>
</div>
</div>
<!-- Target Audience -->
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-white mb-4">Zielgruppe</h2>
<div>
<label for="target_audience" class="block text-sm font-medium text-gray-300 mb-1">Beschreibung der Zielgruppe</label>
<textarea id="target_audience" name="target_audience" rows="3"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Wer ist deine Zielgruppe auf LinkedIn?">{{ strategy.target_audience or '' }}</textarea>
</div>
</div>
<!-- Content Pillars -->
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-white mb-4">Content-Säulen</h2>
<p class="text-gray-400 text-sm mb-4">Die Hauptthemen, über die dein Unternehmen postet.</p>
<div id="content-pillars" class="space-y-2">
{% for pillar in strategy.content_pillars or [] %}
<div class="flex gap-2">
<input type="text" name="content_pillar" value="{{ pillar }}"
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white"
placeholder="z.B. Innovation, Nachhaltigkeit, Teamkultur">
<button type="button" onclick="this.parentElement.remove()"
class="px-3 py-2 text-red-400 hover:text-red-300">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{% endfor %}
{% if not strategy.content_pillars %}
<div class="flex gap-2">
<input type="text" name="content_pillar"
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white"
placeholder="z.B. Innovation, Nachhaltigkeit, Teamkultur">
<button type="button" onclick="this.parentElement.remove()"
class="px-3 py-2 text-red-400 hover:text-red-300">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{% endif %}
</div>
<button type="button" onclick="addPillar()"
class="mt-3 text-brand-highlight text-sm hover:underline flex items-center gap-1">
<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 4v16m8-8H4"/>
</svg>
Weitere Säule hinzufügen
</button>
</div>
<!-- Do's and Don'ts -->
<div class="grid md:grid-cols-2 gap-6">
<!-- Do's -->
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-green-400 mb-4">Do's</h2>
<div id="dos-list" class="space-y-2">
{% for do in strategy.dos or [] %}
<div class="flex gap-2">
<input type="text" name="do_item" value="{{ do }}"
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white text-sm"
placeholder="z.B. Erfolge feiern">
<button type="button" onclick="this.parentElement.remove()"
class="px-2 py-2 text-red-400 hover:text-red-300">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{% endfor %}
{% if not strategy.dos %}
<div class="flex gap-2">
<input type="text" name="do_item"
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white text-sm"
placeholder="z.B. Erfolge feiern">
<button type="button" onclick="this.parentElement.remove()"
class="px-2 py-2 text-red-400 hover:text-red-300">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{% endif %}
</div>
<button type="button" onclick="addDo()"
class="mt-3 text-green-400 text-sm hover:underline flex items-center gap-1">
<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 4v16m8-8H4"/>
</svg>
Hinzufügen
</button>
</div>
<!-- Don'ts -->
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-red-400 mb-4">Don'ts</h2>
<div id="donts-list" class="space-y-2">
{% for dont in strategy.donts or [] %}
<div class="flex gap-2">
<input type="text" name="dont_item" value="{{ dont }}"
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white text-sm"
placeholder="z.B. Konkurrenz kritisieren">
<button type="button" onclick="this.parentElement.remove()"
class="px-2 py-2 text-red-400 hover:text-red-300">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{% endfor %}
{% if not strategy.donts %}
<div class="flex gap-2">
<input type="text" name="dont_item"
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white text-sm"
placeholder="z.B. Konkurrenz kritisieren">
<button type="button" onclick="this.parentElement.remove()"
class="px-2 py-2 text-red-400 hover:text-red-300">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{% endif %}
</div>
<button type="button" onclick="addDont()"
class="mt-3 text-red-400 text-sm hover:underline flex items-center gap-1">
<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 4v16m8-8H4"/>
</svg>
Hinzufügen
</button>
</div>
</div>
<!-- Submit Button -->
<div class="flex justify-end">
<button type="submit" class="btn-primary font-medium py-3 px-8 rounded-lg transition-colors">
Strategie speichern
</button>
</div>
</form>
</div>
<script>
function addPillar() {
const container = document.getElementById('content-pillars');
const div = document.createElement('div');
div.className = 'flex gap-2';
div.innerHTML = `
<input type="text" name="content_pillar"
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white"
placeholder="z.B. Innovation, Nachhaltigkeit, Teamkultur">
<button type="button" onclick="this.parentElement.remove()"
class="px-3 py-2 text-red-400 hover:text-red-300">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
`;
container.appendChild(div);
}
function addDo() {
const container = document.getElementById('dos-list');
const div = document.createElement('div');
div.className = 'flex gap-2';
div.innerHTML = `
<input type="text" name="do_item"
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white text-sm"
placeholder="z.B. Erfolge feiern">
<button type="button" onclick="this.parentElement.remove()"
class="px-2 py-2 text-red-400 hover:text-red-300">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
`;
container.appendChild(div);
}
function addDont() {
const container = document.getElementById('donts-list');
const div = document.createElement('div');
div.className = 'flex gap-2';
div.innerHTML = `
<input type="text" name="dont_item"
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white text-sm"
placeholder="z.B. Konkurrenz kritisieren">
<button type="button" onclick="this.parentElement.remove()"
class="px-2 py-2 text-red-400 hover:text-red-300">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
`;
container.appendChild(div);
}
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,171 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #1a2424 0%, #2d3838 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
max-width: 500px;
width: 100%;
background: white;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.header {
padding: 32px;
text-align: center;
}
.header.success {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
}
.header.error {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
}
.icon {
width: 64px;
height: 64px;
margin: 0 auto 16px;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.icon svg {
width: 32px;
height: 32px;
color: white;
}
.header h1 {
color: white;
font-size: 24px;
font-weight: 600;
}
.content {
padding: 32px;
text-align: center;
}
.message {
font-size: 16px;
color: #4b5563;
line-height: 1.6;
margin-bottom: 24px;
}
.action-badge {
display: inline-block;
padding: 8px 20px;
border-radius: 20px;
font-weight: 600;
font-size: 14px;
margin-bottom: 24px;
}
.action-badge.approved {
background: #dcfce7;
color: #166534;
}
.action-badge.rejected {
background: #fef3c7;
color: #92400e;
}
.info-box {
background: #f8fafc;
border-radius: 12px;
padding: 20px;
margin-top: 16px;
}
.info-box p {
font-size: 14px;
color: #64748b;
}
.footer {
padding: 20px 32px;
background: #f8fafc;
text-align: center;
border-top: 1px solid #e2e8f0;
}
.footer p {
font-size: 12px;
color: #94a3b8;
}
</style>
</head>
<body>
<div class="container">
<div class="header {% if success %}success{% else %}error{% endif %}">
<div class="icon">
{% if success %}
<svg 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 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 %}
</div>
<h1>{{ title }}</h1>
</div>
<div class="content">
{% if success and action %}
<div class="action-badge {% if action == 'approve' %}approved{% else %}rejected{% endif %}">
{% if action == 'approve' %}
Freigegeben
{% else %}
Zur Überarbeitung
{% endif %}
</div>
{% endif %}
<p class="message">{{ message }}</p>
{% if success %}
<div class="info-box">
{% if action == 'approve' %}
<p>Der Post wurde in die Spalte "Veröffentlicht" verschoben und kann nun auf LinkedIn gepostet werden.</p>
{% else %}
<p>Der Post wurde zurück in die Spalte "Vorschläge" verschoben. Der Creator wurde benachrichtigt und wird die gewünschten Änderungen vornehmen.</p>
{% endif %}
</div>
{% endif %}
</div>
<div class="footer">
<p>Du kannst dieses Fenster jetzt schließen.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,137 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Dashboard{% endblock %} - LinkedIn Post Generator</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
'brand-bg': '#1a1a2e',
'brand-bg-dark': '#0f0f1a',
'brand-bg-light': '#252540',
'brand-highlight': '#e94560',
'brand-accent': '#0f3460',
}
}
}
}
</script>
<style>
body {
background-color: #0f0f1a;
}
.sidebar-link {
@apply flex items-center gap-3 px-4 py-3 text-gray-300 hover:bg-brand-bg-light rounded-lg transition-colors;
}
.sidebar-link.active {
@apply bg-brand-bg-light text-white;
}
.card-bg {
background-color: #1a1a2e;
border-color: #2a2a4a;
}
.input-bg {
background-color: #252540;
border-color: #3a3a5a;
}
.input-bg:focus {
border-color: #e94560;
outline: none;
}
.btn-primary {
background-color: #e94560;
}
.btn-primary:hover {
background-color: #d13a52;
}
</style>
{% block head %}{% endblock %}
</head>
<body class="min-h-screen">
<div class="flex min-h-screen">
<!-- Sidebar -->
<aside class="w-64 bg-brand-bg border-r border-gray-700 flex flex-col">
<!-- Logo/Brand -->
<div class="p-6 border-b border-gray-700">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-brand-highlight rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-white" 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>
</div>
<div>
<h1 class="text-white font-bold">{{ session.linkedin_name or session.customer_name or 'Mitarbeiter' }}</h1>
<p class="text-gray-500 text-xs">Mitarbeiter</p>
</div>
</div>
</div>
<!-- Company Info -->
{% if session.company_name %}
<div class="px-6 py-4 border-b border-gray-700">
<p class="text-xs text-gray-500 uppercase tracking-wide mb-1">Unternehmen</p>
<p class="text-brand-highlight font-medium">{{ session.company_name }}</p>
</div>
{% endif %}
<!-- Navigation -->
<nav class="flex-1 p-4 space-y-1">
<a href="/" class="sidebar-link {% if request.url.path == '/' %}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="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"/>
</svg>
Dashboard
</a>
<a href="/posts" class="sidebar-link {% if '/posts' in request.url.path %}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 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>
Meine Posts
</a>
<a href="/create-post" class="sidebar-link {% if '/create-post' in request.url.path %}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="M12 4v16m8-8H4"/>
</svg>
Neuer Post
</a>
<a href="/employee/strategy" class="sidebar-link {% if '/employee/strategy' in request.url.path %}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 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>
Unternehmensstrategie
</a>
<a href="/settings" class="sidebar-link {% if '/settings' in request.url.path %}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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
Einstellungen
</a>
</nav>
<!-- Logout -->
<div class="p-4 border-t border-gray-700">
<a href="/logout" class="sidebar-link text-red-400 hover:text-red-300 hover:bg-red-900/20">
<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="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>
Abmelden
</a>
</div>
</aside>
<!-- Main Content -->
<main class="flex-1 overflow-auto">
<div class="p-8">
{% block content %}{% endblock %}
</div>
</main>
</div>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,147 @@
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<h1 class="text-2xl font-bold text-white mb-6">Willkommen, {{ session.linkedin_name or session.customer_name or 'Mitarbeiter' }}!</h1>
<!-- Company Info Banner -->
{% if session.company_name %}
<div class="bg-brand-highlight/10 border border-brand-highlight/30 rounded-xl p-6 mb-8">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-brand-highlight rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-brand-bg-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
</div>
<div>
<p class="text-gray-400 text-sm">Du bist Mitarbeiter von</p>
<p class="text-xl font-bold text-brand-highlight">{{ session.company_name }}</p>
</div>
</div>
</div>
{% endif %}
<!-- Stats Cards -->
<div class="grid md:grid-cols-3 gap-6 mb-8">
<!-- Posts Card -->
<div class="card-bg border rounded-xl 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="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-3xl font-bold text-white">{{ posts_count or 0 }}</p>
<p class="text-gray-400 text-sm">Erstellte Posts</p>
</div>
</div>
</div>
<!-- Pending Posts Card -->
<div class="card-bg border rounded-xl p-6">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-yellow-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-yellow-400" 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>
</div>
<div>
<p class="text-3xl font-bold text-white">{{ pending_posts or 0 }}</p>
<p class="text-gray-400 text-sm">Ausstehend</p>
</div>
</div>
</div>
<!-- Approved Posts Card -->
<div class="card-bg border rounded-xl p-6">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-green-500/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 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div>
<p class="text-3xl font-bold text-white">{{ approved_posts or 0 }}</p>
<p class="text-gray-400 text-sm">Genehmigt</p>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="card-bg border rounded-xl p-6 mb-8">
<h2 class="text-lg font-semibold text-white mb-4">Schnellzugriff</h2>
<div class="grid md:grid-cols-3 gap-4">
<a href="/research" class="flex items-center gap-3 p-4 bg-brand-bg-dark rounded-lg hover:bg-brand-bg-light transition-colors">
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</div>
<div>
<p class="text-white font-medium">Research Topics</p>
<p class="text-gray-400 text-sm">Themen recherchieren</p>
</div>
</a>
<a href="/create" class="flex items-center gap-3 p-4 bg-brand-bg-dark rounded-lg hover:bg-brand-bg-light transition-colors">
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
<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 4v16m8-8H4"/>
</svg>
</div>
<div>
<p class="text-white font-medium">Neuer Post</p>
<p class="text-gray-400 text-sm">KI-generiert</p>
</div>
</a>
<a href="/employee/strategy" class="flex items-center gap-3 p-4 bg-brand-bg-dark rounded-lg hover:bg-brand-bg-light transition-colors">
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
<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 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/>
</svg>
</div>
<div>
<p class="text-white font-medium">Strategie</p>
<p class="text-gray-400 text-sm">Richtlinien</p>
</div>
</a>
</div>
</div>
<!-- Recent Posts -->
{% if recent_posts and recent_posts|length > 0 %}
<div class="card-bg border rounded-xl p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-white">Letzte Posts</h2>
<a href="/posts" class="text-brand-highlight text-sm hover:underline">Alle anzeigen</a>
</div>
<div class="space-y-3">
{% for post in recent_posts[:5] %}
<div class="flex items-start gap-3 p-3 bg-brand-bg-dark rounded-lg">
<div class="flex-1">
<p class="text-white text-sm line-clamp-2">{{ post.post_content[:150] }}{% if post.post_content|length > 150 %}...{% endif %}</p>
<p class="text-gray-500 text-xs mt-1">{{ post.created_at.strftime('%d.%m.%Y') }}</p>
</div>
<span class="text-xs px-2 py-1 rounded {% if post.status == 'approved' %}bg-green-500/20 text-green-400{% elif post.status == 'rejected' %}bg-red-500/20 text-red-400{% else %}bg-yellow-500/20 text-yellow-400{% endif %}">
{% if post.status == 'approved' %}Genehmigt{% elif post.status == 'rejected' %}Abgelehnt{% else %}Ausstehend{% endif %}
</span>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="card-bg border rounded-xl p-6 text-center">
<div class="w-16 h-16 bg-gray-600/30 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-gray-500" 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>
<p class="text-gray-400">Noch keine Posts erstellt</p>
<a href="/create" class="inline-block mt-4 text-brand-highlight hover:underline">Ersten Post erstellen</a>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,139 @@
{% extends "base.html" %}
{% block title %}Unternehmensstrategie - {{ session.company_name }}{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto">
<div class="mb-8">
<h1 class="text-2xl font-bold text-white mb-2">Unternehmensstrategie</h1>
<p class="text-gray-400">Diese Strategie wird bei der Erstellung deiner LinkedIn-Posts berücksichtigt.</p>
{% if session.company_name %}
<p class="text-brand-highlight text-sm mt-2">Richtlinien von {{ session.company_name }}</p>
{% endif %}
</div>
{% if not strategy or not strategy.mission %}
<div class="card-bg border rounded-xl p-8 text-center">
<div class="w-16 h-16 bg-yellow-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-yellow-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>
<p class="text-gray-400">Dein Unternehmen hat noch keine Strategie definiert.</p>
<p class="text-gray-500 text-sm mt-2">Wende dich an deinen Administrator, um die Unternehmensstrategie einzurichten.</p>
</div>
{% else %}
<div class="space-y-6">
<!-- Mission & Vision -->
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-white mb-4">Mission & Vision</h2>
<div class="space-y-4">
{% if strategy.mission %}
<div>
<p class="text-sm font-medium text-gray-400 mb-1">Mission</p>
<p class="text-white bg-brand-bg-dark rounded-lg p-3">{{ strategy.mission }}</p>
</div>
{% endif %}
{% if strategy.vision %}
<div>
<p class="text-sm font-medium text-gray-400 mb-1">Vision</p>
<p class="text-white bg-brand-bg-dark rounded-lg p-3">{{ strategy.vision }}</p>
</div>
{% endif %}
</div>
</div>
<!-- Brand Voice -->
{% if strategy.brand_voice or strategy.tone_guidelines %}
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-white mb-4">Brand Voice</h2>
<div class="space-y-4">
{% if strategy.brand_voice %}
<div>
<p class="text-sm font-medium text-gray-400 mb-1">Markenstimme</p>
<p class="text-white bg-brand-bg-dark rounded-lg p-3">{{ strategy.brand_voice }}</p>
</div>
{% endif %}
{% if strategy.tone_guidelines %}
<div>
<p class="text-sm font-medium text-gray-400 mb-1">Tonalität-Richtlinien</p>
<p class="text-white bg-brand-bg-dark rounded-lg p-3">{{ strategy.tone_guidelines }}</p>
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Target Audience -->
{% if strategy.target_audience %}
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-white mb-4">Zielgruppe</h2>
<p class="text-white bg-brand-bg-dark rounded-lg p-3">{{ strategy.target_audience }}</p>
</div>
{% endif %}
<!-- Content Pillars -->
{% if strategy.content_pillars and strategy.content_pillars|length > 0 %}
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-white mb-4">Content-Säulen</h2>
<p class="text-gray-400 text-sm mb-4">Die Hauptthemen, über die das Unternehmen postet.</p>
<div class="flex flex-wrap gap-2">
{% for pillar in strategy.content_pillars %}
<span class="px-3 py-2 bg-brand-highlight/20 border border-brand-highlight/30 rounded-lg text-brand-highlight text-sm">{{ pillar }}</span>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Do's and Don'ts -->
{% if (strategy.dos and strategy.dos|length > 0) or (strategy.donts and strategy.donts|length > 0) %}
<div class="grid md:grid-cols-2 gap-6">
<!-- Do's -->
{% if strategy.dos and strategy.dos|length > 0 %}
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-green-400 mb-4 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="M5 13l4 4L19 7"/>
</svg>
Do's
</h2>
<ul class="space-y-2">
{% for do in strategy.dos %}
<li class="flex items-start gap-2 text-white text-sm">
<svg class="w-4 h-4 text-green-400 mt-0.5 flex-shrink-0" 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>
{{ do }}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
<!-- Don'ts -->
{% if strategy.donts and strategy.donts|length > 0 %}
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-red-400 mb-4 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="M6 18L18 6M6 6l12 12"/>
</svg>
Don'ts
</h2>
<ul class="space-y-2">
{% for dont in strategy.donts %}
<li class="flex items-start gap-2 text-white text-sm">
<svg class="w-4 h-4 text-red-400 mt-0.5 flex-shrink-0" 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>
{{ dont }}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,151 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Einladung annehmen - 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; }
.input-bg { background-color: #3d4848; border-color: #5a6868; }
.input-bg:focus { border-color: #ffc700; outline: none; }
.btn-primary { background-color: #ffc700; color: #2d3838; }
.btn-primary:hover { background-color: #e6b300; }
</style>
</head>
<body class="text-gray-100 min-h-screen flex items-center justify-center">
<div class="w-full max-w-md px-4">
<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">Einladung von {{ company_name }}</h1>
<p class="text-gray-400">Du wurdest von {{ inviter_name }} eingeladen, dem Team beizutreten.</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">
{{ error }}
</div>
{% endif %}
{% if expired %}
<div class="bg-yellow-900/50 border border-yellow-500 text-yellow-200 px-4 py-3 rounded-lg mb-6">
<p class="font-medium">Diese Einladung ist abgelaufen</p>
<p class="text-sm">Bitte frage nach einer neuen Einladung.</p>
</div>
{% else %}
<!-- Invitation Details -->
<div class="bg-brand-bg border border-gray-600 rounded-lg p-4 mb-6">
<div class="flex items-center gap-3 mb-3">
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
<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="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
</div>
<div>
<p class="text-white font-medium">{{ company_name }}</p>
<p class="text-sm text-gray-400">Eingeladen am {{ invitation.created_at.strftime('%d.%m.%Y') }}</p>
</div>
</div>
<p class="text-sm text-gray-400">
Als Teammitglied kannst du LinkedIn-Posts erstellen, die der Unternehmensstrategie entsprechen.
</p>
</div>
<!-- LinkedIn OAuth -->
<div class="mb-6">
<a href="/auth/linkedin?invite_token={{ invitation.token }}" 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 beitreten
</a>
</div>
<div class="relative my-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-600"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-4 bg-brand-bg-light text-gray-400">oder mit E-Mail</span>
</div>
</div>
<!-- Email/Password Form -->
<form method="POST" action="/invite/{{ invitation.token }}/accept" class="space-y-4">
<div>
<label for="email" class="block text-sm font-medium text-gray-300 mb-1">E-Mail</label>
<input type="email" id="email" name="email" required
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
value="{{ invitation.email }}" readonly>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-300 mb-1">Passwort erstellen</label>
<input type="password" id="password" name="password" required minlength="8"
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
placeholder="Mindestens 8 Zeichen">
<p class="text-xs text-gray-500 mt-1">Mind. 8 Zeichen, 1 Großbuchstabe, 1 Zahl</p>
</div>
<div>
<label for="password_confirm" class="block text-sm font-medium text-gray-300 mb-1">Passwort bestätigen</label>
<input type="password" id="password_confirm" name="password_confirm" required
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
placeholder="Passwort wiederholen">
</div>
<button type="submit" class="w-full btn-primary font-medium py-3 px-4 rounded-lg transition-colors">
Einladung annehmen
</button>
</form>
{% endif %}
<div class="text-center pt-6 mt-6 border-t border-gray-600">
<p class="text-gray-400 text-sm">
Du hast bereits ein Konto?
<a href="/login" class="text-brand-highlight hover:underline">Anmelden</a>
</p>
</div>
</div>
</div>
<script>
// Password confirmation validation
const form = document.querySelector('form');
if (form) {
const password = document.getElementById('password');
const passwordConfirm = document.getElementById('password_confirm');
form.addEventListener('submit', function(e) {
if (password.value !== passwordConfirm.value) {
e.preventDefault();
alert('Passwörter stimmen nicht überein');
}
});
}
</script>
</body>
</html>

View File

@@ -59,8 +59,35 @@
Mit LinkedIn anmelden
</a>
<div class="relative my-4">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-600"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-4 bg-brand-bg-light text-gray-400">oder</span>
</div>
</div>
<!-- Email/Password Login Form -->
<form method="POST" action="/auth/login" class="space-y-4">
<div>
<input type="email" name="email" required
class="w-full bg-brand-bg border border-gray-600 rounded-lg px-4 py-2 text-white focus:border-brand-highlight focus:outline-none"
placeholder="E-Mail">
</div>
<div>
<input type="password" name="password" required
class="w-full bg-brand-bg border border-gray-600 rounded-lg px-4 py-2 text-white focus:border-brand-highlight focus:outline-none"
placeholder="Passwort">
</div>
<button type="submit" class="w-full bg-brand-highlight hover:bg-brand-highlight-dark text-brand-bg-dark font-medium py-3 px-4 rounded-lg transition-colors">
Anmelden
</button>
</form>
<p class="text-center text-gray-500 text-sm">
Melde dich mit deinem LinkedIn-Konto an, um auf das Dashboard zuzugreifen.
Noch kein Konto?
<a href="/register" class="text-brand-highlight hover:underline">Jetzt registrieren</a>
</p>
</div>
</div>

View File

@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Onboarding{% endblock %} - 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; }
.card-bg { background-color: #4a5858; border-color: #5a6868; }
.input-bg { background-color: #3d4848; border-color: #5a6868; }
.input-bg:focus { border-color: #ffc700; outline: none; }
.btn-primary { background-color: #ffc700; color: #2d3838; }
.btn-primary:hover { background-color: #e6b300; }
.btn-secondary { background-color: #5a6868; color: #fff; }
.btn-secondary:hover { background-color: #6a7878; }
.step-active { background-color: #ffc700; color: #2d3838; }
.step-done { background-color: #22c55e; color: white; }
.step-pending { background-color: #5a6868; color: #999; }
</style>
{% block head %}{% endblock %}
</head>
<body class="text-gray-100 min-h-screen">
<div class="max-w-4xl mx-auto px-4 py-8">
<!-- Header -->
<div class="text-center mb-8">
<img src="/static/logo.png" alt="Logo" class="h-12 w-auto mx-auto mb-4">
</div>
<!-- Progress Steps -->
{% if steps %}
<div class="mb-8">
<div class="flex items-center justify-center">
{% for step in steps %}
<div class="flex items-center">
<div class="flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium
{% if step.status == 'done' %}step-done
{% elif step.status == 'active' %}step-active
{% else %}step-pending{% endif %}">
{% if step.status == 'done' %}
<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>
{% else %}
{{ loop.index }}
{% endif %}
</div>
<span class="ml-2 text-sm {% if step.status == 'active' %}text-white{% else %}text-gray-500{% endif %}">
{{ step.name }}
</span>
</div>
{% if not loop.last %}
<div class="w-12 h-0.5 mx-2 {% if step.status == 'done' %}bg-green-500{% else %}bg-gray-600{% endif %}"></div>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
<!-- Content Card -->
<div class="card-bg rounded-xl border p-8">
{% block content %}{% endblock %}
</div>
</div>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,193 @@
{% extends "onboarding/base.html" %}
{% block title %}Posts kategorisieren{% endblock %}
{% block content %}
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-white mb-2">Posts kategorisieren</h1>
<p class="text-gray-400">Wir ordnen deine Posts automatisch den Post-Typen zu.</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">
{{ error }}
</div>
{% endif %}
<!-- Classification Progress -->
<div id="classification-section" class="mb-8">
<div class="card-bg border border-gray-600 rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium text-white">Kategorisierung</h3>
<div id="classification-status" class="text-sm">
{% if classification_complete %}
<span class="text-green-400">Abgeschlossen</span>
{% else %}
<span class="text-yellow-400">Ausstehend</span>
{% endif %}
</div>
</div>
<!-- Progress Bar -->
<div class="mb-4">
<div class="h-2 bg-gray-600 rounded-full overflow-hidden">
<div id="progress-bar" class="h-full bg-brand-highlight transition-all duration-300"
style="width: {{ progress }}%"></div>
</div>
<div class="flex justify-between text-xs text-gray-400 mt-1">
<span id="progress-text">{{ classified_count }}/{{ total_posts }} Posts</span>
<span id="progress-percent">{{ progress }}%</span>
</div>
</div>
{% if not classification_complete %}
<button id="start-classification-btn"
class="btn-primary py-2 px-4 rounded-lg 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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
Automatisch kategorisieren
</button>
{% endif %}
</div>
</div>
<!-- Post Type Distribution -->
<div class="mb-8">
<h3 class="text-lg font-medium text-white mb-4">Verteilung nach Post-Typ</h3>
<div class="space-y-3" id="type-distribution">
{% for post_type in post_types %}
<div class="flex items-center gap-4">
<div class="flex-1">
<div class="flex justify-between text-sm mb-1">
<span class="text-white">{{ post_type.name }}</span>
<span class="text-gray-400">{{ post_type.count }} Posts</span>
</div>
<div class="h-2 bg-gray-600 rounded-full overflow-hidden">
<div class="h-full bg-brand-highlight"
style="width: {{ (post_type.count / total_posts * 100) if total_posts > 0 else 0 }}%"></div>
</div>
</div>
</div>
{% endfor %}
{% if uncategorized_count > 0 %}
<div class="flex items-center gap-4">
<div class="flex-1">
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-400">Nicht kategorisiert</span>
<span class="text-gray-400">{{ uncategorized_count }} Posts</span>
</div>
<div class="h-2 bg-gray-600 rounded-full overflow-hidden">
<div class="h-full bg-gray-500"
style="width: {{ (uncategorized_count / total_posts * 100) if total_posts > 0 else 0 }}%"></div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Manual Review Section -->
{% if uncategorized_posts and uncategorized_posts|length > 0 %}
<div class="mb-8">
<h3 class="text-lg font-medium text-white mb-4">Manuelle Nachkategorisierung</h3>
<p class="text-sm text-gray-400 mb-4">Diese Posts konnten nicht automatisch kategorisiert werden:</p>
<div class="space-y-4" id="manual-review-list">
{% for post in uncategorized_posts[:5] %}
<div class="bg-brand-bg border border-gray-600 rounded-lg p-4">
<p class="text-sm text-gray-300 mb-3 line-clamp-3">{{ post.post_text[:300] }}{% if post.post_text|length > 300 %}...{% endif %}</p>
<div class="flex flex-wrap gap-2">
{% for pt in post_types %}
<button type="button"
class="text-xs px-3 py-1 rounded-full border border-gray-500 text-gray-300 hover:border-brand-highlight hover:text-brand-highlight transition-colors"
onclick="categorizePost('{{ post.id }}', '{{ pt.id }}')">
{{ pt.name }}
</button>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Navigation -->
<form method="POST" action="/onboarding/categorize">
<div class="flex justify-between pt-6 border-t border-gray-600">
<a href="/onboarding/post-types" class="btn-secondary font-medium py-3 px-6 rounded-lg transition-colors">
Zurück
</a>
<button type="submit" class="btn-primary font-medium py-3 px-8 rounded-lg transition-colors"
{% if not classification_complete and classified_count < 3 %}disabled{% endif %}>
Weiter
</button>
</div>
</form>
{% endblock %}
{% block scripts %}
<script>
const userId = "{{ user_id }}";
{% if not classification_complete %}
document.getElementById('start-classification-btn').addEventListener('click', async function() {
const btn = this;
btn.disabled = true;
btn.innerHTML = `
<svg class="w-4 h-4 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 12h4z"></path>
</svg>
Kategorisiere...
`;
try {
const response = await fetch('/api/onboarding/classify-posts', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({user_id: userId})
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
btn.disabled = false;
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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
Automatisch kategorisieren
`;
}
} catch (e) {
alert('Fehler bei der Kategorisierung');
btn.disabled = false;
}
});
{% endif %}
async function categorizePost(postId, postTypeId) {
try {
const response = await fetch('/api/onboarding/categorize-post', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({post_id: postId, post_type_id: postTypeId})
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
}
} catch (e) {
alert('Fehler bei der Kategorisierung');
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,77 @@
{% extends "onboarding/base.html" %}
{% block title %}Unternehmensdaten{% endblock %}
{% block content %}
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-white mb-2">Unternehmensdaten</h1>
<p class="text-gray-400">Erzähl uns mehr über dein Unternehmen.</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">
{{ error }}
</div>
{% endif %}
<form method="POST" action="/onboarding/company" class="space-y-6">
<!-- Company Name -->
<div>
<label for="name" class="block text-sm font-medium text-gray-300 mb-1">
Unternehmensname *
</label>
<input type="text" id="name" name="name" required
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Dein Unternehmen GmbH"
value="{{ company.name if company else '' }}">
</div>
<!-- Description -->
<div>
<label for="description" class="block text-sm font-medium text-gray-300 mb-1">
Beschreibung
</label>
<textarea id="description" name="description" rows="3"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Was macht dein Unternehmen?">{{ company.description if company else '' }}</textarea>
</div>
<!-- Website & Industry -->
<div class="grid md:grid-cols-2 gap-4">
<div>
<label for="website" class="block text-sm font-medium text-gray-300 mb-1">
Website
</label>
<input type="url" id="website" name="website"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="https://dein-unternehmen.de"
value="{{ company.website if company else '' }}">
</div>
<div>
<label for="industry" class="block text-sm font-medium text-gray-300 mb-1">
Branche
</label>
<select id="industry" name="industry"
class="w-full input-bg border rounded-lg px-4 py-3 text-white">
<option value="">Bitte wählen...</option>
<option value="technology" {% if company and company.industry == 'technology' %}selected{% endif %}>Technologie</option>
<option value="finance" {% if company and company.industry == 'finance' %}selected{% endif %}>Finanzen</option>
<option value="healthcare" {% if company and company.industry == 'healthcare' %}selected{% endif %}>Gesundheitswesen</option>
<option value="education" {% if company and company.industry == 'education' %}selected{% endif %}>Bildung</option>
<option value="retail" {% if company and company.industry == 'retail' %}selected{% endif %}>Einzelhandel</option>
<option value="manufacturing" {% if company and company.industry == 'manufacturing' %}selected{% endif %}>Produktion</option>
<option value="consulting" {% if company and company.industry == 'consulting' %}selected{% endif %}>Beratung</option>
<option value="marketing" {% if company and company.industry == 'marketing' %}selected{% endif %}>Marketing & Werbung</option>
<option value="real_estate" {% if company and company.industry == 'real_estate' %}selected{% endif %}>Immobilien</option>
<option value="other" {% if company and company.industry == 'other' %}selected{% endif %}>Sonstiges</option>
</select>
</div>
</div>
<div class="flex justify-end pt-4">
<button type="submit" class="btn-primary font-medium py-3 px-8 rounded-lg transition-colors">
Weiter zur Strategie
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,104 @@
{% extends "onboarding/base.html" %}
{% block title %}Setup abgeschlossen{% endblock %}
{% block content %}
<div class="text-center">
<!-- Success Icon -->
<div class="w-20 h-20 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
<svg class="w-10 h-10 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>
</div>
<h1 class="text-2xl font-bold text-white mb-2">Setup abgeschlossen!</h1>
<p class="text-gray-400 mb-8">Dein Profil wurde erfolgreich eingerichtet. Du kannst jetzt mit dem Erstellen von Posts beginnen.</p>
<!-- Summary -->
<div class="bg-brand-bg border border-gray-600 rounded-lg p-6 mb-8 text-left">
<h3 class="text-lg font-medium text-white mb-4">Zusammenfassung</h3>
<div class="space-y-3 text-sm">
<div class="flex justify-between">
<span class="text-gray-400">LinkedIn-Profil</span>
<span class="text-white">{{ profile.linkedin_url }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">Posts analysiert</span>
<span class="text-white">{{ posts_count }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">Post-Typen definiert</span>
<span class="text-white">{{ post_types_count }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">Profil-Analyse</span>
{% if profile_analysis %}
<span class="text-green-400">Abgeschlossen</span>
{% elif analysis_started %}
<span class="text-brand-highlight animate-pulse">Läuft im Hintergrund...</span>
{% else %}
<span class="text-gray-500">Ausstehend</span>
{% endif %}
</div>
</div>
</div>
{% if analysis_started %}
<!-- Analysis Running Info -->
<div class="bg-brand-highlight/10 border border-brand-highlight/30 rounded-lg p-4 mb-8 text-left">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-brand-highlight mt-0.5 animate-spin" 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>
<div>
<h4 class="font-medium text-brand-highlight">Analyse läuft im Hintergrund</h4>
<p class="text-sm text-gray-400 mt-1">
Wir analysieren jetzt dein Profil, kategorisieren deine Posts und analysieren deine Post-Typen.
Du kannst das Dashboard bereits nutzen - die Fortschrittsanzeige siehst du unten rechts.
</p>
</div>
</div>
</div>
{% endif %}
<!-- Next Steps -->
<div class="grid md:grid-cols-3 gap-4 mb-8">
<div class="bg-brand-bg border border-gray-600 rounded-lg p-4">
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center mb-3 mx-auto">
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</div>
<h4 class="font-medium text-white mb-1">Topics recherchieren</h4>
<p class="text-xs text-gray-400">Finde relevante Themen für deine nächsten Posts</p>
</div>
<div class="bg-brand-bg border border-gray-600 rounded-lg p-4">
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center mb-3 mx-auto">
<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="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>
<h4 class="font-medium text-white mb-1">Post erstellen</h4>
<p class="text-xs text-gray-400">Schreibe deinen ersten KI-unterstützten Post</p>
</div>
<div class="bg-brand-bg border border-gray-600 rounded-lg p-4">
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center mb-3 mx-auto">
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</div>
<h4 class="font-medium text-white mb-1">Einstellungen</h4>
<p class="text-xs text-gray-400">Passe dein Profil weiter an</p>
</div>
</div>
<!-- CTA -->
<a href="/" class="inline-block btn-primary font-medium py-3 px-8 rounded-lg transition-colors">
Zum Dashboard
</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,229 @@
{% extends "onboarding/base.html" %}
{% block title %}Post-Typen{% endblock %}
{% block content %}
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-white mb-2">Post-Typen definieren</h1>
<p class="text-gray-400">Definiere die verschiedenen Arten von Posts, die du schreibst.</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">
{{ error }}
</div>
{% endif %}
<!-- Predefined Post Types -->
<div class="mb-8">
<h3 class="text-lg font-medium text-white mb-4">Vordefinierte Post-Typen</h3>
<p class="text-sm text-gray-400 mb-4">Wähle die Post-Typen aus, die zu deinem Content passen:</p>
<div class="grid md:grid-cols-2 gap-4" id="predefined-types">
<label class="flex items-start gap-3 p-4 border border-gray-600 rounded-lg cursor-pointer hover:border-brand-highlight transition-colors">
<input type="checkbox" name="predefined_type" value="thought_leadership" class="mt-1">
<div>
<span class="text-white font-medium">Thought Leadership</span>
<p class="text-sm text-gray-400">Brancheninsights, Meinungen, Trends</p>
</div>
</label>
<label class="flex items-start gap-3 p-4 border border-gray-600 rounded-lg cursor-pointer hover:border-brand-highlight transition-colors">
<input type="checkbox" name="predefined_type" value="personal_story" class="mt-1">
<div>
<span class="text-white font-medium">Personal Story</span>
<p class="text-sm text-gray-400">Persönliche Erfahrungen, Learnings</p>
</div>
</label>
<label class="flex items-start gap-3 p-4 border border-gray-600 rounded-lg cursor-pointer hover:border-brand-highlight transition-colors">
<input type="checkbox" name="predefined_type" value="how_to" class="mt-1">
<div>
<span class="text-white font-medium">How-To / Tutorial</span>
<p class="text-sm text-gray-400">Anleitungen, Tipps, Best Practices</p>
</div>
</label>
<label class="flex items-start gap-3 p-4 border border-gray-600 rounded-lg cursor-pointer hover:border-brand-highlight transition-colors">
<input type="checkbox" name="predefined_type" value="news_commentary" class="mt-1">
<div>
<span class="text-white font-medium">News & Kommentar</span>
<p class="text-sm text-gray-400">Aktuelle Nachrichten mit Einordnung</p>
</div>
</label>
<label class="flex items-start gap-3 p-4 border border-gray-600 rounded-lg cursor-pointer hover:border-brand-highlight transition-colors">
<input type="checkbox" name="predefined_type" value="case_study" class="mt-1">
<div>
<span class="text-white font-medium">Case Study</span>
<p class="text-sm text-gray-400">Erfolgsgeschichten, Projektberichte</p>
</div>
</label>
<label class="flex items-start gap-3 p-4 border border-gray-600 rounded-lg cursor-pointer hover:border-brand-highlight transition-colors">
<input type="checkbox" name="predefined_type" value="engagement" class="mt-1">
<div>
<span class="text-white font-medium">Engagement Post</span>
<p class="text-sm text-gray-400">Fragen, Umfragen, Diskussionen</p>
</div>
</label>
</div>
</div>
<!-- Custom Post Types -->
<div class="mb-8">
<h3 class="text-lg font-medium text-white mb-4">Eigene Post-Typen</h3>
<div id="custom-types-list" class="space-y-3 mb-4">
<!-- Custom types will be added here -->
</div>
<button type="button" id="add-custom-type-btn"
class="btn-secondary py-2 px-4 rounded-lg 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="M12 4v16m8-8H4"/>
</svg>
Eigenen Typ hinzufügen
</button>
</div>
<!-- Navigation -->
<form method="POST" action="/onboarding/post-types" id="post-types-form">
<input type="hidden" name="post_types_json" id="post-types-json">
<div class="flex justify-between pt-6 border-t border-gray-600">
<a href="/onboarding/posts" class="btn-secondary font-medium py-3 px-6 rounded-lg transition-colors">
Zurück
</a>
<button type="submit" class="btn-primary font-medium py-3 px-8 rounded-lg transition-colors">
Weiter
</button>
</div>
</form>
<!-- Custom Type Modal -->
<div id="custom-type-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="card-bg rounded-xl p-6 w-full max-w-md mx-4">
<h3 class="text-lg font-medium text-white mb-4">Eigenen Post-Typ erstellen</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Name *</label>
<input type="text" id="custom-type-name"
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
placeholder="z.B. Produktvorstellung">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Beschreibung</label>
<textarea id="custom-type-description" rows="2"
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
placeholder="Kurze Beschreibung..."></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Keywords (kommagetrennt)</label>
<input type="text" id="custom-type-keywords"
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
placeholder="produkt, launch, neu">
</div>
</div>
<div class="flex justify-end gap-3 mt-6">
<button type="button" onclick="closeCustomTypeModal()"
class="btn-secondary py-2 px-4 rounded-lg">Abbrechen</button>
<button type="button" onclick="addCustomType()"
class="btn-primary py-2 px-4 rounded-lg">Hinzufügen</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const customTypes = [];
// Predefined types mapping
const predefinedTypes = {
'thought_leadership': {name: 'Thought Leadership', description: 'Brancheninsights, Meinungen, Trends', keywords: ['insight', 'trend', 'meinung']},
'personal_story': {name: 'Personal Story', description: 'Persönliche Erfahrungen, Learnings', keywords: ['story', 'erfahrung', 'learning']},
'how_to': {name: 'How-To / Tutorial', description: 'Anleitungen, Tipps, Best Practices', keywords: ['howto', 'tipps', 'anleitung']},
'news_commentary': {name: 'News & Kommentar', description: 'Aktuelle Nachrichten mit Einordnung', keywords: ['news', 'aktuell', 'kommentar']},
'case_study': {name: 'Case Study', description: 'Erfolgsgeschichten, Projektberichte', keywords: ['case', 'projekt', 'erfolg']},
'engagement': {name: 'Engagement Post', description: 'Fragen, Umfragen, Diskussionen', keywords: ['frage', 'umfrage', 'diskussion']}
};
document.getElementById('add-custom-type-btn').addEventListener('click', function() {
document.getElementById('custom-type-modal').classList.remove('hidden');
});
function closeCustomTypeModal() {
document.getElementById('custom-type-modal').classList.add('hidden');
document.getElementById('custom-type-name').value = '';
document.getElementById('custom-type-description').value = '';
document.getElementById('custom-type-keywords').value = '';
}
function addCustomType() {
const name = document.getElementById('custom-type-name').value.trim();
const description = document.getElementById('custom-type-description').value.trim();
const keywords = document.getElementById('custom-type-keywords').value.split(',').map(k => k.trim()).filter(k => k);
if (!name) {
alert('Name ist erforderlich');
return;
}
customTypes.push({name, description, keywords, custom: true});
renderCustomTypes();
closeCustomTypeModal();
}
function removeCustomType(index) {
customTypes.splice(index, 1);
renderCustomTypes();
}
function renderCustomTypes() {
const container = document.getElementById('custom-types-list');
container.innerHTML = customTypes.map((type, index) => `
<div class="flex items-center justify-between p-3 bg-brand-bg border border-gray-600 rounded-lg">
<div>
<span class="text-white font-medium">${type.name}</span>
${type.description ? `<p class="text-sm text-gray-400">${type.description}</p>` : ''}
</div>
<button type="button" onclick="removeCustomType(${index})" class="text-gray-500 hover:text-red-400">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
`).join('');
}
// Form submission
document.getElementById('post-types-form').addEventListener('submit', function(e) {
// Collect selected predefined types
const selected = Array.from(document.querySelectorAll('input[name="predefined_type"]:checked'))
.map(cb => {
const def = predefinedTypes[cb.value];
return {
name: def.name,
description: def.description,
identifying_keywords: def.keywords,
custom: false
};
});
// Add custom types
const allTypes = [...selected, ...customTypes.map(t => ({
name: t.name,
description: t.description,
identifying_keywords: t.keywords,
custom: true
}))];
document.getElementById('post-types-json').value = JSON.stringify(allTypes);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,226 @@
{% extends "onboarding/base.html" %}
{% block title %}Posts analysieren{% endblock %}
{% block content %}
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-white mb-2">Posts analysieren</h1>
<p class="text-gray-400">Wir analysieren die bisherigen LinkedIn-Posts deines Kunden, um den Schreibstil zu lernen.</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">
{{ error }}
</div>
{% endif %}
<!-- Scraping Status -->
<div id="scraping-section" class="mb-8">
<div class="card-bg border border-gray-600 rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-lg font-medium text-white">LinkedIn Posts von {{ profile.display_name }}</h3>
<p class="text-sm text-gray-400">Profil: {{ profile.linkedin_url }}</p>
</div>
<div id="post-count" class="text-2xl font-bold text-brand-highlight">
<span id="post-count-number">{{ scraped_posts_count }}</span> Posts
</div>
</div>
<!-- Scraping in Progress Notice -->
<div id="scraping-progress" class="{% if not scraping_in_progress %}hidden{% endif %} bg-brand-highlight/10 border border-brand-highlight/30 rounded-lg p-4 mb-4">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-brand-highlight animate-spin" 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>
<div>
<p class="text-brand-highlight font-medium">Posts werden im Hintergrund geladen...</p>
<p class="text-sm text-gray-400">Du kannst fortfahren sobald genug Posts vorhanden sind.</p>
</div>
</div>
</div>
{% if scraped_posts_count < 10 %}
<div id="low-posts-warning" class="bg-yellow-900/30 border border-yellow-600 rounded-lg p-4 mb-4">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-yellow-500 mt-0.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>
<div>
<p class="text-yellow-200 font-medium">Zu wenige Posts gefunden</p>
<p class="text-yellow-300/70 text-sm">Für eine gute Stilanalyse brauchen wir mindestens 10 Posts. Du kannst entweder manuell Beispiel-Posts hinzufügen oder warten bis das Scraping fertig ist.</p>
</div>
</div>
</div>
{% endif %}
<button id="rescrape-btn" class="btn-secondary py-2 px-4 rounded-lg 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="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>
<span>Posts neu laden</span>
</button>
</div>
</div>
<!-- Manual Posts Section -->
<div id="manual-posts-section" class="{% if scraped_posts_count >= 10 %}hidden{% endif %} mb-8">
<h3 class="text-lg font-medium text-white mb-4">Beispiel-Posts manuell hinzufügen</h3>
<form id="manual-post-form" class="space-y-4">
<div>
<textarea id="manual-post-text" rows="6"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Füge hier einen LinkedIn-Post deines Kunden ein..."></textarea>
</div>
<button type="submit" class="btn-secondary py-2 px-4 rounded-lg transition-colors">
Post hinzufügen
</button>
</form>
<!-- Added Manual Posts -->
<div id="manual-posts-list" class="mt-4 space-y-2">
{% for post in example_posts %}
<div class="bg-brand-bg border border-gray-600 rounded-lg p-3 flex justify-between items-start">
<p class="text-sm text-gray-300 line-clamp-2">{{ post.post_text[:200] }}{% if post.post_text|length > 200 %}...{% endif %}</p>
<button class="text-gray-500 hover:text-red-400 ml-2" onclick="removeManualPost('{{ post.id }}', this.parentElement)">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{% endfor %}
</div>
</div>
<!-- Navigation -->
<div class="flex justify-between pt-8 border-t border-gray-600">
<a href="/onboarding/profile" class="text-gray-400 hover:text-white transition-colors 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="M11 17l-5-5m0 0l5-5m-5 5h12"/>
</svg>
Zurück
</a>
<form method="POST" action="/onboarding/posts">
<button type="submit" id="continue-btn" class="btn-primary font-medium py-3 px-8 rounded-lg transition-colors flex items-center gap-2"
{% if scraped_posts_count < 5 %}disabled{% endif %}>
Weiter
<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="M13 7l5 5m0 0l-5 5m5-5H6"/>
</svg>
</button>
</form>
</div>
<script>
async function removeManualPost(postId, element) {
try {
const response = await fetch(`/api/onboarding/remove-manual-post/${postId}`, {method: 'DELETE'});
if (response.ok) {
if (element) {
element.remove();
} else {
// Find and remove the element by post ID
const btn = document.querySelector(`[onclick*="${postId}"]`);
if (btn) btn.closest('.bg-brand-bg').remove();
}
}
} catch (e) {
console.error('Error removing manual post:', e);
}
}
document.addEventListener('DOMContentLoaded', function() {
const postCountEl = document.getElementById('post-count-number');
const progressEl = document.getElementById('scraping-progress');
const warningEl = document.getElementById('low-posts-warning');
const manualSection = document.getElementById('manual-posts-section');
const continueBtn = document.getElementById('continue-btn');
const rescrapeBtn = document.getElementById('rescrape-btn');
// Poll for post count updates
let pollInterval = setInterval(async () => {
try {
const response = await fetch('/api/posts-count');
const data = await response.json();
postCountEl.textContent = data.count;
// Update UI based on count
if (data.count >= 10) {
if (warningEl) warningEl.classList.add('hidden');
if (manualSection) manualSection.classList.add('hidden');
}
if (data.count >= 5) {
continueBtn.disabled = false;
}
// Check if scraping is still in progress
if (!data.scraping_active) {
progressEl.classList.add('hidden');
}
} catch (e) {
console.error('Error polling post count:', e);
}
}, 3000);
// Manual post form
const form = document.getElementById('manual-post-form');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const textarea = document.getElementById('manual-post-text');
const text = textarea.value.trim();
if (!text) return;
try {
const response = await fetch('/api/onboarding/add-manual-post', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({post_text: text})
});
if (response.ok) {
const data = await response.json();
textarea.value = '';
// Add to list
const list = document.getElementById('manual-posts-list');
const div = document.createElement('div');
div.className = 'bg-brand-bg border border-gray-600 rounded-lg p-3 flex justify-between items-start';
div.innerHTML = `
<p class="text-sm text-gray-300 line-clamp-2">${text.substring(0, 200)}${text.length > 200 ? '...' : ''}</p>
<button class="text-gray-500 hover:text-red-400 ml-2" onclick="removeManualPost('${data.id}', this.parentElement)">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
`;
list.appendChild(div);
}
} catch (e) {
console.error('Error adding manual post:', e);
}
});
// Rescrape button
rescrapeBtn.addEventListener('click', async () => {
try {
const response = await fetch('/api/onboarding/rescrape', {method: 'POST'});
if (response.ok) {
progressEl.classList.remove('hidden');
showToast('Scraping gestartet...');
}
} catch (e) {
console.error('Error starting rescrape:', e);
}
});
// Cleanup on leave
window.addEventListener('beforeunload', () => {
clearInterval(pollInterval);
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,130 @@
{% extends "onboarding/base.html" %}
{% block title %}Profil einrichten{% endblock %}
{% block content %}
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-white mb-2">Profil einrichten</h1>
<p class="text-gray-400">Richte dein Ghostwriter-Profil ein und gib die Daten deines Kunden an.</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">
{{ error }}
</div>
{% endif %}
<form method="POST" action="/onboarding/profile" class="space-y-8">
<!-- Section: Ghostwriter (You) -->
<div class="bg-brand-bg border border-gray-600 rounded-xl p-6">
<h2 class="text-lg 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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
Deine Daten (Ghostwriter)
</h2>
<p class="text-gray-400 text-sm mb-4">Diese Informationen werden in deinem Dashboard angezeigt.</p>
<div class="grid md:grid-cols-2 gap-4">
<div>
<label for="ghostwriter_name" class="block text-sm font-medium text-gray-300 mb-1">
Dein Name *
</label>
<input type="text" id="ghostwriter_name" name="ghostwriter_name" required
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Max Mustermann"
value="{{ prefill.ghostwriter_name or session.linkedin_name or '' }}">
</div>
<div>
<label for="creator_email" class="block text-sm font-medium text-gray-300 mb-1">
Deine E-Mail
</label>
<input type="email" id="creator_email" name="creator_email"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="ghostwriter@email.de"
value="{{ prefill.creator_email or session.email or '' }}">
<p class="text-xs text-gray-500 mt-1">Du wirst über Entscheidungen benachrichtigt</p>
</div>
</div>
</div>
<!-- Section: Client/Customer -->
<div class="bg-brand-bg border border-gray-600 rounded-xl p-6">
<h2 class="text-lg 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="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 0z"/>
</svg>
Kunden-Daten (für wen du schreibst)
</h2>
<p class="text-gray-400 text-sm mb-4">Die Person/Marke, für die du LinkedIn-Posts erstellst.</p>
<div class="space-y-4">
<div class="grid md:grid-cols-2 gap-4">
<div>
<label for="customer_name" class="block text-sm font-medium text-gray-300 mb-1">
Kunden-Name *
</label>
<input type="text" id="customer_name" name="customer_name" required
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Lisa Kundin"
value="{{ prefill.customer_name or '' }}">
</div>
<div>
<label for="customer_email" class="block text-sm font-medium text-gray-300 mb-1">
Kunden E-Mail
</label>
<input type="email" id="customer_email" name="customer_email"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="kunde@email.de"
value="{{ prefill.customer_email or '' }}">
<p class="text-xs text-gray-500 mt-1">Genehmigt Posts per E-Mail (optional)</p>
</div>
</div>
<div>
<label for="linkedin_url" class="block text-sm font-medium text-gray-300 mb-1">
LinkedIn-Profil des Kunden *
</label>
<input type="url" id="linkedin_url" name="linkedin_url" required
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="https://linkedin.com/in/kunde-name"
value="{{ prefill.linkedin_url or '' }}">
<p class="text-xs text-gray-500 mt-1">Wir analysieren die bisherigen Posts, um den Schreibstil zu lernen</p>
</div>
<div>
<label for="company_name" class="block text-sm font-medium text-gray-300 mb-1">
Firma des Kunden (optional)
</label>
<input type="text" id="company_name" name="company_name"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Kunden GmbH"
value="{{ prefill.company_name or '' }}">
</div>
</div>
</div>
<!-- Section: Writing Style Notes -->
<div class="bg-brand-bg border border-gray-600 rounded-xl p-6">
<h2 class="text-lg 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="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>
Schreibstil-Notizen (optional)
</h2>
<textarea id="writing_style_notes" name="writing_style_notes" rows="4"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Besondere Hinweise zum Schreibstil des Kunden, z.B. 'Duzt immer', 'Nutzt oft Emojis', 'Formeller Ton', etc.">{{ prefill.writing_style_notes or '' }}</textarea>
</div>
<div class="flex justify-end pt-4">
<button type="submit" class="btn-primary font-medium py-3 px-8 rounded-lg transition-colors flex items-center gap-2">
Weiter
<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="M13 7l5 5m0 0l-5 5m5-5H6"/>
</svg>
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,93 @@
{% extends "onboarding/base.html" %}
{% block title %}Profil einrichten{% endblock %}
{% block content %}
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-white mb-2">Profil einrichten</h1>
<p class="text-gray-400">Richte dein LinkedIn-Profil ein, um personalisierte Posts zu erstellen.</p>
{% if session.company_name %}
<p class="text-brand-highlight text-sm mt-2">Du wirst als Mitarbeiter von {{ session.company_name }} registriert.</p>
{% endif %}
</div>
{% if error %}
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
{{ error }}
</div>
{% endif %}
<form method="POST" action="/onboarding/profile" class="space-y-8">
<!-- Hidden field to indicate employee flow -->
<input type="hidden" name="is_employee" value="true">
<!-- Section: Your Profile -->
<div class="bg-brand-bg border border-gray-600 rounded-xl p-6">
<h2 class="text-lg 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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
Deine Daten
</h2>
<p class="text-gray-400 text-sm mb-4">Diese Informationen werden in deinem Dashboard angezeigt.</p>
<div class="space-y-4">
<div class="grid md:grid-cols-2 gap-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-300 mb-1">
Dein Name *
</label>
<input type="text" id="name" name="name" required
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Max Mustermann"
value="{{ prefill.name or session.linkedin_name or '' }}">
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-300 mb-1">
Deine E-Mail
</label>
<input type="email" id="email" name="email"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="deine@email.de"
value="{{ prefill.email or session.email or '' }}">
<p class="text-xs text-gray-500 mt-1">Du wirst benachrichtigt, wenn Posts bereit sind</p>
</div>
</div>
<div>
<label for="linkedin_url" class="block text-sm font-medium text-gray-300 mb-1">
Dein LinkedIn-Profil *
</label>
<input type="url" id="linkedin_url" name="linkedin_url" required
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="https://linkedin.com/in/dein-name"
value="{{ prefill.linkedin_url or '' }}">
<p class="text-xs text-gray-500 mt-1">Wir analysieren deine bisherigen Posts, um deinen Schreibstil zu lernen</p>
</div>
</div>
</div>
<!-- Section: Writing Style Notes -->
<div class="bg-brand-bg border border-gray-600 rounded-xl p-6">
<h2 class="text-lg 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="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>
Schreibstil-Notizen (optional)
</h2>
<textarea id="writing_style_notes" name="writing_style_notes" rows="4"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Besondere Hinweise zu deinem Schreibstil, z.B. 'Ich duze immer', 'Nutze oft Emojis', 'Formeller Ton', etc.">{{ prefill.writing_style_notes or '' }}</textarea>
</div>
<div class="flex justify-end pt-4">
<button type="submit" class="btn-primary font-medium py-3 px-8 rounded-lg transition-colors flex items-center gap-2">
Weiter
<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="M13 7l5 5m0 0l-5 5m5-5H6"/>
</svg>
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,277 @@
{% extends "onboarding/base.html" %}
{% block title %}Unternehmensstrategie{% endblock %}
{% block content %}
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-white mb-2">Unternehmensstrategie</h1>
<p class="text-gray-400">Definiere die Content-Strategie für dein Unternehmen. Diese Richtlinien werden bei jedem Post berücksichtigt.</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">
{{ error }}
</div>
{% endif %}
<form method="POST" action="/onboarding/strategy" class="space-y-8">
<!-- Mission & Vision -->
<div class="space-y-4">
<h3 class="text-lg font-medium text-white">Mission & Vision</h3>
<div>
<label for="mission" class="block text-sm font-medium text-gray-300 mb-1">
Mission
</label>
<textarea id="mission" name="mission" rows="2"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Was ist der Zweck deines Unternehmens?">{{ strategy.mission if strategy else '' }}</textarea>
</div>
<div>
<label for="vision" class="block text-sm font-medium text-gray-300 mb-1">
Vision
</label>
<textarea id="vision" name="vision" rows="2"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Wo soll dein Unternehmen in 5-10 Jahren stehen?">{{ strategy.vision if strategy else '' }}</textarea>
</div>
</div>
<!-- Brand Voice -->
<div class="space-y-4">
<h3 class="text-lg font-medium text-white">Brand Voice & Tonalität</h3>
<div>
<label for="brand_voice" class="block text-sm font-medium text-gray-300 mb-1">
Brand Voice
</label>
<textarea id="brand_voice" name="brand_voice" rows="2"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Wie soll dein Unternehmen klingen? z.B. 'professionell aber nahbar', 'innovativ und zukunftsorientiert'">{{ strategy.brand_voice if strategy else '' }}</textarea>
</div>
<div>
<label for="tone_guidelines" class="block text-sm font-medium text-gray-300 mb-1">
Tonalitäts-Richtlinien
</label>
<textarea id="tone_guidelines" name="tone_guidelines" rows="3"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Spezifische Anweisungen zur Tonalität, z.B. 'Wir duzen unsere Zielgruppe', 'Wir nutzen keine Anglizismen'">{{ strategy.tone_guidelines if strategy else '' }}</textarea>
</div>
</div>
<!-- Content Pillars -->
<div class="space-y-4">
<h3 class="text-lg font-medium text-white">Content Pillars</h3>
<p class="text-sm text-gray-400">Die Hauptthemen, über die dein Unternehmen kommuniziert (max. 5).</p>
<div id="content-pillars" class="space-y-2">
{% if strategy and strategy.content_pillars %}
{% for pillar in strategy.content_pillars %}
<div class="flex gap-2 pillar-row">
<input type="text" name="content_pillar" value="{{ pillar }}"
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white"
placeholder="z.B. 'Digitale Transformation'">
<button type="button" onclick="removePillar(this)"
class="text-gray-500 hover:text-red-400 px-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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{% endfor %}
{% else %}
<div class="flex gap-2 pillar-row">
<input type="text" name="content_pillar"
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white"
placeholder="z.B. 'Digitale Transformation'">
<button type="button" onclick="removePillar(this)"
class="text-gray-500 hover:text-red-400 px-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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{% endif %}
</div>
<button type="button" onclick="addPillar()"
class="text-sm text-brand-highlight hover:underline flex items-center gap-1">
<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 4v16m8-8H4"/>
</svg>
Weiteren Pillar hinzufügen
</button>
</div>
<!-- Target Audience -->
<div>
<label for="target_audience" class="block text-sm font-medium text-gray-300 mb-1">
Zielgruppe
</label>
<textarea id="target_audience" name="target_audience" rows="2"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Wer ist eure Zielgruppe auf LinkedIn? z.B. 'B2B Entscheider in der DACH-Region, CEOs und CTOs von mittelstaendischen Unternehmen'">{{ strategy.target_audience if strategy else '' }}</textarea>
</div>
<!-- Do's and Don'ts -->
<div class="grid md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">
Do's (empfohlen)
</label>
<div id="dos-list" class="space-y-2 mb-2">
{% if strategy and strategy.dos %}
{% for item in strategy.dos %}
<div class="flex gap-2 do-row">
<input type="text" name="do_item" value="{{ item }}"
class="flex-1 input-bg border rounded-lg px-3 py-2 text-white text-sm"
placeholder="z.B. 'Aktuelle Studien zitieren'">
<button type="button" onclick="removeItem(this)"
class="text-gray-500 hover:text-red-400">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{% endfor %}
{% else %}
<div class="flex gap-2 do-row">
<input type="text" name="do_item"
class="flex-1 input-bg border rounded-lg px-3 py-2 text-white text-sm"
placeholder="z.B. 'Aktuelle Studien zitieren'">
<button type="button" onclick="removeItem(this)"
class="text-gray-500 hover:text-red-400">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{% endif %}
</div>
<button type="button" onclick="addDo()"
class="text-xs text-brand-highlight hover:underline">+ Hinzufügen</button>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">
Don'ts (vermeiden)
</label>
<div id="donts-list" class="space-y-2 mb-2">
{% if strategy and strategy.donts %}
{% for item in strategy.donts %}
<div class="flex gap-2 dont-row">
<input type="text" name="dont_item" value="{{ item }}"
class="flex-1 input-bg border rounded-lg px-3 py-2 text-white text-sm"
placeholder="z.B. 'Keine politischen Themen'">
<button type="button" onclick="removeItem(this)"
class="text-gray-500 hover:text-red-400">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{% endfor %}
{% else %}
<div class="flex gap-2 dont-row">
<input type="text" name="dont_item"
class="flex-1 input-bg border rounded-lg px-3 py-2 text-white text-sm"
placeholder="z.B. 'Keine politischen Themen'">
<button type="button" onclick="removeItem(this)"
class="text-gray-500 hover:text-red-400">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{% endif %}
</div>
<button type="button" onclick="addDont()"
class="text-xs text-brand-highlight hover:underline">+ Hinzufügen</button>
</div>
</div>
<div class="flex justify-between pt-6 border-t border-gray-600">
<a href="/onboarding/company" class="btn-secondary font-medium py-3 px-6 rounded-lg transition-colors">
Zurück
</a>
<button type="submit" class="btn-primary font-medium py-3 px-8 rounded-lg transition-colors">
Strategie speichern
</button>
</div>
</form>
{% endblock %}
{% block scripts %}
<script>
function addPillar() {
const container = document.getElementById('content-pillars');
if (container.querySelectorAll('.pillar-row').length >= 5) {
alert('Maximal 5 Content Pillars erlaubt');
return;
}
const row = document.createElement('div');
row.className = 'flex gap-2 pillar-row';
row.innerHTML = `
<input type="text" name="content_pillar"
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white"
placeholder="z.B. 'Digitale Transformation'">
<button type="button" onclick="removePillar(this)"
class="text-gray-500 hover:text-red-400 px-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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
`;
container.appendChild(row);
}
function removePillar(btn) {
const container = document.getElementById('content-pillars');
if (container.querySelectorAll('.pillar-row').length > 1) {
btn.closest('.pillar-row').remove();
}
}
function addDo() {
const container = document.getElementById('dos-list');
const row = document.createElement('div');
row.className = 'flex gap-2 do-row';
row.innerHTML = `
<input type="text" name="do_item"
class="flex-1 input-bg border rounded-lg px-3 py-2 text-white text-sm"
placeholder="z.B. 'Aktuelle Studien zitieren'">
<button type="button" onclick="removeItem(this)"
class="text-gray-500 hover:text-red-400">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
`;
container.appendChild(row);
}
function addDont() {
const container = document.getElementById('donts-list');
const row = document.createElement('div');
row.className = 'flex gap-2 dont-row';
row.innerHTML = `
<input type="text" name="dont_item"
class="flex-1 input-bg border rounded-lg px-3 py-2 text-white text-sm"
placeholder="z.B. 'Keine politischen Themen'">
<button type="button" onclick="removeItem(this)"
class="text-gray-500 hover:text-red-400">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
`;
container.appendChild(row);
}
function removeItem(btn) {
btn.closest('div').remove();
}
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,352 @@
{% extends "base.html" %}
{% block title %}Post-Typen{% endblock %}
{% block content %}
<div class="max-w-6xl mx-auto">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-white">Post-Typen</h1>
<p class="text-gray-400 mt-1">Verwalte und kategorisiere deine LinkedIn-Posts</p>
</div>
<button onclick="runAnalysis()" id="analyze-btn" class="btn-primary px-5 py-2.5 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="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>
Analyse starten
</button>
</div>
<!-- Filter & Search Bar -->
<div class="card-bg border rounded-xl p-4 mb-6">
<div class="flex flex-col md:flex-row gap-4">
<!-- Search -->
<div class="flex-1">
<div class="relative">
<svg class="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" 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>
<input type="text" id="search-input" placeholder="Posts durchsuchen..."
class="w-full input-bg border rounded-lg pl-10 pr-4 py-2.5 text-white"
oninput="filterPosts()">
</div>
</div>
<!-- Post Type Filter -->
<div class="md:w-64">
<select id="type-filter" class="w-full input-bg border rounded-lg px-4 py-2.5 text-white" onchange="filterPosts()">
<option value="all">Alle Post-Typen</option>
<option value="uncategorized">Nicht kategorisiert</option>
{% for pt in post_types %}
<option value="{{ pt.id }}">{{ pt.name }}</option>
{% endfor %}
</select>
</div>
</div>
<!-- Stats Row -->
<div class="flex flex-wrap gap-4 mt-4 pt-4 border-t border-gray-600">
<div class="text-sm">
<span class="text-gray-400">Gesamt:</span>
<span class="text-white font-medium ml-1">{{ (uncategorized_posts|length) + (categorized_posts|length) }} Posts</span>
</div>
<div class="text-sm">
<span class="text-gray-400">Kategorisiert:</span>
<span class="text-green-400 font-medium ml-1">{{ categorized_posts|length }}</span>
</div>
<div class="text-sm">
<span class="text-gray-400">Nicht kategorisiert:</span>
<span class="text-yellow-400 font-medium ml-1">{{ uncategorized_posts|length }}</span>
</div>
</div>
</div>
<!-- Posts List -->
<div class="space-y-3" id="posts-list">
{% for post in uncategorized_posts %}
<div class="post-item card-bg border rounded-xl overflow-hidden"
data-post-id="{{ post.id }}"
data-type-id=""
data-text="{{ post.post_text|lower }}">
<div class="p-4">
<div class="flex items-start justify-between gap-4">
<!-- Post Content -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-2">
<span class="px-2 py-0.5 text-xs rounded-full bg-yellow-900/50 text-yellow-300">
Nicht kategorisiert
</span>
{% if post.post_date %}
<span class="text-xs text-gray-500">{{ post.post_date.strftime('%d.%m.%Y') if post.post_date else '' }}</span>
{% endif %}
</div>
<p class="text-gray-300 text-sm post-text line-clamp-3">{{ post.post_text[:300] }}{% if post.post_text|length > 300 %}...{% endif %}</p>
</div>
<!-- Post Type Selector -->
<div class="flex-shrink-0 w-48">
<select onchange="categorizePost('{{ post.id }}', this.value)"
class="w-full input-bg border rounded-lg px-3 py-2 text-sm text-white">
<option value="">Typ wählen...</option>
{% for pt in post_types %}
<option value="{{ pt.id }}">{{ pt.name }}</option>
{% endfor %}
</select>
</div>
</div>
<!-- Expand Button -->
<button onclick="toggleExpand(this)" class="text-xs text-gray-500 hover:text-gray-300 mt-2 flex items-center gap-1">
<svg class="w-4 h-4 expand-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
<span class="expand-text">Mehr anzeigen</span>
</button>
</div>
<!-- Full Content (hidden by default) -->
<div class="full-content hidden border-t border-gray-600 p-4 bg-brand-bg/30">
<p class="text-gray-300 text-sm whitespace-pre-wrap">{{ post.post_text }}</p>
{% if post.likes or post.comments %}
<div class="flex gap-4 mt-3 text-xs text-gray-500">
{% if post.likes %}<span>{{ post.likes }} Likes</span>{% endif %}
{% if post.comments %}<span>{{ post.comments }} Kommentare</span>{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
{% for post in categorized_posts %}
<div class="post-item card-bg border rounded-xl overflow-hidden"
data-post-id="{{ post.id }}"
data-type-id="{{ post.post_type_id or '' }}"
data-text="{{ post.post_text|lower }}">
<div class="p-4">
<div class="flex items-start justify-between gap-4">
<!-- Post Content -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-2">
{% set type_name = "Unbekannt" %}
{% for pt in post_types %}
{% if pt.id == post.post_type_id %}
{% set type_name = pt.name %}
{% endif %}
{% endfor %}
<span class="px-2 py-0.5 text-xs rounded-full bg-brand-highlight/20 text-brand-highlight">
{{ type_name }}
</span>
{% if post.post_date %}
<span class="text-xs text-gray-500">{{ post.post_date.strftime('%d.%m.%Y') if post.post_date else '' }}</span>
{% endif %}
</div>
<p class="text-gray-300 text-sm post-text line-clamp-3">{{ post.post_text[:300] }}{% if post.post_text|length > 300 %}...{% endif %}</p>
</div>
<!-- Post Type Selector -->
<div class="flex-shrink-0 w-48">
<select onchange="categorizePost('{{ post.id }}', this.value)"
class="w-full input-bg border rounded-lg px-3 py-2 text-sm text-white">
<option value="">Typ ändern...</option>
{% for pt in post_types %}
<option value="{{ pt.id }}" {% if pt.id == post.post_type_id %}selected{% endif %}>{{ pt.name }}</option>
{% endfor %}
</select>
</div>
</div>
<!-- Expand Button -->
<button onclick="toggleExpand(this)" class="text-xs text-gray-500 hover:text-gray-300 mt-2 flex items-center gap-1">
<svg class="w-4 h-4 expand-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
<span class="expand-text">Mehr anzeigen</span>
</button>
</div>
<!-- Full Content (hidden by default) -->
<div class="full-content hidden border-t border-gray-600 p-4 bg-brand-bg/30">
<p class="text-gray-300 text-sm whitespace-pre-wrap">{{ post.post_text }}</p>
{% if post.likes or post.comments %}
<div class="flex gap-4 mt-3 text-xs text-gray-500">
{% if post.likes %}<span>{{ post.likes }} Likes</span>{% endif %}
{% if post.comments %}<span>{{ post.comments }} Kommentare</span>{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<!-- Empty State -->
{% if not uncategorized_posts and not categorized_posts %}
<div class="card-bg border rounded-xl 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="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>
<h3 class="text-xl font-semibold text-white mb-2">Keine Posts gefunden</h3>
<p class="text-gray-400">Es wurden noch keine LinkedIn-Posts geladen.</p>
</div>
{% endif %}
<!-- No Results State (hidden by default) -->
<div id="no-results" class="hidden card-bg border rounded-xl 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<h3 class="text-xl font-semibold text-white mb-2">Keine Ergebnisse</h3>
<p class="text-gray-400">Keine Posts gefunden, die deinen Filterkriterien entsprechen.</p>
</div>
</div>
<script>
function toggleExpand(btn) {
const card = btn.closest('.post-item');
const fullContent = card.querySelector('.full-content');
const icon = btn.querySelector('.expand-icon');
const text = btn.querySelector('.expand-text');
if (fullContent.classList.contains('hidden')) {
fullContent.classList.remove('hidden');
icon.style.transform = 'rotate(180deg)';
text.textContent = 'Weniger anzeigen';
card.querySelector('.post-text').classList.remove('line-clamp-3');
} else {
fullContent.classList.add('hidden');
icon.style.transform = '';
text.textContent = 'Mehr anzeigen';
card.querySelector('.post-text').classList.add('line-clamp-3');
}
}
function filterPosts() {
const searchTerm = document.getElementById('search-input').value.toLowerCase();
const typeFilter = document.getElementById('type-filter').value;
const posts = document.querySelectorAll('.post-item');
let visibleCount = 0;
posts.forEach(post => {
const text = post.dataset.text;
const typeId = post.dataset.typeId;
const matchesSearch = !searchTerm || text.includes(searchTerm);
let matchesType = true;
if (typeFilter === 'uncategorized') {
matchesType = !typeId;
} else if (typeFilter !== 'all') {
matchesType = typeId === typeFilter;
}
if (matchesSearch && matchesType) {
post.classList.remove('hidden');
visibleCount++;
} else {
post.classList.add('hidden');
}
});
// Show/hide no results message
const noResults = document.getElementById('no-results');
if (visibleCount === 0 && posts.length > 0) {
noResults.classList.remove('hidden');
} else {
noResults.classList.add('hidden');
}
}
async function categorizePost(postId, postTypeId) {
if (!postTypeId) return;
const postEl = document.querySelector(`[data-post-id="${postId}"]`);
try {
const response = await fetch('/api/categorize-post', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({post_id: postId, post_type_id: postTypeId})
});
if (response.ok) {
// Update the badge
const badge = postEl.querySelector('.px-2.py-0\\.5');
const select = postEl.querySelector('select');
const selectedOption = select.options[select.selectedIndex];
badge.textContent = selectedOption.text;
badge.className = 'px-2 py-0.5 text-xs rounded-full bg-brand-highlight/20 text-brand-highlight';
// Update data attribute
postEl.dataset.typeId = postTypeId;
// Show success toast
if (window.showToast) {
showToast('Post kategorisiert!', 'success');
}
}
} catch (error) {
console.error('Error categorizing post:', error);
if (window.showToast) {
showToast('Fehler beim Kategorisieren', 'error');
}
}
}
async function runAnalysis() {
const btn = document.getElementById('analyze-btn');
const originalHTML = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<svg class="w-5 h-5 animate-spin" 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> Analysiere...';
try {
const response = await fetch('/api/run-post-type-analysis', {
method: 'POST'
});
if (response.ok) {
const data = await response.json();
if (window.showToast) {
showToast('Post-Typ-Analyse gestartet!');
}
// Listen for job completion via SSE, then reload
if (data.job_id) {
const evtSource = new EventSource('/api/job-updates');
evtSource.onmessage = function(event) {
try {
const jobData = JSON.parse(event.data);
if (jobData.id === data.job_id) {
if (jobData.status === 'completed' || jobData.status === 'failed') {
evtSource.close();
btn.disabled = false;
btn.innerHTML = originalHTML;
if (jobData.status === 'completed') {
window.location.reload();
} else {
if (window.showToast) showToast('Analyse fehlgeschlagen', 'error');
}
}
}
} catch (e) {
console.error('Error parsing SSE data:', e);
}
};
evtSource.onerror = function() {
evtSource.close();
btn.disabled = false;
btn.innerHTML = originalHTML;
};
} else {
btn.disabled = false;
btn.innerHTML = originalHTML;
}
} else {
btn.disabled = false;
btn.innerHTML = originalHTML;
}
} catch (error) {
console.error('Error starting analysis:', error);
btn.disabled = false;
btn.innerHTML = originalHTML;
}
}
</script>
{% endblock %}

View File

@@ -1,39 +1,175 @@
{% extends "base.html" %}
{% block title %}Meine Posts - LinkedIn Posts{% endblock %}
{% macro render_post_card(post) %}
<div class="post-card"
draggable="true"
data-post-id="{{ post.id }}"
ondragstart="handleDragStart(event)"
ondragend="handleDragEnd(event)"
onclick="window.location.href='/posts/{{ post.id }}'">
<div class="flex items-start justify-between gap-2 mb-2">
<h4 class="post-card-title">{{ post.topic_title or 'Untitled' }}</h4>
{% if post.critic_feedback and post.critic_feedback | length > 0 %}
{% set score = post.critic_feedback[-1].overall_score %}
<span class="score-badge flex-shrink-0 {{ 'score-high' if score >= 85 else 'score-medium' if score >= 70 else 'score-low' }}">
{{ score }}
</span>
{% endif %}
</div>
<div class="post-card-meta">
<span class="flex items-center gap-1">
<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">
<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 }}x
</span>
</div>
{% if post.post_content %}
<p class="post-card-preview">{{ post.post_content[:150] }}{% if post.post_content | length > 150 %}...{% endif %}</p>
{% endif %}
</div>
{% endmacro %}
{% block head %}
<style>
.post-card {
background: linear-gradient(135deg, rgba(61, 72, 72, 0.3) 0%, rgba(45, 56, 56, 0.4) 100%);
.kanban-board {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
min-height: calc(100vh - 250px);
}
@media (max-width: 1024px) {
.kanban-board {
grid-template-columns: 1fr;
}
}
.kanban-column {
background: rgba(45, 56, 56, 0.3);
border: 1px solid rgba(61, 72, 72, 0.6);
border-radius: 1rem;
display: flex;
flex-direction: column;
min-height: 400px;
}
.kanban-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(61, 72, 72, 0.6);
display: flex;
align-items: center;
justify-content: space-between;
}
.kanban-header h3 {
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.kanban-count {
background: rgba(61, 72, 72, 0.8);
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.kanban-body {
flex: 1;
padding: 1rem;
overflow-y: auto;
min-height: 100px;
}
.kanban-body.drag-over {
background: rgba(255, 199, 0, 0.05);
border: 2px dashed rgba(255, 199, 0, 0.3);
border-radius: 0.5rem;
margin: 0.5rem;
}
.post-card {
background: linear-gradient(135deg, rgba(61, 72, 72, 0.5) 0%, rgba(45, 56, 56, 0.6) 100%);
border: 1px solid rgba(61, 72, 72, 0.8);
border-radius: 0.75rem;
padding: 1rem;
margin-bottom: 0.75rem;
cursor: grab;
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);
border-color: rgba(255, 199, 0, 0.4);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.score-ring {
width: 44px;
height: 44px;
border-radius: 50%;
.post-card.dragging {
opacity: 0.5;
cursor: grabbing;
}
.post-card-title {
font-weight: 500;
color: white;
margin-bottom: 0.5rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.post-card-meta {
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
gap: 0.75rem;
font-size: 0.75rem;
color: #9ca3af;
}
.post-card-preview {
font-size: 0.8rem;
color: #9ca3af;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid rgba(61, 72, 72, 0.6);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
}
.score-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.7rem;
font-weight: 600;
}
.score-high { background: rgba(34, 197, 94, 0.2); color: #86efac; }
.score-medium { background: rgba(234, 179, 8, 0.2); color: #fde047; }
.score-low { background: rgba(239, 68, 68, 0.2); color: #fca5a5; }
.column-draft .kanban-header { border-left: 3px solid #f59e0b; }
.column-approved .kanban-header { border-left: 3px solid #3b82f6; }
.column-ready .kanban-header { border-left: 3px solid #22c55e; }
.empty-column {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
color: #6b7280;
text-align: center;
}
.empty-column svg {
width: 3rem;
height: 3rem;
margin-bottom: 0.75rem;
opacity: 0.5;
}
.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 class="mb-6 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>
<h1 class="text-2xl font-bold text-white mb-1">Meine Posts</h1>
<p class="text-gray-400 text-sm">Ziehe Posts zwischen den Spalten um den Status zu ändern</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>
@@ -47,59 +183,73 @@
</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 %}
<div class="kanban-board">
<!-- Column: Vorschlag (draft) -->
<div class="kanban-column column-draft">
<div class="kanban-header">
<h3 class="text-yellow-400">
<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.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
Vorschlag
</h3>
<span class="kanban-count" id="count-draft">{{ posts | selectattr('status', 'equalto', 'draft') | list | length }}</span>
</div>
<div class="kanban-body" data-status="draft" ondragover="handleDragOver(event)" ondrop="handleDrop(event)" ondragleave="handleDragLeave(event)">
{% for post in posts if post.status == 'draft' %}
{{ render_post_card(post) }}
{% else %}
<div class="empty-column" id="empty-draft">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
<p>Keine Vorschläge</p>
</div>
{% endfor %}
</div>
</div>
<!-- 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>
<!-- Column: Bearbeitet (approved) - waiting for customer approval -->
<div class="kanban-column column-approved">
<div class="kanban-header">
<h3 class="text-blue-400">
<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>
Bearbeitet
</h3>
<span class="kanban-count" id="count-approved">{{ posts | selectattr('status', 'equalto', 'approved') | list | length }}</span>
</div>
<div class="kanban-body" data-status="approved" ondragover="handleDragOver(event)" ondrop="handleDrop(event)" ondragleave="handleDragLeave(event)">
{% for post in posts if post.status == 'approved' %}
{{ render_post_card(post) }}
{% else %}
<div class="empty-column" id="empty-approved">
<svg 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>
<p>Keine bearbeiteten Posts</p>
</div>
{% endfor %}
</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>
<!-- Column: Freigegeben (ready) - approved by customer, ready for calendar scheduling -->
<div class="kanban-column column-ready">
<div class="kanban-header">
<h3 class="text-green-400">
<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 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
Freigegeben
</h3>
<span class="kanban-count" id="count-ready">{{ posts | selectattr('status', 'equalto', 'ready') | list | length }}</span>
</div>
<div class="kanban-body" data-status="ready" ondragover="handleDragOver(event)" ondrop="handleDrop(event)" ondragleave="handleDragLeave(event)">
{% for post in posts if post.status == 'ready' %}
{{ render_post_card(post) }}
{% else %}
<div class="empty-column" id="empty-ready">
<svg 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>
<p>Keine freigegebenen Posts</p>
</div>
{% endfor %}
</div>
</div>
</div>
{% else %}
<div class="card-bg rounded-xl border p-12 text-center">
{% if not posts %}
<div class="card-bg rounded-xl border p-12 text-center mt-6">
<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>
@@ -111,4 +261,149 @@
</a>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
let draggedElement = null;
let sourceStatus = null;
function handleDragStart(e) {
draggedElement = e.target;
sourceStatus = e.target.closest('.kanban-body').dataset.status;
e.target.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', e.target.dataset.postId);
}
function handleDragEnd(e) {
e.target.classList.remove('dragging');
document.querySelectorAll('.kanban-body').forEach(body => {
body.classList.remove('drag-over');
});
}
function handleDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const kanbanBody = e.target.closest('.kanban-body');
if (kanbanBody) {
kanbanBody.classList.add('drag-over');
}
}
function handleDragLeave(e) {
const kanbanBody = e.target.closest('.kanban-body');
if (kanbanBody && !kanbanBody.contains(e.relatedTarget)) {
kanbanBody.classList.remove('drag-over');
}
}
async function handleDrop(e) {
e.preventDefault();
const kanbanBody = e.target.closest('.kanban-body');
if (!kanbanBody || !draggedElement) return;
kanbanBody.classList.remove('drag-over');
const newStatus = kanbanBody.dataset.status;
const postId = draggedElement.dataset.postId;
// Don't do anything if dropped in same column
if (sourceStatus === newStatus) return;
// Remove empty placeholder if exists
const emptyPlaceholder = kanbanBody.querySelector('.empty-column');
if (emptyPlaceholder) {
emptyPlaceholder.remove();
}
// Move card to new column
kanbanBody.appendChild(draggedElement);
// Check if source column is now empty
const sourceBody = document.querySelector(`.kanban-body[data-status="${sourceStatus}"]`);
if (sourceBody && sourceBody.querySelectorAll('.post-card').length === 0) {
addEmptyPlaceholder(sourceBody, sourceStatus);
}
// Update counts
updateCounts();
// Update status in backend
try {
const formData = new FormData();
formData.append('status', newStatus);
const response = await fetch(`/api/posts/${postId}/status`, {
method: 'PATCH',
body: formData
});
if (!response.ok) {
throw new Error('Failed to update status');
}
} catch (error) {
console.error('Error updating status:', error);
showToast('Fehler beim Aktualisieren des Status', 'error');
// Revert the move
location.reload();
}
}
function addEmptyPlaceholder(container, status) {
const icons = {
'draft': '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>',
'approved': '<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"/>',
'ready': '<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"/>'
};
const labels = {
'draft': 'Keine Vorschläge',
'approved': 'Keine bearbeiteten Posts',
'ready': 'Keine freigegebenen Posts'
};
const placeholder = document.createElement('div');
placeholder.className = 'empty-column';
placeholder.id = `empty-${status}`;
placeholder.innerHTML = `
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">${icons[status]}</svg>
<p>${labels[status]}</p>
`;
container.appendChild(placeholder);
}
function updateCounts() {
['draft', 'approved', 'ready'].forEach(status => {
const count = document.querySelectorAll(`.kanban-body[data-status="${status}"] .post-card`).length;
document.getElementById(`count-${status}`).textContent = count;
});
}
function getStatusLabel(status) {
const labels = {
'draft': 'Vorschlag',
'approved': 'Bearbeitet',
'ready': 'Freigegeben'
};
return labels[status] || status;
}
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `fixed bottom-4 right-4 px-4 py-3 rounded-lg shadow-lg z-50 transition-all transform ${
type === 'success' ? 'bg-green-600 text-white' :
type === 'error' ? 'bg-red-600 text-white' :
'bg-brand-bg-light text-white'
}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.classList.add('opacity-0');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
</script>
{% endblock %}

View File

@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Registrierung - 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; }
.card-bg { background-color: #4a5858; border-color: #5a6868; }
.btn-primary { background-color: #ffc700; color: #2d3838; }
.btn-primary:hover { background-color: #e6b300; }
</style>
</head>
<body class="text-gray-100 min-h-screen flex items-center justify-center">
<div class="w-full max-w-2xl px-4">
<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">Unternehmen registrieren</h1>
<p class="text-gray-400">Erstelle ein Konto für dein Unternehmen</p>
</div>
<!-- GHOSTWRITER FEATURE DISABLED -->
<!-- To re-enable: Uncomment ghostwriter option below and change grid to md:grid-cols-2 -->
<div class="mb-8">
<!-- Ghostwriter Option (DISABLED) -->
<!--
<a href="/register/ghostwriter" class="block p-6 rounded-xl border border-gray-600 hover:border-brand-highlight transition-colors group">
<div class="w-12 h-12 bg-brand-highlight/20 rounded-lg flex items-center justify-center mb-4 group-hover:bg-brand-highlight/30">
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-white mb-2">Ghostwriter</h3>
<p class="text-gray-400 text-sm">
Erstelle LinkedIn-Posts fur dich selbst oder fur einen einzelnen Kunden.
Ideal fur Freelancer und Content-Creator.
</p>
<div class="mt-4 text-brand-highlight text-sm font-medium group-hover:underline">
Als Ghostwriter starten &rarr;
</div>
</a>
-->
<!-- Company Option -->
<a href="/register/company" class="block p-6 rounded-xl border border-brand-highlight bg-brand-highlight/5 transition-colors group max-w-lg mx-auto">
<div class="w-12 h-12 bg-brand-highlight/20 rounded-lg flex items-center justify-center mb-4 group-hover:bg-brand-highlight/30">
<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="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-white mb-2">Unternehmen</h3>
<p class="text-gray-400 text-sm">
Verwalte mehrere Mitarbeiter-Konten mit einer einheitlichen Unternehmensstrategie.
Ideal fur Teams und Agenturen.
</p>
<div class="mt-4 text-brand-highlight text-sm font-medium group-hover:underline">
Jetzt registrieren &rarr;
</div>
</a>
</div>
<div class="text-center pt-6 border-t border-gray-600">
<p class="text-gray-400 text-sm">
Du hast bereits ein Konto?
<a href="/login" class="text-brand-highlight hover:underline">Anmelden</a>
</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>

View File

@@ -0,0 +1,160 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Unternehmens Registrierung - 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; }
.input-bg { background-color: #3d4848; border-color: #5a6868; }
.input-bg:focus { border-color: #ffc700; outline: none; }
.btn-primary { background-color: #ffc700; color: #2d3838; }
.btn-primary:hover { background-color: #e6b300; }
</style>
</head>
<body class="text-gray-100 min-h-screen flex items-center justify-center">
<div class="w-full max-w-md px-4">
<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">Unternehmens-Konto</h1>
<p class="text-gray-400">Registriere dein Unternehmen</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">
{{ error }}
</div>
{% endif %}
<!-- LinkedIn OAuth -->
<div class="mb-6">
<a href="/auth/linkedin?account_type=company" 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 registrieren (empfohlen)
</a>
</div>
<div class="relative my-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-600"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-4 bg-brand-bg-light text-gray-400">oder mit E-Mail</span>
</div>
</div>
<!-- Email/Password Form -->
<form method="POST" action="/register/company" class="space-y-4">
<div>
<label for="license_key" class="block text-sm font-medium text-gray-300 mb-1">
Lizenzschlüssel *
<span class="text-xs text-gray-500 font-normal ml-2">
(Format: XXXX-XXXX-XXXX-XXXX)
</span>
</label>
<input type="text"
id="license_key"
name="license_key"
placeholder="ABCD-EFGH-IJKL-MNOP"
pattern="[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}"
required
class="w-full input-bg border rounded-lg px-4 py-2 text-white placeholder-gray-500"
maxlength="19"
style="text-transform: uppercase;">
<p class="text-xs text-gray-500 mt-1">
Bitte gib deinen Lizenzschlüssel ein, um dich zu registrieren.
</p>
</div>
<div>
<label for="company_name" class="block text-sm font-medium text-gray-300 mb-1">Unternehmensname</label>
<input type="text" id="company_name" name="company_name" required
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
placeholder="Dein Unternehmen GmbH">
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-300 mb-1">E-Mail</label>
<input type="email" id="email" name="email" required
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
placeholder="admin@unternehmen.de">
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-300 mb-1">Passwort</label>
<input type="password" id="password" name="password" required minlength="8"
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
placeholder="Mindestens 8 Zeichen">
<p class="text-xs text-gray-500 mt-1">Mind. 8 Zeichen, 1 Großbuchstabe, 1 Zahl</p>
</div>
<div>
<label for="password_confirm" class="block text-sm font-medium text-gray-300 mb-1">Passwort bestätigen</label>
<input type="password" id="password_confirm" name="password_confirm" required
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
placeholder="Passwort wiederholen">
</div>
<button type="submit" class="w-full btn-primary font-medium py-3 px-4 rounded-lg transition-colors">
Unternehmen registrieren
</button>
</form>
<div class="text-center pt-6 mt-6 border-t border-gray-600">
<p class="text-gray-400 text-sm">
Du hast bereits ein Konto?
<a href="/login" class="text-brand-highlight hover:underline">Anmelden</a>
</p>
<p class="text-gray-500 text-sm mt-2">
<a href="/register" class="hover:text-gray-300">&larr; Kontotyp ändern</a>
</p>
</div>
</div>
</div>
<script>
// License key uppercase formatting
const licenseKeyInput = document.getElementById('license_key');
licenseKeyInput.addEventListener('input', (e) => {
e.target.value = e.target.value.toUpperCase();
});
// Password confirmation validation
const form = document.querySelector('form');
const password = document.getElementById('password');
const passwordConfirm = document.getElementById('password_confirm');
form.addEventListener('submit', function(e) {
if (password.value !== passwordConfirm.value) {
e.preventDefault();
alert('Passwörter stimmen nicht überein');
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ghostwriter Registrierung - 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; }
.input-bg { background-color: #3d4848; border-color: #5a6868; }
.input-bg:focus { border-color: #ffc700; outline: none; }
.btn-primary { background-color: #ffc700; color: #2d3838; }
.btn-primary:hover { background-color: #e6b300; }
</style>
</head>
<body class="text-gray-100 min-h-screen flex items-center justify-center">
<div class="w-full max-w-md px-4">
<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">Ghostwriter Konto</h1>
<p class="text-gray-400">Erstelle dein persönliches Konto</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">
{{ error }}
</div>
{% endif %}
<!-- LinkedIn OAuth -->
<div class="mb-6">
<a href="/auth/linkedin?account_type=ghostwriter" 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 registrieren (empfohlen)
</a>
</div>
<div class="relative my-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-600"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-4 bg-brand-bg-light text-gray-400">oder mit E-Mail</span>
</div>
</div>
<!-- Email/Password Form -->
<form method="POST" action="/register/ghostwriter" class="space-y-4">
<div>
<label for="email" class="block text-sm font-medium text-gray-300 mb-1">E-Mail</label>
<input type="email" id="email" name="email" required
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
placeholder="deine@email.de">
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-300 mb-1">Passwort</label>
<input type="password" id="password" name="password" required minlength="8"
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
placeholder="Mindestens 8 Zeichen">
<p class="text-xs text-gray-500 mt-1">Mind. 8 Zeichen, 1 Großbuchstabe, 1 Zahl</p>
</div>
<div>
<label for="password_confirm" class="block text-sm font-medium text-gray-300 mb-1">Passwort bestätigen</label>
<input type="password" id="password_confirm" name="password_confirm" required
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
placeholder="Passwort wiederholen">
</div>
<button type="submit" class="w-full btn-primary font-medium py-3 px-4 rounded-lg transition-colors">
Konto erstellen
</button>
</form>
<div class="text-center pt-6 mt-6 border-t border-gray-600">
<p class="text-gray-400 text-sm">
Du hast bereits ein Konto?
<a href="/login" class="text-brand-highlight hover:underline">Anmelden</a>
</p>
<p class="text-gray-500 text-sm mt-2">
<a href="/register" class="hover:text-gray-300">&larr; Kontotyp ändern</a>
</p>
</div>
</div>
</div>
<script>
// Password confirmation validation
const form = document.querySelector('form');
const password = document.getElementById('password');
const passwordConfirm = document.getElementById('password_confirm');
form.addEventListener('submit', function(e) {
if (password.value !== passwordConfirm.value) {
e.preventDefault();
alert('Passwörter stimmen nicht überein');
}
});
</script>
</body>
</html>

View File

@@ -7,6 +7,21 @@
<p class="text-gray-400">Recherchiere neue Content-Themen mit Perplexity AI</p>
</div>
<!-- Limit Warning -->
{% if limit_reached %}
<div class="bg-red-900/50 border border-red-500 text-red-200 px-6 py-4 rounded-xl mb-8">
<div class="flex items-center gap-3">
<svg class="w-6 h-6 flex-shrink-0" 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>
<strong class="font-semibold">Limit erreicht</strong>
<p class="text-sm mt-1">{{ limit_message }}</p>
</div>
</div>
</div>
{% endif %}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Left: Form -->
<div>
@@ -34,9 +49,10 @@
</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">
<button type="submit" id="submitBtn" {% if limit_reached %}disabled{% endif %}
class="w-full btn-primary font-medium py-3 rounded-lg transition-colors flex items-center justify-center gap-2 {% if limit_reached %}opacity-50 cursor-not-allowed{% 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 starten
{% if limit_reached %}Limit erreicht{% else %}Research starten{% endif %}
</button>
</form>
</div>

View File

@@ -0,0 +1,316 @@
{% extends "base.html" %}
{% block title %}Einstellungen{% endblock %}
{% block content %}
<div class="max-w-2xl mx-auto">
<div class="mb-8">
<h1 class="text-2xl font-bold text-white mb-2">Einstellungen</h1>
<p class="text-gray-400">Verwalte deine Profil- und Email-Einstellungen</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 %}
<!-- Email Settings -->
<div class="card-bg rounded-xl border p-6 mb-6">
<h2 class="text-lg 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="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
Email-Benachrichtigungen
</h2>
<p class="text-sm text-gray-400 mb-6">
Konfiguriere die Email-Adressen für den Freigabe-Workflow. Wenn ein Post in "Bearbeitet" verschoben wird,
erhält die Kunden-Email einen Link zur Freigabe. Nach der Entscheidung wird die Creator-Email benachrichtigt.
</p>
<form id="emailSettingsForm" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Creator-Email
<span class="text-gray-500 font-normal">(erhält Benachrichtigungen über Entscheidungen)</span>
</label>
<input type="email"
name="creator_email"
id="creatorEmail"
value="{{ profile.creator_email or '' }}"
placeholder="creator@example.com"
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-brand-highlight">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Kunden-Email
<span class="text-gray-500 font-normal">(erhält Posts zur Freigabe)</span>
</label>
<input type="email"
name="customer_email"
id="customerEmail"
value="{{ profile.customer_email or '' }}"
placeholder="kunde@example.com"
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-brand-highlight">
</div>
<div class="pt-4">
<button type="submit"
id="saveEmailsBtn"
class="px-6 py-3 bg-brand-highlight hover:bg-brand-highlight/90 text-brand-bg-dark font-medium rounded-lg 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="M5 13l4 4L19 7"/>
</svg>
Speichern
</button>
</div>
</form>
</div>
<!-- LinkedIn Account Connection -->
<div class="card-bg rounded-xl border border-gray-700 p-6 mb-6">
<h2 class="text-xl font-semibold text-white mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-[#0A66C2]" 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>
LinkedIn-Konto verbinden
</h2>
{% if linkedin_account %}
<!-- Connected State -->
<div class="bg-green-900/20 border border-green-600 rounded-lg p-4 mb-4">
<div class="flex items-start gap-4">
{% if linkedin_account.linkedin_picture %}
<img src="{{ linkedin_account.linkedin_picture }}"
alt="{{ linkedin_account.linkedin_name }}"
class="w-12 h-12 rounded-full border-2 border-green-500">
{% endif %}
<div class="flex-1">
<p class="text-white font-medium flex items-center gap-2">
<svg class="w-4 h-4 text-green-400" 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>
{{ linkedin_account.linkedin_name }}
</p>
<p class="text-gray-400 text-sm mt-1">
Verbunden seit {{ linkedin_account.created_at.strftime('%d.%m.%Y um %H:%M') }} Uhr
</p>
{% if linkedin_account.last_used_at %}
<p class="text-gray-500 text-xs mt-1">
Zuletzt verwendet: {{ linkedin_account.last_used_at.strftime('%d.%m.%Y um %H:%M') }} Uhr
</p>
{% endif %}
</div>
</div>
</div>
{% if linkedin_account.last_error %}
<div class="bg-yellow-900/20 border border-yellow-600 rounded-lg p-4 mb-4">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-yellow-400 flex-shrink-0 mt-0.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>
<div>
<p class="text-yellow-200 font-medium">Verbindungsproblem</p>
<p class="text-yellow-200/80 text-sm mt-1">
Deine LinkedIn-Verbindung funktioniert möglicherweise nicht mehr. Bitte verbinde dein Konto erneut.
</p>
<p class="text-yellow-600 text-xs mt-2 font-mono">{{ linkedin_account.last_error }}</p>
</div>
</div>
</div>
{% else %}
<div class="bg-blue-900/20 border border-blue-600 rounded-lg p-4 mb-4">
<p class="text-blue-200 text-sm">
<strong>✨ Automatisches Posten aktiviert!</strong><br>
Geplante Posts werden automatisch auf deinem LinkedIn-Profil veröffentlicht.
</p>
</div>
{% endif %}
<button onclick="disconnectLinkedIn()"
class="px-6 py-3 bg-red-600 hover:bg-red-700 text-white rounded-lg 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="M6 18L18 6M6 6l12 12"/>
</svg>
Verbindung trennen
</button>
{% else %}
<!-- Not Connected State -->
<p class="text-gray-400 mb-4">
Verbinde dein LinkedIn-Konto, um Posts automatisch zu veröffentlichen.
Wenn dein Konto verbunden ist, werden geplante Posts direkt auf dein LinkedIn-Profil gepostet.
</p>
<div class="bg-brand-bg-light rounded-lg p-4 mb-4 border border-brand-bg-light">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-brand-highlight flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="text-sm text-gray-400">
<p class="font-medium text-white mb-2">Vorteile:</p>
<ul class="space-y-1">
<li>• Automatische Veröffentlichung zur geplanten Zeit</li>
<li>• Keine manuelle Arbeit mehr nötig</li>
<li>• Posts mit Bildern werden unterstützt</li>
<li>• Du bleibst im Workflow informiert</li>
</ul>
</div>
</div>
</div>
<a href="/settings/linkedin/connect"
class="inline-flex items-center gap-2 px-6 py-3 bg-[#0A66C2] hover:bg-[#004182] text-white rounded-lg transition-colors">
<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 verbinden
</a>
{% endif %}
</div>
<!-- Workflow Info -->
<div class="card-bg rounded-xl border p-6">
<h2 class="text-lg 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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
So funktioniert der Workflow
</h2>
<div class="space-y-4 text-sm text-gray-400">
<div class="flex gap-3">
<div class="w-8 h-8 rounded-full bg-yellow-600/20 text-yellow-400 flex items-center justify-center flex-shrink-0 font-semibold">1</div>
<div>
<p class="text-white font-medium">Vorschlag</p>
<p>Neue Posts werden hier erstellt und können bearbeitet werden.</p>
</div>
</div>
<div class="flex gap-3">
<div class="w-8 h-8 rounded-full bg-blue-600/20 text-blue-400 flex items-center justify-center flex-shrink-0 font-semibold">2</div>
<div>
<p class="text-white font-medium">Bearbeitet</p>
<p>Wenn ein Post hierher verschoben wird, erhält die Kunden-Email eine Freigabe-Anfrage.</p>
</div>
</div>
<div class="flex gap-3">
<div class="w-8 h-8 rounded-full bg-green-600/20 text-green-400 flex items-center justify-center flex-shrink-0 font-semibold">3</div>
<div>
<p class="text-white font-medium">Veröffentlicht</p>
<p>Nach Freigabe durch den Kunden landet der Post hier und ist bereit für LinkedIn.</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.getElementById('emailSettingsForm').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('saveEmailsBtn');
const originalHTML = btn.innerHTML;
btn.innerHTML = '<div class="w-4 h-4 border-2 border-brand-bg-dark border-t-transparent rounded-full animate-spin"></div> Speichern...';
btn.disabled = true;
try {
const formData = new FormData();
formData.append('creator_email', document.getElementById('creatorEmail').value);
formData.append('customer_email', document.getElementById('customerEmail').value);
const response = await fetch('/api/settings/emails', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('Fehler beim Speichern');
}
// Show success
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> Gespeichert!';
btn.classList.remove('bg-brand-highlight');
btn.classList.add('bg-green-600');
setTimeout(() => {
btn.innerHTML = originalHTML;
btn.classList.remove('bg-green-600');
btn.classList.add('bg-brand-highlight');
btn.disabled = false;
}, 2000);
} catch (error) {
console.error('Error saving settings:', error);
btn.innerHTML = 'Fehler!';
btn.classList.remove('bg-brand-highlight');
btn.classList.add('bg-red-600');
setTimeout(() => {
btn.innerHTML = originalHTML;
btn.classList.remove('bg-red-600');
btn.classList.add('bg-brand-highlight');
btn.disabled = false;
}, 2000);
}
});
// LinkedIn disconnect
async function disconnectLinkedIn() {
if (!confirm('LinkedIn-Verbindung wirklich trennen?\n\nPosts werden dann nicht mehr automatisch veröffentlicht und du erhältst wieder Email-Benachrichtigungen.')) {
return;
}
try {
const response = await fetch('/api/settings/linkedin/disconnect', {
method: 'POST'
});
if (response.ok) {
window.location.reload();
} else {
alert('Fehler beim Trennen der Verbindung. Bitte versuche es erneut.');
}
} catch (error) {
console.error('Error disconnecting LinkedIn:', error);
alert('Fehler: ' + error.message);
}
}
// Show success/error messages from URL params
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('success')) {
const successMsg = urlParams.get('success');
if (successMsg === 'linkedin_connected') {
// Show temporary success message
const successDiv = document.createElement('div');
successDiv.className = 'fixed top-4 right-4 bg-green-600 text-white px-6 py-3 rounded-lg shadow-lg z-50';
successDiv.textContent = '✓ LinkedIn-Konto erfolgreich verbunden!';
document.body.appendChild(successDiv);
setTimeout(() => successDiv.remove(), 3000);
// Clean URL
window.history.replaceState({}, '', window.location.pathname);
}
}
if (urlParams.has('error')) {
const errorMsg = urlParams.get('error');
const errorMessages = {
'linkedin_auth_failed': 'LinkedIn-Authentifizierung fehlgeschlagen',
'invalid_state': 'Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut.',
'token_exchange_failed': 'Token-Austausch fehlgeschlagen',
'userinfo_failed': 'Konnte LinkedIn-Profil nicht abrufen',
'connection_failed': 'Verbindung fehlgeschlagen. Bitte versuche es erneut.'
};
const errorDiv = document.createElement('div');
errorDiv.className = 'fixed top-4 right-4 bg-red-600 text-white px-6 py-3 rounded-lg shadow-lg z-50';
errorDiv.textContent = '✗ ' + (errorMessages[errorMsg] || 'Ein Fehler ist aufgetreten');
document.body.appendChild(errorDiv);
setTimeout(() => errorDiv.remove(), 5000);
// Clean URL
window.history.replaceState({}, '', window.location.pathname);
}
</script>
{% endblock %}

View File

@@ -21,14 +21,20 @@
<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">
<img src="{{ profile_picture }}" alt="{{ profile.display_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>
<span class="text-brand-bg-dark font-bold text-lg">{{ (profile.display_name or 'U')[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>
<h3 class="font-semibold text-white text-lg">{{ profile.display_name or session.linkedin_name or 'Unbekannt' }}</h3>
<p class="text-sm text-gray-400">
{% if session.account_type == 'ghostwriter' %}
Ghostwriter
{% else %}
{{ session.company_name or 'Kein Unternehmen' }}
{% endif %}
</p>
</div>
</div>
<div class="flex items-center gap-2">

View File

@@ -1,123 +1,35 @@
"""User authentication with Supabase LinkedIn OAuth."""
import re
"""User authentication with Supabase Auth.
Uses Supabase's built-in authentication for:
- Email/password signup and login
- LinkedIn OAuth
- Session management via JWT tokens
"""
import secrets
from typing import Optional
from uuid import UUID
from fastapi import Request, Response
from loguru import logger
from supabase import create_client
from src.config import settings
from src.database import db
# Session management
USER_SESSION_COOKIE = "linkedin_user_session"
# Session management - using Supabase JWT tokens stored in cookies
USER_SESSION_COOKIE = "sb_session" # Supabase session cookie
REFRESH_TOKEN_COOKIE = "sb_refresh_token"
SESSION_SECRET = settings.session_secret or secrets.token_hex(32)
# Supabase client for auth operations
_supabase_client = None
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
def get_supabase():
"""Get or create Supabase client."""
global _supabase_client
if _supabase_client is None:
_supabase_client = create_client(settings.supabase_url, settings.supabase_key)
return _supabase_client
class UserSession:
@@ -125,19 +37,47 @@ class UserSession:
def __init__(
self,
customer_id: str,
customer_name: str,
linkedin_vanity_name: str,
user_id: Optional[str] = None,
linkedin_vanity_name: str = "",
linkedin_name: Optional[str] = None,
linkedin_picture: Optional[str] = None,
email: Optional[str] = None
email: Optional[str] = None,
account_type: str = "ghostwriter",
company_id: Optional[str] = None,
onboarding_status: str = "completed",
company_name: Optional[str] = None,
display_name: Optional[str] = None
):
self.customer_id = customer_id
self.customer_name = customer_name
self.user_id = user_id
self.linkedin_vanity_name = linkedin_vanity_name
self.linkedin_name = linkedin_name
self.linkedin_picture = linkedin_picture
self.email = email
self.account_type = account_type
self.company_id = company_id
self.onboarding_status = onboarding_status
self.company_name = company_name
self.display_name = display_name or linkedin_name
@property
def is_onboarding_complete(self) -> bool:
"""Check if user has completed onboarding."""
return self.onboarding_status == "completed"
@property
def is_company_owner(self) -> bool:
"""Check if user is a company owner."""
return self.account_type == "company"
@property
def is_employee(self) -> bool:
"""Check if user is an employee."""
return self.account_type == "employee"
@property
def is_ghostwriter(self) -> bool:
"""Check if user is a ghostwriter."""
return self.account_type == "ghostwriter"
def to_cookie_value(self) -> str:
"""Serialize session to cookie value."""
@@ -145,12 +85,16 @@ class UserSession:
import hashlib
data = {
"customer_id": self.customer_id,
"customer_name": self.customer_name,
"user_id": self.user_id,
"linkedin_vanity_name": self.linkedin_vanity_name,
"linkedin_name": self.linkedin_name,
"linkedin_picture": self.linkedin_picture,
"email": self.email
"email": self.email,
"account_type": self.account_type,
"company_id": self.company_id,
"onboarding_status": self.onboarding_status,
"company_name": self.company_name,
"display_name": self.display_name
}
# Create signed cookie value
@@ -184,12 +128,16 @@ class UserSession:
data = json.loads(json_data)
return cls(
customer_id=data["customer_id"],
customer_name=data["customer_name"],
linkedin_vanity_name=data["linkedin_vanity_name"],
user_id=data.get("user_id"),
linkedin_vanity_name=data.get("linkedin_vanity_name", ""),
linkedin_name=data.get("linkedin_name"),
linkedin_picture=data.get("linkedin_picture"),
email=data.get("email")
email=data.get("email"),
account_type=data.get("account_type", "ghostwriter"),
company_id=data.get("company_id"),
onboarding_status=data.get("onboarding_status", "completed"),
company_name=data.get("company_name"),
display_name=data.get("display_name")
)
except Exception as e:
logger.error(f"Failed to parse session cookie: {e}")
@@ -198,146 +146,354 @@ class UserSession:
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)
# Try legacy cookie first (contains full session data including profile info)
legacy_cookie = request.cookies.get("linkedin_user_session")
if legacy_cookie:
session = UserSession.from_cookie_value(legacy_cookie)
if session:
return session
# Fallback to Supabase session validation
access_token = request.cookies.get(USER_SESSION_COOKIE)
if access_token:
try:
supabase = get_supabase()
user_response = supabase.auth.get_user(access_token)
if user_response and user_response.user:
return _create_session_from_supabase_user(user_response.user)
except Exception as e:
logger.debug(f"Could not validate Supabase session: {e}")
return None
def set_user_session(response: Response, session: UserSession) -> None:
"""Set user session cookie."""
async def get_user_session_async(request: Request) -> Optional[UserSession]:
"""Async version of get_user_session with profile lookup."""
session = get_user_session(request)
if session and session.user_id:
# Fetch additional profile data if needed
try:
user = await db.get_user(UUID(session.user_id))
if user:
session.onboarding_status = user.onboarding_status.value if hasattr(user.onboarding_status, 'value') else user.onboarding_status
session.account_type = user.account_type.value if hasattr(user.account_type, 'value') else user.account_type
session.company_id = str(user.company_id) if user.company_id else None
except Exception as e:
logger.warning(f"Could not fetch profile data: {e}")
return session
def _create_session_from_supabase_user(user) -> UserSession:
"""Create UserSession from Supabase user object."""
user_metadata = user.user_metadata or {}
return UserSession(
user_id=str(user.id),
linkedin_vanity_name=user_metadata.get("vanityName", ""),
linkedin_name=user_metadata.get("name"),
linkedin_picture=user_metadata.get("picture"),
email=user.email,
account_type=user_metadata.get("account_type", "ghostwriter"),
company_id=None, # Will be fetched from profile
onboarding_status="pending", # Will be fetched from profile
company_name=None,
display_name=user_metadata.get("display_name") or user_metadata.get("name")
)
def set_user_session(response: Response, session: UserSession, access_token: str = None, refresh_token: str = None) -> None:
"""Set user session cookies."""
if access_token:
response.set_cookie(
key=USER_SESSION_COOKIE,
value=access_token,
httponly=True,
max_age=60 * 60,
samesite="lax",
secure=True
)
if refresh_token:
response.set_cookie(
key=REFRESH_TOKEN_COOKIE,
value=refresh_token,
httponly=True,
max_age=60 * 60 * 24 * 7,
samesite="lax",
secure=True
)
# Also set legacy cookie for backwards compatibility
response.set_cookie(
key=USER_SESSION_COOKIE,
key="linkedin_user_session",
value=session.to_cookie_value(),
httponly=True,
max_age=60 * 60 * 24 * 7, # 7 days
max_age=60 * 60 * 24 * 7,
samesite="lax"
)
def clear_user_session(response: Response) -> None:
"""Clear user session cookie."""
"""Clear all session cookies."""
response.delete_cookie(USER_SESSION_COOKIE)
response.delete_cookie(REFRESH_TOKEN_COOKIE)
response.delete_cookie("linkedin_user_session")
async def handle_oauth_callback(
access_token: str,
refresh_token: Optional[str] = None
) -> Optional[UserSession]:
"""Handle OAuth callback from Supabase.
refresh_token: Optional[str] = None,
allow_registration: bool = True,
account_type: str = "ghostwriter"
) -> tuple[Optional[UserSession], Optional[str], Optional[str]]:
"""Handle OAuth callback from Supabase Auth.
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
Supabase Auth handles the user creation in auth.users automatically.
Our trigger creates the profile in the profiles table.
Returns UserSession if authorized, None if not.
Returns:
Tuple of (UserSession, access_token, refresh_token) if authorized,
(None, None, None) if not.
"""
from supabase import create_client
from src.database.models import Profile, AccountType, OnboardingStatus
try:
# Create a new client with the user's access token
supabase = create_client(settings.supabase_url, settings.supabase_key)
supabase = get_supabase()
# 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
return None, None, 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"=== OAUTH CALLBACK ===")
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)
vanity_name = user_metadata.get("vanityName")
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
# Check if profile exists (should be created by trigger)
profile = await db.get_profile(UUID(str(user.id)))
# 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}")
if not profile:
logger.info(f"Profile not found for user {user.id}, creating...")
profile = Profile(
account_type=AccountType(account_type),
onboarding_status=OnboardingStatus.PENDING,
display_name=name
)
profile = await db.create_profile(UUID(str(user.id)), profile)
# 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}")
# Get company name if applicable
company_name = None
if profile.company_id:
company = await db.get_company(profile.company_id)
if company:
company_name = company.name
# 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 "",
session = UserSession(
user_id=str(user.id),
linkedin_vanity_name=vanity_name or "",
linkedin_name=name,
linkedin_picture=picture,
email=email
email=email,
account_type=profile.account_type.value if hasattr(profile.account_type, 'value') else profile.account_type,
company_id=str(profile.company_id) if profile.company_id else None,
onboarding_status=profile.onboarding_status.value if hasattr(profile.onboarding_status, 'value') else profile.onboarding_status,
company_name=company_name,
display_name=profile.display_name or name
)
return session, access_token, refresh_token
except Exception as e:
logger.exception(f"OAuth callback error: {e}")
return None
return None, None, None
async def handle_email_password_login(email: str, password: str) -> tuple[Optional[UserSession], Optional[str], Optional[str]]:
"""Handle email/password login via Supabase Auth."""
try:
supabase = get_supabase()
auth_response = supabase.auth.sign_in_with_password({
"email": email.lower(),
"password": password
})
if not auth_response or not auth_response.user:
logger.warning(f"Failed login attempt for: {email}")
return None, None, None
user = auth_response.user
session = auth_response.session
logger.info(f"Successful email/password login for: {email}")
# Get profile data
profile = await db.get_profile(UUID(str(user.id)))
if not profile:
logger.error(f"No profile found for user {user.id}")
return None, None, None
# Get company name if applicable
company_name = None
if profile.company_id:
company = await db.get_company(profile.company_id)
if company:
company_name = company.name
user_session = UserSession(
user_id=str(user.id),
linkedin_vanity_name="",
linkedin_name=profile.display_name,
linkedin_picture=None,
email=user.email,
account_type=profile.account_type.value if hasattr(profile.account_type, 'value') else profile.account_type,
company_id=str(profile.company_id) if profile.company_id else None,
onboarding_status=profile.onboarding_status.value if hasattr(profile.onboarding_status, 'value') else profile.onboarding_status,
company_name=company_name,
display_name=profile.display_name
)
return user_session, session.access_token, session.refresh_token
except Exception as e:
logger.warning(f"Email/password login error: {e}")
return None, None, None
async def create_email_password_user(
email: str,
password: str,
account_type: str = "ghostwriter",
company_id: Optional[str] = None,
display_name: Optional[str] = None
) -> tuple[Optional[UserSession], Optional[str], Optional[str], Optional[str]]:
"""Create a new user with email/password authentication via Supabase Auth."""
from src.database.models import Profile, AccountType, OnboardingStatus
from src.web.user.password_auth import validate_password_strength
try:
# Validate password
is_valid, error_msg = validate_password_strength(password)
if not is_valid:
logger.warning(f"Weak password for registration: {error_msg}")
return None, None, None, error_msg
supabase = get_supabase()
# Sign up via Supabase Auth
auth_response = supabase.auth.sign_up({
"email": email.lower(),
"password": password,
"options": {
"data": {
"account_type": account_type,
"display_name": display_name
}
}
})
if not auth_response or not auth_response.user:
logger.warning(f"Failed to create user: {email}")
return None, None, None, "Registrierung fehlgeschlagen"
user = auth_response.user
session = auth_response.session
logger.info(f"Created new user via Supabase Auth: {user.id}")
# Wait a moment for the trigger to create the profile
import asyncio
await asyncio.sleep(0.5)
# Update profile with additional data
profile = await db.get_profile(UUID(str(user.id)))
if not profile:
logger.warning(f"Profile not created by trigger, creating manually")
acc_type = AccountType(account_type)
profile = Profile(
account_type=acc_type,
display_name=display_name,
onboarding_status=OnboardingStatus.PENDING
)
if company_id:
profile.company_id = UUID(company_id)
profile = await db.create_profile(UUID(str(user.id)), profile)
elif company_id or display_name:
updates = {}
if company_id:
updates["company_id"] = company_id
if display_name:
updates["display_name"] = display_name
if updates:
profile = await db.update_profile(UUID(str(user.id)), updates)
# Get company name if applicable
company_name = None
if profile.company_id:
company = await db.get_company(profile.company_id)
if company:
company_name = company.name
user_session = UserSession(
user_id=str(user.id),
linkedin_vanity_name="",
linkedin_name=display_name,
linkedin_picture=None,
email=user.email,
account_type=account_type,
company_id=company_id,
onboarding_status="pending",
company_name=company_name,
display_name=display_name
)
access_token = session.access_token if session else None
refresh_token = session.refresh_token if session else None
return user_session, access_token, refresh_token, None
except Exception as e:
error_str = str(e)
logger.exception(f"Error creating email/password user: {e}")
if "already registered" in error_str.lower() or "already exists" in error_str.lower():
return None, None, None, "Diese E-Mail-Adresse ist bereits registriert"
return None, None, None, "Registrierung fehlgeschlagen"
async def sign_out(access_token: Optional[str] = None) -> bool:
"""Sign out user from Supabase Auth."""
try:
if access_token:
supabase = get_supabase()
supabase.auth.sign_out()
return True
except Exception as e:
logger.warning(f"Error signing out: {e}")
return False
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
"""
"""Generate Supabase OAuth login URL for LinkedIn."""
from urllib.parse import urlencode
# Supabase OAuth endpoint
base_url = f"{settings.supabase_url}/auth/v1/authorize"
params = {
@@ -346,3 +502,18 @@ def get_supabase_login_url(redirect_to: str) -> str:
}
return f"{base_url}?{urlencode(params)}"
async def refresh_session(refresh_token: str) -> tuple[Optional[str], Optional[str]]:
"""Refresh the user's session using a refresh token."""
try:
supabase = get_supabase()
response = supabase.auth.refresh_session(refresh_token)
if response and response.session:
return response.session.access_token, response.session.refresh_token
return None, None
except Exception as e:
logger.warning(f"Error refreshing session: {e}")
return None, None

View File

@@ -0,0 +1,141 @@
"""Password authentication utilities."""
import secrets
import hashlib
import hmac
from datetime import datetime, timedelta
from typing import Optional, Tuple
import bcrypt
from loguru import logger
def hash_password(password: str) -> str:
"""Hash a password using bcrypt.
Args:
password: Plain text password
Returns:
Hashed password string
"""
# bcrypt automatically handles salting
salt = bcrypt.gensalt(rounds=12)
hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
return hashed.decode('utf-8')
def verify_password(password: str, password_hash: str) -> bool:
"""Verify a password against its hash.
Args:
password: Plain text password to verify
password_hash: Stored hash to compare against
Returns:
True if password matches, False otherwise
"""
try:
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
except Exception as e:
logger.error(f"Password verification error: {e}")
return False
def generate_verification_token() -> str:
"""Generate a secure email verification token.
Returns:
URL-safe token string (64 characters)
"""
return secrets.token_urlsafe(48)
def generate_invitation_token() -> str:
"""Generate a secure invitation token.
Returns:
URL-safe token string (32 characters)
"""
return secrets.token_urlsafe(24)
def generate_password_reset_token() -> str:
"""Generate a secure password reset token.
Returns:
URL-safe token string (64 characters)
"""
return secrets.token_urlsafe(48)
def get_verification_expiry(hours: int = 24) -> datetime:
"""Get expiry datetime for email verification token.
Args:
hours: Number of hours until expiry (default 24)
Returns:
Datetime when token expires (timezone-aware UTC)
"""
from datetime import timezone
return datetime.now(timezone.utc) + timedelta(hours=hours)
def get_invitation_expiry(days: int = 7) -> datetime:
"""Get expiry datetime for invitation token.
Args:
days: Number of days until expiry (default 7)
Returns:
Datetime when token expires (timezone-aware UTC)
"""
from datetime import timezone
return datetime.now(timezone.utc) + timedelta(days=days)
def is_token_expired(expires_at: datetime) -> bool:
"""Check if a token has expired.
Args:
expires_at: Expiry datetime of the token
Returns:
True if expired, False otherwise
"""
from datetime import timezone
now = datetime.now(timezone.utc)
# Handle both timezone-aware and naive datetimes
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
return now > expires_at
def validate_password_strength(password: str) -> Tuple[bool, Optional[str]]:
"""Validate password meets minimum requirements.
Requirements:
- Minimum 8 characters
- At least one uppercase letter
- At least one lowercase letter
- At least one digit
Args:
password: Password to validate
Returns:
Tuple of (is_valid, error_message)
"""
if len(password) < 8:
return False, "Passwort muss mindestens 8 Zeichen lang sein"
if not any(c.isupper() for c in password):
return False, "Passwort muss mindestens einen Großbuchstaben enthalten"
if not any(c.islower() for c in password):
return False, "Passwort muss mindestens einen Kleinbuchstaben enthalten"
if not any(c.isdigit() for c in password):
return False, "Passwort muss mindestens eine Zahl enthalten"
return True, None

File diff suppressed because it is too large Load Diff