nordabiz/templates/admin/social_publisher_form.html
Maciej Pienczyn 4a033f0d81 feat: Add Social Media Publisher module (MVP)
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>
2026-02-18 12:08:29 +01:00

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 &#8599;
</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 %}