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

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