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:
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 %}
|
||||
Reference in New Issue
Block a user