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
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>
1093 lines
40 KiB
HTML
1093 lines
40 KiB
HTML
{% 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) }}">« 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 »</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 %}
|