Admin panel module for publishing posts on NORDA chamber Facebook page. Includes AI content generation (Gemini), post workflow (draft/approved/ scheduled/published), Facebook Graph API publishing, and engagement tracking. New: migration 070, SocialPost/SocialMediaConfig models, publisher service, admin routes with AJAX, 3 templates (list/form/settings). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
431 lines
16 KiB
HTML
431 lines
16 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}{% if post %}Edycja Posta #{{ post.id }}{% else %}Nowy Post{% endif %} - Social Publisher{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
.admin-header {
|
|
margin-bottom: var(--spacing-xl);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.admin-header h1 {
|
|
font-size: var(--font-size-3xl);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.form-section {
|
|
background: var(--surface);
|
|
padding: var(--spacing-xl);
|
|
border-radius: var(--radius-lg);
|
|
box-shadow: var(--shadow);
|
|
max-width: 900px;
|
|
}
|
|
|
|
.form-group {
|
|
margin-bottom: var(--spacing-lg);
|
|
}
|
|
|
|
.form-group label {
|
|
display: block;
|
|
margin-bottom: var(--spacing-xs);
|
|
font-weight: 500;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.form-group input[type="text"],
|
|
.form-group input[type="datetime-local"],
|
|
.form-group select,
|
|
.form-group textarea {
|
|
width: 100%;
|
|
padding: var(--spacing-sm) var(--spacing-md);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-md);
|
|
font-size: var(--font-size-base);
|
|
font-family: inherit;
|
|
}
|
|
|
|
.form-group textarea {
|
|
min-height: 120px;
|
|
resize: vertical;
|
|
}
|
|
|
|
.form-group textarea.content-editor {
|
|
min-height: 250px;
|
|
}
|
|
|
|
.form-hint {
|
|
font-size: var(--font-size-sm);
|
|
color: var(--text-secondary);
|
|
margin-top: var(--spacing-xs);
|
|
}
|
|
|
|
.form-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: var(--spacing-lg);
|
|
}
|
|
|
|
.section-title {
|
|
font-size: var(--font-size-lg);
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
margin-top: var(--spacing-xl);
|
|
margin-bottom: var(--spacing-md);
|
|
padding-bottom: var(--spacing-xs);
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.btn-group {
|
|
display: flex;
|
|
gap: var(--spacing-md);
|
|
margin-top: var(--spacing-xl);
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.btn-generate {
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
border: none;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.btn-generate:hover {
|
|
opacity: 0.9;
|
|
}
|
|
|
|
.btn-generate:disabled {
|
|
opacity: 0.6;
|
|
cursor: wait;
|
|
}
|
|
|
|
.status-info {
|
|
background: var(--background);
|
|
padding: var(--spacing-md);
|
|
border-radius: var(--radius);
|
|
margin-bottom: var(--spacing-lg);
|
|
font-size: var(--font-size-sm);
|
|
}
|
|
|
|
.status-info strong {
|
|
color: var(--primary);
|
|
}
|
|
|
|
.engagement-box {
|
|
background: var(--background);
|
|
padding: var(--spacing-lg);
|
|
border-radius: var(--radius);
|
|
margin-top: var(--spacing-lg);
|
|
}
|
|
|
|
.engagement-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
|
gap: var(--spacing-md);
|
|
margin-bottom: var(--spacing-md);
|
|
}
|
|
|
|
.engagement-item {
|
|
text-align: center;
|
|
}
|
|
|
|
.engagement-item .value {
|
|
font-size: var(--font-size-2xl);
|
|
font-weight: 700;
|
|
color: var(--primary);
|
|
}
|
|
|
|
.engagement-item .label {
|
|
font-size: var(--font-size-sm);
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.d-none {
|
|
display: none !important;
|
|
}
|
|
|
|
.char-counter {
|
|
font-size: var(--font-size-xs);
|
|
color: var(--text-secondary);
|
|
text-align: right;
|
|
margin-top: var(--spacing-xs);
|
|
}
|
|
|
|
.char-counter.warning { color: var(--warning); }
|
|
.char-counter.over { color: var(--error); }
|
|
|
|
.fb-link {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: var(--spacing-xs);
|
|
color: #1877f2;
|
|
font-weight: 500;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.fb-link:hover {
|
|
text-decoration: underline;
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container">
|
|
<div class="admin-header">
|
|
<h1>{% if post %}Edycja Posta #{{ post.id }}{% else %}Nowy Post{% endif %}</h1>
|
|
<a href="{{ url_for('admin.social_publisher_list') }}" class="btn btn-secondary">Powrot do listy</a>
|
|
</div>
|
|
|
|
{% if post %}
|
|
<div class="status-info">
|
|
<strong>Status:</strong>
|
|
{% if post.status == 'draft' %}Szkic
|
|
{% elif post.status == 'approved' %}Zatwierdzony
|
|
{% elif post.status == 'scheduled' %}Zaplanowany
|
|
{% elif post.status == 'published' %}Opublikowany
|
|
{% elif post.status == 'failed' %}Błąd
|
|
{% else %}{{ post.status }}{% endif %}
|
|
{% if post.creator %}
|
|
| <strong>Autor:</strong> {{ post.creator.name }}
|
|
{% endif %}
|
|
{% if post.ai_model %}
|
|
| <strong>Model AI:</strong> {{ post.ai_model }}
|
|
{% endif %}
|
|
{% if post.published_at %}
|
|
| <strong>Opublikowano:</strong> {{ post.published_at.strftime('%Y-%m-%d %H:%M') }}
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="form-section">
|
|
<form method="POST" id="postForm">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
|
|
<!-- Typ posta -->
|
|
<div class="form-group">
|
|
<label for="post_type">Typ posta</label>
|
|
<select id="post_type" name="post_type" required>
|
|
<option value="">-- Wybierz typ --</option>
|
|
{% for key, label in post_types.items() %}
|
|
<option value="{{ key }}" {% if post and post.post_type == key %}selected{% endif %}>{{ label }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Firma (widoczna dla member_spotlight) -->
|
|
<div class="form-group" id="company-field">
|
|
<label for="company_id">Firma</label>
|
|
<select id="company_id" name="company_id">
|
|
<option value="">-- Wybierz firmę --</option>
|
|
{% for company in companies %}
|
|
<option value="{{ company.id }}" {% if post and post.company_id == company.id %}selected{% endif %}>{{ company.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
<p class="form-hint">Firma, którą chcesz zaprezentować w poście</p>
|
|
</div>
|
|
|
|
<!-- Wydarzenie (widoczne dla event_*) -->
|
|
<div class="form-group" id="event-field">
|
|
<label for="event_id">Wydarzenie</label>
|
|
<select id="event_id" name="event_id">
|
|
<option value="">-- Wybierz wydarzenie --</option>
|
|
{% for event in events %}
|
|
<option value="{{ event.id }}" {% if post and post.event_id == event.id %}selected{% endif %}>{{ event.title }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
<p class="form-hint">Wydarzenie powiązane z postem</p>
|
|
</div>
|
|
|
|
<!-- Kontekst dodatkowy (dla regional_news/chamber_news) -->
|
|
<div class="form-group" id="custom-context-field">
|
|
<h3 class="section-title">Kontekst dla AI</h3>
|
|
<div class="form-group">
|
|
<label for="custom_topic">Temat</label>
|
|
<input type="text" id="custom_topic" name="custom_topic" placeholder="np. Nowa inwestycja w porcie Gdynia">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="custom_details">Szczegoly</label>
|
|
<textarea id="custom_details" name="custom_details" rows="3" placeholder="Dodatkowe informacje, które AI powinno uwzględnić..."></textarea>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="custom_facts">Fakty/dane</label>
|
|
<input type="text" id="custom_facts" name="custom_facts" placeholder="np. Wartosc: 50 mln PLN, termin: Q3 2026">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="custom_source">Zrodlo</label>
|
|
<input type="text" id="custom_source" name="custom_source" placeholder="np. Portal Morski, komunikat prasowy">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Przycisk generowania AI -->
|
|
<div class="form-group" style="text-align: right;">
|
|
<button type="button" id="btn-generate-ai" class="btn btn-generate">
|
|
Generuj AI
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Tresc -->
|
|
<div class="form-group">
|
|
<label for="content">Tresc posta</label>
|
|
<textarea id="content" name="content" class="content-editor" required
|
|
placeholder="Wpisz tresc posta lub wygeneruj za pomoca AI...">{{ post.content if post else '' }}</textarea>
|
|
<div class="char-counter" id="content-counter">0 znaków</div>
|
|
</div>
|
|
|
|
<!-- Hashtagi -->
|
|
<div class="form-group">
|
|
<label for="hashtags">Hashtagi</label>
|
|
<input type="text" id="hashtags" name="hashtags"
|
|
value="{{ post.hashtags if post else '' }}"
|
|
placeholder="#NordaBiznes #Wejherowo #Pomorze">
|
|
<p class="form-hint">Hashtagi oddzielone spacjami</p>
|
|
</div>
|
|
|
|
<!-- Akcje -->
|
|
<div class="btn-group">
|
|
<a href="{{ url_for('admin.social_publisher_list') }}" class="btn btn-secondary">Anuluj</a>
|
|
|
|
{% if post %}
|
|
<button type="submit" name="action" value="save" class="btn btn-primary">Zapisz zmiany</button>
|
|
{% if post.status == 'draft' %}
|
|
<button type="submit" name="action" value="approve" class="btn btn-info" style="background: #0ea5e9; color: white; border: none;">Zatwierdź</button>
|
|
{% endif %}
|
|
{% if post.status in ['draft', 'approved'] %}
|
|
<button type="submit" name="action" value="publish" class="btn btn-success">Publikuj teraz</button>
|
|
{% endif %}
|
|
{% if post.status != 'published' %}
|
|
<button type="submit" name="action" value="delete" class="btn btn-error"
|
|
onclick="return confirm('Czy na pewno chcesz usunąć ten post?');">Usuń</button>
|
|
{% endif %}
|
|
{% else %}
|
|
<button type="submit" name="action" value="draft" class="btn btn-secondary">Zapisz szkic</button>
|
|
<button type="submit" name="action" value="publish" class="btn btn-success">Publikuj teraz</button>
|
|
{% endif %}
|
|
</div>
|
|
</form>
|
|
|
|
{% if post and post.status == 'published' %}
|
|
<!-- Engagement metrics -->
|
|
<div class="engagement-box">
|
|
<h3 class="section-title" style="margin-top: 0;">Engagement</h3>
|
|
<div class="engagement-grid">
|
|
<div class="engagement-item">
|
|
<div class="value">{{ post.engagement_likes or 0 }}</div>
|
|
<div class="label">Polubienia</div>
|
|
</div>
|
|
<div class="engagement-item">
|
|
<div class="value">{{ post.engagement_comments or 0 }}</div>
|
|
<div class="label">Komentarze</div>
|
|
</div>
|
|
<div class="engagement-item">
|
|
<div class="value">{{ post.engagement_shares or 0 }}</div>
|
|
<div class="label">Udostepnienia</div>
|
|
</div>
|
|
<div class="engagement-item">
|
|
<div class="value">{{ post.engagement_reach or 0 }}</div>
|
|
<div class="label">Zasieg</div>
|
|
</div>
|
|
</div>
|
|
<div style="display: flex; gap: var(--spacing-md); align-items: center; flex-wrap: wrap;">
|
|
<form method="POST" action="{{ url_for('admin.social_publisher_refresh_engagement', post_id=post.id) }}" style="display:inline;">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
<button type="submit" class="btn btn-secondary btn-small">Odswiez engagement</button>
|
|
</form>
|
|
{% if post.meta_post_id %}
|
|
<a href="https://www.facebook.com/{{ post.meta_post_id }}" target="_blank" class="fb-link">
|
|
Zobacz post na Facebook ↗
|
|
</a>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
// AI generation
|
|
document.getElementById('btn-generate-ai')?.addEventListener('click', async function() {
|
|
const postType = document.getElementById('post_type').value;
|
|
const companyId = document.getElementById('company_id')?.value;
|
|
const eventId = document.getElementById('event_id')?.value;
|
|
|
|
if (!postType) {
|
|
alert('Wybierz typ posta przed generowaniem.');
|
|
return;
|
|
}
|
|
|
|
const customContext = {};
|
|
const topicEl = document.getElementById('custom_topic');
|
|
if (topicEl) customContext.topic = topicEl.value;
|
|
const detailsEl = document.getElementById('custom_details');
|
|
if (detailsEl) customContext.details = detailsEl.value;
|
|
const factsEl = document.getElementById('custom_facts');
|
|
if (factsEl) customContext.facts = factsEl.value;
|
|
const sourceEl = document.getElementById('custom_source');
|
|
if (sourceEl) customContext.source = sourceEl.value;
|
|
|
|
this.disabled = true;
|
|
this.textContent = 'Generowanie...';
|
|
|
|
try {
|
|
const resp = await fetch("{{ url_for('admin.social_publisher_generate') }}", {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': document.querySelector('[name=csrf_token]')?.value || ''
|
|
},
|
|
body: JSON.stringify({
|
|
post_type: postType,
|
|
company_id: companyId || null,
|
|
event_id: eventId || null,
|
|
custom_context: customContext
|
|
})
|
|
});
|
|
const data = await resp.json();
|
|
if (data.success) {
|
|
document.getElementById('content').value = data.content;
|
|
if (data.hashtags) {
|
|
document.getElementById('hashtags').value = data.hashtags;
|
|
}
|
|
updateContentCounter();
|
|
} else {
|
|
alert('Błąd generowania: ' + (data.error || 'Nieznany błąd'));
|
|
}
|
|
} catch (err) {
|
|
alert('Błąd połączenia: ' + err.message);
|
|
} finally {
|
|
this.disabled = false;
|
|
this.textContent = 'Generuj AI';
|
|
}
|
|
});
|
|
|
|
// Show/hide context fields based on post type
|
|
document.getElementById('post_type')?.addEventListener('change', function() {
|
|
const type = this.value;
|
|
document.getElementById('company-field')?.classList.toggle('d-none', type !== 'member_spotlight');
|
|
document.getElementById('event-field')?.classList.toggle('d-none', !type.startsWith('event_'));
|
|
document.getElementById('custom-context-field')?.classList.toggle('d-none',
|
|
!['regional_news', 'chamber_news'].includes(type));
|
|
});
|
|
// Trigger on load
|
|
document.getElementById('post_type')?.dispatchEvent(new Event('change'));
|
|
|
|
// Character counter
|
|
function updateContentCounter() {
|
|
const content = document.getElementById('content');
|
|
const counter = document.getElementById('content-counter');
|
|
if (content && counter) {
|
|
const len = content.value.length;
|
|
counter.textContent = len + ' znaków';
|
|
counter.classList.remove('warning', 'over');
|
|
if (len > 2000) counter.classList.add('over');
|
|
else if (len > 1500) counter.classList.add('warning');
|
|
}
|
|
}
|
|
document.getElementById('content')?.addEventListener('input', updateContentCounter);
|
|
updateContentCounter();
|
|
{% endblock %}
|