nordabiz/templates/admin/zopk_news.html
Maciej Pienczyn 110d971dca
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
feat: migrate prod docs to OVH VPS + UTC→Warsaw timezone in all templates
Production moved from on-prem VM 249 (10.22.68.249) to OVH VPS
(57.128.200.27, inpi-vps-waw01). Updated ALL documentation, slash
commands, memory files, architecture docs, and deploy procedures.

Added |local_time Jinja filter (UTC→Europe/Warsaw) and converted
155 .strftime() calls across 71 templates so timestamps display
in Polish timezone regardless of server timezone.

Also includes: created_by_id tracking, abort import fix, ICS
calendar fix for missing end times, Pros Poland data cleanup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:41:53 +02:00

1093 lines
40 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block title %}ZOP Kaszubia Newsy - Panel Admina{% endblock %}
{% block extra_css %}
<style>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xl);
}
.page-header h1 {
font-size: var(--font-size-2xl);
}
.filters {
display: flex;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
flex-wrap: wrap;
align-items: center;
}
.filter-btn {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--surface);
text-decoration: none;
color: var(--text-secondary);
font-size: var(--font-size-sm);
transition: var(--transition);
}
.filter-btn:hover {
background: var(--background);
color: var(--text-primary);
}
.filter-btn.active {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.news-table-wrapper {
overflow-x: auto;
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
}
.news-table {
width: 100%;
min-width: 900px;
}
.news-table th,
.news-table td {
padding: var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.news-table th {
background: var(--background);
font-weight: 600;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.news-table tr:hover {
background: var(--background);
}
.news-title {
max-width: 400px;
}
.news-title a {
color: var(--text-primary);
text-decoration: none;
font-weight: 500;
}
.news-title a:hover {
color: var(--primary);
}
.news-title small {
display: block;
color: var(--text-secondary);
font-size: var(--font-size-xs);
margin-top: 2px;
}
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
}
.status-pending { background: #fef3c7; color: #92400e; }
.status-approved { background: #dcfce7; color: #166534; }
.status-rejected { background: #fee2e2; color: #991b1b; }
.source-badge {
display: inline-block;
padding: 2px 6px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
background: #f3f4f6;
color: #374151;
}
.action-btn {
padding: 4px 8px;
font-size: var(--font-size-xs);
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
transition: var(--transition);
}
.action-btn.approve {
background: #28a745;
color: white;
}
.action-btn.reject {
background: #dc3545;
color: white;
}
.action-btn:hover {
opacity: 0.8;
}
.pagination {
display: flex;
justify-content: center;
gap: var(--spacing-sm);
margin-top: var(--spacing-xl);
}
.pagination a,
.pagination span {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
text-decoration: none;
font-weight: 500;
}
.pagination a {
background: var(--surface);
color: var(--text-primary);
border: 1px solid var(--border);
}
.pagination a:hover {
background: var(--background);
}
.pagination span.current {
background: var(--primary);
color: white;
}
.empty-state {
text-align: center;
padding: var(--spacing-2xl);
color: var(--text-secondary);
background: var(--surface);
border-radius: var(--radius-lg);
}
/* AI Stars Rating */
.ai-stars {
display: inline-flex;
gap: 1px;
font-size: 12px;
}
.ai-stars .star-filled { color: #f59e0b; }
.ai-stars .star-empty { color: #d1d5db; }
.ai-score-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
}
.ai-score-badge.score-5 { background: #dcfce7; color: #166534; }
.ai-score-badge.score-4 { background: #d1fae5; color: #047857; }
.ai-score-badge.score-3 { background: #fef3c7; color: #92400e; }
.ai-score-badge.score-2 { background: #fed7aa; color: #c2410c; }
.ai-score-badge.score-1 { background: #fee2e2; color: #991b1b; }
.ai-score-badge.score-none { background: #f3f4f6; color: #6b7280; }
/* Sortable Headers */
.sortable-header {
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.sortable-header a {
color: var(--text-secondary);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 4px;
}
.sortable-header a:hover {
color: var(--primary);
}
.sortable-header.active a {
color: var(--primary);
font-weight: 700;
}
.sort-icon {
font-size: 10px;
opacity: 0.5;
}
.sortable-header.active .sort-icon {
opacity: 1;
}
/* Sort Controls */
.sort-controls {
display: flex;
gap: var(--spacing-md);
align-items: center;
margin-left: auto;
padding-left: var(--spacing-lg);
border-left: 1px solid var(--border);
}
.sort-controls select {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--surface);
font-size: var(--font-size-sm);
color: var(--text-primary);
}
/* Star filter styling */
.star-filter .star-icon {
font-size: 10px;
letter-spacing: -1px;
}
.star-filter.active .star-icon {
color: #f59e0b;
}
/* Bulk actions */
.bulk-actions {
background: var(--surface);
padding: var(--spacing-md);
border-radius: var(--radius);
border: 1px solid var(--border);
}
/* Mass reject modal */
.mass-reject-options {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
margin: var(--spacing-lg) 0;
}
.mass-reject-option {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: var(--transition);
}
.mass-reject-option:hover {
background: var(--background);
}
.mass-reject-option.selected {
background: #fee2e2;
border-color: #dc3545;
}
.mass-reject-option input[type="checkbox"] {
accent-color: #dc3545;
}
.mass-reject-stars {
color: #f59e0b;
font-size: 14px;
}
.mass-reject-count {
margin-left: auto;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
@media (max-width: 768px) {
.news-table {
display: block;
overflow-x: auto;
}
.sort-controls {
margin-left: 0;
padding-left: 0;
border-left: none;
width: 100%;
margin-top: var(--spacing-md);
}
.filters {
flex-direction: column;
align-items: flex-start;
}
.bulk-actions {
flex-wrap: wrap;
}
}
</style>
{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>Zarządzanie newsami ZOP Kaszubia</h1>
<p class="text-muted">{{ total }} artykułów</p>
</div>
<a href="{{ url_for('admin.admin_zopk') }}" class="btn btn-secondary">Powrót do dashboardu</a>
</div>
<div class="filters">
<span class="text-muted">Status:</span>
<a href="{{ url_for('admin.admin_zopk_news', status='all', stars=current_stars, sort=current_sort, dir=current_dir) }}" class="filter-btn {% if current_status == 'all' %}active{% endif %}">Wszystkie</a>
<a href="{{ url_for('admin.admin_zopk_news', status='pending', stars=current_stars, sort=current_sort, dir=current_dir) }}" class="filter-btn {% if current_status == 'pending' %}active{% endif %}">Oczekujące</a>
<a href="{{ url_for('admin.admin_zopk_news', status='approved', stars=current_stars, sort=current_sort, dir=current_dir) }}" class="filter-btn {% if current_status == 'approved' %}active{% endif %}">Zatwierdzone</a>
<a href="{{ url_for('admin.admin_zopk_news', status='rejected', stars=current_stars, sort=current_sort, dir=current_dir) }}" class="filter-btn {% if current_status == 'rejected' %}active{% endif %}">Odrzucone</a>
<span class="text-muted" style="margin-left: var(--spacing-md);">Gwiazdki:</span>
<a href="{{ url_for('admin.admin_zopk_news', status=current_status, stars='all', sort=current_sort, dir=current_dir) }}" class="filter-btn {% if current_stars == 'all' %}active{% endif %}">Wszystkie</a>
{% for star in [5, 4, 3, 2, 1] %}
<a href="{{ url_for('admin.admin_zopk_news', status=current_status, stars=star, sort=current_sort, dir=current_dir) }}" class="filter-btn star-filter {% if current_stars == star|string %}active{% endif %}">
<span class="star-icon">{{ '★' * star }}{{ '☆' * (5 - star) }}</span>
</a>
{% endfor %}
<a href="{{ url_for('admin.admin_zopk_news', status=current_status, stars='none', sort=current_sort, dir=current_dir) }}" class="filter-btn {% if current_stars == 'none' %}active{% endif %}">Brak oceny</a>
<div class="sort-controls">
<span class="text-muted">Sortuj:</span>
<select id="sort-select" onchange="updateSort()">
<option value="date-desc" {% if current_sort == 'date' and current_dir == 'desc' %}selected{% endif %}>Data (najnowsze)</option>
<option value="date-asc" {% if current_sort == 'date' and current_dir == 'asc' %}selected{% endif %}>Data (najstarsze)</option>
<option value="score-desc" {% if current_sort == 'score' and current_dir == 'desc' %}selected{% endif %}>Ocena AI (najwyższa)</option>
<option value="score-asc" {% if current_sort == 'score' and current_dir == 'asc' %}selected{% endif %}>Ocena AI (najniższa)</option>
<option value="title-asc" {% if current_sort == 'title' and current_dir == 'asc' %}selected{% endif %}>Tytuł (A-Z)</option>
<option value="title-desc" {% if current_sort == 'title' and current_dir == 'desc' %}selected{% endif %}>Tytuł (Z-A)</option>
</select>
</div>
</div>
<!-- Mass reject by stars -->
<div class="bulk-actions" style="margin-bottom: var(--spacing-lg); display: flex; gap: var(--spacing-md); align-items: center; flex-wrap: wrap;">
<span class="text-muted">Moderacja:</span>
<button class="action-btn reject" onclick="showMassRejectModal()" style="padding: 6px 12px;">
🗑️ Odrzuć po gwiazdkach
</button>
{% if current_stars != 'all' and current_stars != 'none' and current_status == 'pending' %}
<button class="action-btn reject" onclick="rejectCurrentFilter()" style="padding: 6px 12px;">
✕ Odrzuć wszystkie {{ current_stars }}★ ({{ total }})
</button>
{% endif %}
<span style="border-left: 1px solid var(--border); height: 24px; margin: 0 8px;"></span>
<span class="text-muted">Baza wiedzy:</span>
<button class="action-btn" onclick="batchScrapeContent()" style="padding: 6px 12px; background: #e0f2fe; border-color: #0284c7; color: #0369a1;">
📥 Scrapuj treść
</button>
<button class="action-btn" onclick="batchExtractKnowledge()" style="padding: 6px 12px; background: #dcfce7; border-color: #16a34a; color: #15803d;">
🧠 Ekstraktuj wiedzę
</button>
<button class="action-btn" onclick="batchGenerateEmbeddings()" style="padding: 6px 12px; background: #fef3c7; border-color: #d97706; color: #b45309;">
🔢 Generuj embeddingi
</button>
<button class="action-btn" onclick="showKnowledgeStats()" style="padding: 6px 12px; background: #f3f4f6; border-color: #6b7280; color: #374151;">
📊 Statystyki
</button>
</div>
{% if news_items %}
<div class="news-table-wrapper">
<table class="news-table">
<thead>
<tr>
<th class="sortable-header {% if current_sort == 'title' %}active{% endif %}" style="width: 35%">
<a href="{{ url_for('admin.admin_zopk_news', status=current_status, sort='title', dir='desc' if current_sort == 'title' and current_dir == 'asc' else 'asc') }}">
Tytuł
<span class="sort-icon">{% if current_sort == 'title' %}{{ '▲' if current_dir == 'asc' else '▼' }}{% else %}⇅{% endif %}</span>
</a>
</th>
<th>Źródło</th>
<th class="sortable-header {% if current_sort == 'score' %}active{% endif %}">
<a href="{{ url_for('admin.admin_zopk_news', status=current_status, sort='score', dir='asc' if current_sort == 'score' and current_dir == 'desc' else 'desc') }}">
Ocena AI
<span class="sort-icon">{% if current_sort == 'score' %}{{ '▲' if current_dir == 'asc' else '▼' }}{% else %}⇅{% endif %}</span>
</a>
</th>
<th>Status</th>
<th class="sortable-header {% if current_sort == 'date' %}active{% endif %}">
<a href="{{ url_for('admin.admin_zopk_news', status=current_status, sort='date', dir='asc' if current_sort == 'date' and current_dir == 'desc' else 'desc') }}">
Data
<span class="sort-icon">{% if current_sort == 'date' %}{{ '▲' if current_dir == 'asc' else '▼' }}{% else %}⇅{% endif %}</span>
</a>
</th>
<th style="min-width: 150px;">Akcje</th>
</tr>
</thead>
<tbody>
{% for news in news_items %}
<tr id="news-row-{{ news.id }}">
<td class="news-title">
<a href="{{ news.url }}" target="_blank" rel="noopener">{{ news.title }}</a>
<small>{{ news.source_name or news.source_domain }}</small>
</td>
<td><span class="source-badge">{{ news.source_type }}</span></td>
<td>
{% if news.ai_relevance_score %}
<span class="ai-score-badge score-{{ news.ai_relevance_score }}" title="{{ news.ai_evaluation_reason or 'Brak opisu' }}">
<span class="ai-stars">
{% for i in range(1, 6) %}
<span class="{{ 'star-filled' if i <= news.ai_relevance_score else 'star-empty' }}"></span>
{% endfor %}
</span>
</span>
{% elif news.ai_relevant is not none %}
<span class="ai-score-badge score-{{ '3' if news.ai_relevant else '1' }}" title="{{ news.ai_evaluation_reason or 'Brak opisu' }}">
{{ '✓ Relevant' if news.ai_relevant else '✗ Nie' }}
</span>
{% else %}
<span class="ai-score-badge score-none"></span>
{% endif %}
</td>
<td>
<span class="status-badge status-{{ news.status }}">
{% if news.status == 'pending' %}Oczekuje{% elif news.status == 'approved' %}Zatwierdzony{% else %}Odrzucony{% endif %}
</span>
</td>
<td>{{ news.published_at|local_time('%d.%m.%Y') if news.published_at else (news.created_at|local_time('%d.%m.%Y') if news.created_at else '-') }}</td>
<td style="white-space: nowrap;">
{% if news.status == 'pending' %}
<button class="action-btn approve" onclick="approveNews({{ news.id }})">Zatwierdź</button>
<button class="action-btn reject" onclick="rejectNews({{ news.id }})">Odrzuć</button>
{% elif news.status == 'approved' %}
<button class="action-btn reject" onclick="rejectNews({{ news.id }})">Odrzuć</button>
{% else %}
<button class="action-btn approve" onclick="approveNews({{ news.id }})">Przywróć</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if total_pages > 1 %}
<nav class="pagination">
{% if page > 1 %}
<a href="{{ url_for('admin.admin_zopk_news', page=page-1, status=current_status, stars=current_stars, sort=current_sort, dir=current_dir) }}">&laquo; Poprzednia</a>
{% endif %}
{% for p in range(1, total_pages + 1) %}
{% if p == page %}
<span class="current">{{ p }}</span>
{% elif p <= 3 or p > total_pages - 3 or (p >= page - 1 and p <= page + 1) %}
<a href="{{ url_for('admin.admin_zopk_news', page=p, status=current_status, stars=current_stars, sort=current_sort, dir=current_dir) }}">{{ p }}</a>
{% elif p == 4 or p == total_pages - 3 %}
<span>...</span>
{% endif %}
{% endfor %}
{% if page < total_pages %}
<a href="{{ url_for('admin.admin_zopk_news', page=page+1, status=current_status, stars=current_stars, sort=current_sort, dir=current_dir) }}">Następna &raquo;</a>
{% endif %}
</nav>
{% endif %}
{% else %}
<div class="empty-state">
<p>Brak artykułów o tym statusie.</p>
</div>
{% endif %}
<!-- Universal Confirm/Alert Modal -->
<div class="modal-overlay" id="confirmModal">
<div class="modal" style="max-width: 420px;">
<div style="text-align: center; margin-bottom: var(--spacing-lg);">
<div class="modal-icon" id="confirmModalIcon"></div>
<h3 id="confirmModalTitle" style="margin-bottom: var(--spacing-sm);">Potwierdzenie</h3>
<p class="modal-description" id="confirmModalMessage" style="white-space: pre-line; text-align: left;">Czy na pewno chcesz kontynuować?</p>
</div>
<div class="form-group" id="confirmModalInputGroup" style="display: none;">
<label id="confirmModalInputLabel">Wprowadź wartość:</label>
<input type="text" id="confirmModalInput" placeholder="">
</div>
<div class="modal-actions" style="justify-content: center;">
<button type="button" class="btn btn-secondary" id="confirmModalCancel">Anuluj</button>
<button type="button" class="btn btn-primary" id="confirmModalOk">OK</button>
</div>
</div>
</div>
<!-- Mass Reject by Stars Modal -->
<div class="modal-overlay" id="massRejectModal">
<div class="modal" style="max-width: 480px;">
<div style="text-align: center; margin-bottom: var(--spacing-md);">
<div class="modal-icon">🗑️</div>
<h3 style="margin-bottom: var(--spacing-xs);">Masowe odrzucanie po gwiazdkach</h3>
<p class="modal-description">Wybierz oceny gwiazdkowe, które chcesz odrzucić.<br>Dotyczy tylko artykułów <strong>oczekujących</strong>.</p>
</div>
<div class="mass-reject-options" id="massRejectOptions">
{% for star in [1, 2, 3, 4, 5] %}
<label class="mass-reject-option" data-star="{{ star }}">
<input type="checkbox" name="reject_stars" value="{{ star }}">
<span class="mass-reject-stars">{{ '★' * star }}{{ '☆' * (5 - star) }}</span>
<span>{{ star }} {{ 'gwiazdka' if star == 1 else ('gwiazdki' if star < 5 else 'gwiazdek') }}</span>
<span class="mass-reject-count" id="star-count-{{ star }}">— szt.</span>
</label>
{% endfor %}
<label class="mass-reject-option" data-star="0">
<input type="checkbox" name="reject_stars" value="0">
<span class="mass-reject-stars" style="color: var(--text-secondary);"></span>
<span>Brak oceny AI</span>
<span class="mass-reject-count" id="star-count-0">— szt.</span>
</label>
</div>
<div class="form-group">
<label>Powód odrzucenia (wspólny dla wszystkich):</label>
<input type="text" id="massRejectReason" placeholder="np. Niska ocena AI, nieistotne artykuły...">
</div>
<div id="massRejectSummary" style="background: #fee2e2; padding: var(--spacing-md); border-radius: var(--radius); margin-bottom: var(--spacing-md); display: none;">
<strong>Do odrzucenia:</strong> <span id="massRejectTotal">0</span> artykułów
</div>
<div class="modal-actions" style="justify-content: center;">
<button type="button" class="btn btn-secondary" onclick="closeMassRejectModal()">Anuluj</button>
<button type="button" class="btn btn-danger" id="massRejectConfirmBtn" onclick="executeMassReject()">Odrzuć wybrane</button>
</div>
</div>
</div>
<div id="toastContainer" style="position: fixed; top: 80px; right: 20px; z-index: 1100; display: flex; flex-direction: column; gap: 10px;"></div>
<style>
.modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1000; align-items: center; justify-content: center; }
.modal-overlay.active { display: flex; }
.modal { background: var(--surface); border-radius: var(--radius-lg); padding: var(--spacing-xl); max-width: 500px; width: 90%; }
.modal-icon { font-size: 3em; margin-bottom: var(--spacing-md); }
.modal-actions { display: flex; justify-content: flex-end; gap: var(--spacing-sm); margin-top: var(--spacing-lg); }
.toast { padding: 12px 20px; border-radius: var(--radius); background: var(--surface); border-left: 4px solid var(--primary); box-shadow: 0 4px 12px rgba(0,0,0,0.15); display: flex; align-items: center; gap: 10px; animation: slideIn 0.3s ease; max-width: 350px; }
.toast.success { border-left-color: var(--success); }
.toast.error { border-left-color: var(--error); }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes slideOut { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } }
</style>
{% endblock %}
{% block extra_js %}
const csrfToken = '{{ csrf_token() }}';
const currentStars = '{{ current_stars }}';
// Universal Modal System
let confirmModalResolve = null;
function showConfirm(message, options = {}) {
return new Promise((resolve) => {
confirmModalResolve = resolve;
const modal = document.getElementById('confirmModal');
document.getElementById('confirmModalIcon').textContent = options.icon || '❓';
document.getElementById('confirmModalTitle').textContent = options.title || 'Potwierdzenie';
document.getElementById('confirmModalMessage').textContent = message;
document.getElementById('confirmModalCancel').textContent = options.cancelText || 'Anuluj';
document.getElementById('confirmModalOk').textContent = options.okText || 'OK';
document.getElementById('confirmModalOk').className = 'btn ' + (options.okClass || 'btn-primary');
const inputGroup = document.getElementById('confirmModalInputGroup');
if (options.showInput) {
inputGroup.style.display = 'block';
document.getElementById('confirmModalInputLabel').textContent = options.inputLabel || 'Wprowadź wartość:';
document.getElementById('confirmModalInput').value = '';
document.getElementById('confirmModalInput').placeholder = options.inputPlaceholder || '';
} else {
inputGroup.style.display = 'none';
}
document.getElementById('confirmModalCancel').style.display = options.alertOnly ? 'none' : '';
modal.classList.add('active');
});
}
function closeConfirmModal(result) {
document.getElementById('confirmModal').classList.remove('active');
if (confirmModalResolve) { confirmModalResolve(result); confirmModalResolve = null; }
}
document.getElementById('confirmModalOk').addEventListener('click', () => {
const inputGroup = document.getElementById('confirmModalInputGroup');
closeConfirmModal(inputGroup.style.display !== 'none' ? document.getElementById('confirmModalInput').value : true);
});
document.getElementById('confirmModalCancel').addEventListener('click', () => closeConfirmModal(false));
document.getElementById('confirmModal').addEventListener('click', (e) => { if (e.target.id === 'confirmModal') closeConfirmModal(false); });
function showToast(message, type = 'info', duration = 4000) {
const container = document.getElementById('toastContainer');
const icons = { success: '✓', error: '✕', warning: '⚠', info: '' };
const toast = document.createElement('div');
toast.className = `toast ${type}`;
// Bezpieczne tworzenie elementów - unikamy innerHTML z danymi użytkownika
const iconSpan = document.createElement('span');
iconSpan.style.fontSize = '1.2em';
iconSpan.textContent = icons[type] || icons.info;
const msgSpan = document.createElement('span');
msgSpan.textContent = message;
toast.appendChild(iconSpan);
toast.appendChild(msgSpan);
container.appendChild(toast);
setTimeout(() => { toast.style.animation = 'slideOut 0.3s ease forwards'; setTimeout(() => toast.remove(), 300); }, duration);
}
function updateSort() {
const select = document.getElementById('sort-select');
const [sort, dir] = select.value.split('-');
const url = new URL(window.location);
url.searchParams.set('sort', sort);
url.searchParams.set('dir', dir);
url.searchParams.delete('page'); // Reset to first page
window.location.href = url.toString();
}
async function approveNews(newsId) {
try {
const response = await fetch(`/admin/zopk/news/${newsId}/approve`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
showToast('News został zatwierdzony', 'success');
setTimeout(() => location.reload(), 800);
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia: ' + error.message, 'error');
}
}
async function rejectNews(newsId) {
const reason = await showConfirm('Podaj powód odrzucenia (opcjonalnie):', {
icon: '✕',
title: 'Odrzuć news',
showInput: true,
inputLabel: 'Powód odrzucenia:',
inputPlaceholder: 'np. Nieistotny artykuł...',
okText: 'Odrzuć',
okClass: 'btn-danger'
});
if (reason === false) return;
try {
const response = await fetch(`/admin/zopk/news/${newsId}/reject`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ reason: reason })
});
const data = await response.json();
if (data.success) {
showToast('News został odrzucony', 'success');
setTimeout(() => location.reload(), 800);
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia: ' + error.message, 'error');
}
}
// ============================================
// Mass Reject by Stars
// ============================================
let starCounts = {};
async function showMassRejectModal() {
const modal = document.getElementById('massRejectModal');
// Fetch counts for each star rating
try {
const response = await fetch('/admin/zopk/news/star-counts', {
method: 'GET',
headers: { 'X-CSRFToken': csrfToken }
});
const data = await response.json();
if (data.success) {
starCounts = data.counts;
// Update UI with counts
for (let star = 0; star <= 5; star++) {
const countEl = document.getElementById(`star-count-${star}`);
if (countEl) {
const count = starCounts[star] || 0;
countEl.textContent = `${count} szt.`;
}
}
}
} catch (error) {
console.error('Failed to fetch star counts:', error);
}
// Reset checkboxes
document.querySelectorAll('#massRejectOptions input[type="checkbox"]').forEach(cb => {
cb.checked = false;
cb.closest('.mass-reject-option').classList.remove('selected');
});
document.getElementById('massRejectReason').value = '';
document.getElementById('massRejectSummary').style.display = 'none';
modal.classList.add('active');
// Add change listeners
document.querySelectorAll('#massRejectOptions input[type="checkbox"]').forEach(cb => {
cb.addEventListener('change', updateMassRejectSummary);
});
}
function closeMassRejectModal() {
document.getElementById('massRejectModal').classList.remove('active');
}
function updateMassRejectSummary() {
const checkboxes = document.querySelectorAll('#massRejectOptions input[type="checkbox"]:checked');
let total = 0;
checkboxes.forEach(cb => {
const star = parseInt(cb.value);
total += starCounts[star] || 0;
cb.closest('.mass-reject-option').classList.add('selected');
});
document.querySelectorAll('#massRejectOptions input[type="checkbox"]:not(:checked)').forEach(cb => {
cb.closest('.mass-reject-option').classList.remove('selected');
});
const summary = document.getElementById('massRejectSummary');
const totalEl = document.getElementById('massRejectTotal');
totalEl.textContent = total;
if (total > 0) {
summary.style.display = 'block';
} else {
summary.style.display = 'none';
}
}
async function executeMassReject() {
const checkboxes = document.querySelectorAll('#massRejectOptions input[type="checkbox"]:checked');
const stars = Array.from(checkboxes).map(cb => parseInt(cb.value));
const reason = document.getElementById('massRejectReason').value.trim();
if (stars.length === 0) {
showToast('Wybierz co najmniej jedną ocenę gwiazdkową', 'warning');
return;
}
// Calculate total
let total = 0;
stars.forEach(s => total += starCounts[s] || 0);
if (total === 0) {
showToast('Brak artykułów do odrzucenia', 'info');
closeMassRejectModal();
return;
}
// Confirm
const confirmed = await showConfirm(
`Czy na pewno chcesz odrzucić ${total} artykułów?`,
{
icon: '⚠️',
title: 'Potwierdzenie masowego odrzucenia',
okText: `Odrzuć ${total} artykułów`,
okClass: 'btn-danger'
}
);
if (!confirmed) return;
closeMassRejectModal();
try {
const response = await fetch('/admin/zopk/news/reject-by-stars', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ stars: stars, reason: reason })
});
const data = await response.json();
if (data.success) {
showToast(`Odrzucono ${data.count} artykułów`, 'success');
setTimeout(() => location.reload(), 1000);
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia: ' + error.message, 'error');
}
}
async function rejectCurrentFilter() {
const stars = parseInt(currentStars);
if (isNaN(stars) || stars < 1 || stars > 5) {
showToast('Nieprawidłowy filtr gwiazdek', 'error');
return;
}
// Fetch count first
let count = 0;
try {
const response = await fetch('/admin/zopk/news/star-counts');
const data = await response.json();
if (data.success) {
count = data.counts[stars] || 0;
}
} catch (e) {}
if (count === 0) {
showToast('Brak artykułów do odrzucenia', 'info');
return;
}
const confirmed = await showConfirm(
`Czy na pewno chcesz odrzucić wszystkie ${count} artykułów z oceną ${stars}★?`,
{
icon: '⚠️',
title: 'Potwierdzenie odrzucenia',
okText: `Odrzuć ${count} artykułów`,
okClass: 'btn-danger'
}
);
if (!confirmed) return;
try {
const response = await fetch('/admin/zopk/news/reject-by-stars', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ stars: [stars], reason: `Masowo odrzucone - ocena ${stars}★` })
});
const data = await response.json();
if (data.success) {
showToast(`Odrzucono ${data.count} artykułów`, 'success');
setTimeout(() => location.reload(), 1000);
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia: ' + error.message, 'error');
}
}
// Close modal on escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeMassRejectModal();
}
});
// Close modal on overlay click
document.getElementById('massRejectModal').addEventListener('click', (e) => {
if (e.target.id === 'massRejectModal') {
closeMassRejectModal();
}
});
// ============================================
// Knowledge Base Operations
// ============================================
async function batchScrapeContent() {
const limitInput = await showConfirm(
'Ile artykułów chcesz zescrapować?\n\nPobierze pełną treść z artykułów ze statusem "auto_approved".',
{
icon: '📥',
title: 'Scraping treści',
showInput: true,
inputLabel: 'Liczba artykułów (1-100):',
inputPlaceholder: '1',
okText: 'Rozpocznij scraping',
okClass: 'btn-primary'
}
);
if (limitInput === false) return;
const limit = parseInt(limitInput) || 1;
if (limit < 1 || limit > 100) {
showToast('Limit musi być między 1 a 100', 'warning');
return;
}
showToast(`Rozpoczynam scraping ${limit} artykułów...`, 'info', 10000);
try {
const response = await fetch('/admin/zopk/news/scrape-content', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ limit: limit })
});
const data = await response.json();
if (data.success) {
showToast(`✅ Scraping zakończony: ${data.scraped} pobrano, ${data.failed} błędów`, 'success', 8000);
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia: ' + error.message, 'error');
}
}
async function batchExtractKnowledge() {
const limitInput = await showConfirm(
'Ile artykułów chcesz przetworzyć?\n\nWyekstraktuje chunks, fakty i encje z artykułów ze scraped treścią.',
{
icon: '🧠',
title: 'Ekstrakcja wiedzy',
showInput: true,
inputLabel: 'Liczba artykułów (1-100):',
inputPlaceholder: '1',
okText: 'Rozpocznij ekstrakcję',
okClass: 'btn-primary'
}
);
if (limitInput === false) return;
const limit = parseInt(limitInput) || 1;
if (limit < 1 || limit > 100) {
showToast('Limit musi być między 1 a 100', 'warning');
return;
}
showToast(`Rozpoczynam ekstrakcję wiedzy z ${limit} artykułów...`, 'info', 30000);
try {
const response = await fetch('/admin/zopk/knowledge/extract', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ limit: limit })
});
const data = await response.json();
if (data.success) {
showToast(`✅ Ekstrakcja: ${data.processed || 0}/${data.total || 0} art., ${data.chunks_created || 0} chunks, ${data.facts_created || 0} faktów`, 'success', 10000);
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia: ' + error.message, 'error');
}
}
async function batchGenerateEmbeddings() {
const limitInput = await showConfirm(
'Ile chunks chcesz przetworzyć?\n\nWygeneruje wektory dla chunks bez embeddingów (potrzebne do semantic search).',
{
icon: '🔢',
title: 'Generowanie embeddingów',
showInput: true,
inputLabel: 'Liczba chunks (1-500):',
inputPlaceholder: '1',
okText: 'Generuj embeddingi',
okClass: 'btn-primary'
}
);
if (limitInput === false) return;
const limit = parseInt(limitInput) || 1;
if (limit < 1 || limit > 500) {
showToast('Limit musi być między 1 a 500', 'warning');
return;
}
showToast(`Generuję embeddingi dla ${limit} chunks...`, 'info', 15000);
try {
const response = await fetch('/admin/zopk/knowledge/embeddings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ limit: limit })
});
const data = await response.json();
if (data.success) {
showToast(`✅ Embeddingi: ${data.generated || 0}/${data.total || 0} chunks`, 'success', 8000);
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia: ' + error.message, 'error');
}
}
async function showKnowledgeStats() {
try {
// Fetch both scrape stats and knowledge stats
const [scrapeRes, knowledgeRes] = await Promise.all([
fetch('/admin/zopk/news/scrape-stats'),
fetch('/admin/zopk/knowledge/stats')
]);
const scrapeData = await scrapeRes.json();
const knowledgeData = await knowledgeRes.json();
let message = '';
if (scrapeData.success) {
message += `📥 SCRAPING TREŚCI\n`;
message += `━━━━━━━━━━━━━━━━━━━━━\n`;
message += `Oczekujące: ${scrapeData.pending || 0}\n`;
message += `Pobrane: ${scrapeData.scraped || 0}\n`;
message += `Błędy: ${scrapeData.failed || 0}\n`;
message += `Gotowe do AI: ${scrapeData.ready_for_extraction || 0}\n\n`;
}
if (knowledgeData.success) {
const articles = knowledgeData.articles || {};
const kb = knowledgeData.knowledge_base || {};
message += `📰 ARTYKUŁY ZOPK\n`;
message += `━━━━━━━━━━━━━━━━━━━━━\n`;
message += `Zatwierdzone: ${articles.total_approved || 0}\n`;
message += `Ze scraped treścią: ${articles.scraped || 0}\n`;
message += `Z wiedzą AI: ${articles.extracted || 0}\n`;
message += `Do przetworzenia: ${articles.pending || 0}\n\n`;
message += `🧠 BAZA WIEDZY\n`;
message += `━━━━━━━━━━━━━━━━━━━━━\n`;
message += `Chunks: ${kb.chunks || 0}\n`;
message += `Fakty: ${kb.facts || 0}\n`;
message += `Encje: ${kb.entities || 0}\n`;
message += `Relacje: ${kb.relations || 0}`;
}
await showConfirm(message, {
icon: '📊',
title: 'Statystyki bazy wiedzy',
okText: 'OK',
alertOnly: true
});
} catch (error) {
showToast('Błąd pobierania statystyk: ' + error.message, 'error');
}
}
{% endblock %}