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

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

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

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

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

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

View File

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