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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
|
||||
304
src/web/templates/admin/license_keys.html
Normal file
304
src/web/templates/admin/license_keys.html
Normal 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 %}
|
||||
@@ -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
@@ -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 %}
|
||||
@@ -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 %}
|
||||
436
src/web/templates/admin/statistics.html
Normal file
436
src/web/templates/admin/statistics.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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);
|
||||
|
||||
260
src/web/templates/privacy_policy.html
Normal file
260
src/web/templates/privacy_policy.html
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
185
src/web/templates/user/company/accounts.html
Normal file
185
src/web/templates/user/company/accounts.html
Normal 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 %}
|
||||
214
src/web/templates/user/company_accounts.html
Normal file
214
src/web/templates/user/company_accounts.html
Normal 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 %}
|
||||
114
src/web/templates/user/company_base.html
Normal file
114
src/web/templates/user/company_base.html
Normal 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>
|
||||
668
src/web/templates/user/company_calendar.html
Normal file
668
src/web/templates/user/company_calendar.html
Normal 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 %}
|
||||
180
src/web/templates/user/company_dashboard.html
Normal file
180
src/web/templates/user/company_dashboard.html
Normal 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 %}
|
||||
195
src/web/templates/user/company_manage.html
Normal file
195
src/web/templates/user/company_manage.html
Normal 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 %}
|
||||
979
src/web/templates/user/company_manage_create.html
Normal file
979
src/web/templates/user/company_manage_create.html
Normal 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 %}
|
||||
393
src/web/templates/user/company_manage_post_detail.html
Normal file
393
src/web/templates/user/company_manage_post_detail.html
Normal 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 %}
|
||||
286
src/web/templates/user/company_manage_posts.html
Normal file
286
src/web/templates/user/company_manage_posts.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
260
src/web/templates/user/company_strategy.html
Normal file
260
src/web/templates/user/company_strategy.html
Normal 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
171
src/web/templates/user/email_action_result.html
Normal file
171
src/web/templates/user/email_action_result.html
Normal 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>
|
||||
137
src/web/templates/user/employee_base.html
Normal file
137
src/web/templates/user/employee_base.html
Normal 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>
|
||||
147
src/web/templates/user/employee_dashboard.html
Normal file
147
src/web/templates/user/employee_dashboard.html
Normal 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 %}
|
||||
139
src/web/templates/user/employee_strategy.html
Normal file
139
src/web/templates/user/employee_strategy.html
Normal 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 %}
|
||||
151
src/web/templates/user/invite_accept.html
Normal file
151
src/web/templates/user/invite_accept.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
85
src/web/templates/user/onboarding/base.html
Normal file
85
src/web/templates/user/onboarding/base.html
Normal 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>
|
||||
193
src/web/templates/user/onboarding/categorize.html
Normal file
193
src/web/templates/user/onboarding/categorize.html
Normal 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 %}
|
||||
77
src/web/templates/user/onboarding/company.html
Normal file
77
src/web/templates/user/onboarding/company.html
Normal 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 %}
|
||||
104
src/web/templates/user/onboarding/complete.html
Normal file
104
src/web/templates/user/onboarding/complete.html
Normal 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 %}
|
||||
229
src/web/templates/user/onboarding/post_types.html
Normal file
229
src/web/templates/user/onboarding/post_types.html
Normal 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 %}
|
||||
226
src/web/templates/user/onboarding/posts.html
Normal file
226
src/web/templates/user/onboarding/posts.html
Normal 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 %}
|
||||
130
src/web/templates/user/onboarding/profile.html
Normal file
130
src/web/templates/user/onboarding/profile.html
Normal 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 %}
|
||||
93
src/web/templates/user/onboarding/profile_employee.html
Normal file
93
src/web/templates/user/onboarding/profile_employee.html
Normal 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 %}
|
||||
277
src/web/templates/user/onboarding/strategy.html
Normal file
277
src/web/templates/user/onboarding/strategy.html
Normal 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
352
src/web/templates/user/post_types.html
Normal file
352
src/web/templates/user/post_types.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
97
src/web/templates/user/register.html
Normal file
97
src/web/templates/user/register.html
Normal 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 →
|
||||
</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 →
|
||||
</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>
|
||||
160
src/web/templates/user/register_company.html
Normal file
160
src/web/templates/user/register_company.html
Normal 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">← 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>
|
||||
126
src/web/templates/user/register_ghostwriter.html
Normal file
126
src/web/templates/user/register_ghostwriter.html
Normal 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">← 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>
|
||||
@@ -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>
|
||||
|
||||
316
src/web/templates/user/settings.html
Normal file
316
src/web/templates/user/settings.html
Normal 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 %}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user