nordabiz/templates/admin/zopk_dashboard.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

3711 lines
130 KiB
HTML
Raw Permalink 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 - Panel Admina - Norda Biznes Partner{% endblock %}
{% block extra_css %}
<style>
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xl);
}
.admin-header h1 {
font-size: var(--font-size-2xl);
color: var(--text-primary);
}
.admin-actions {
display: flex;
gap: var(--spacing-sm);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.stat-card {
background: var(--surface);
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
cursor: pointer;
transition: var(--transition);
border: 2px solid transparent;
text-decoration: none;
display: block;
color: inherit;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.stat-card.info-only {
cursor: default;
opacity: 0.85;
}
.stat-card.info-only:hover {
transform: none;
box-shadow: var(--shadow);
}
.stat-card.active {
border-color: var(--primary);
background: var(--primary-light, #f0fdf4);
}
.stat-value {
font-size: var(--font-size-3xl);
font-weight: 700;
color: var(--primary);
}
.stat-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.stat-card.warning .stat-value {
color: var(--warning);
}
.stat-card.success .stat-value {
color: var(--success);
}
.stat-card.danger .stat-value {
color: var(--danger);
}
/* Stats sections (two-row layout) */
.stats-section {
margin-bottom: var(--spacing-lg);
}
.stats-section-title {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--spacing-md);
}
.stats-section-title small {
font-weight: 400;
text-transform: none;
letter-spacing: normal;
opacity: 0.8;
}
.stats-grid-small {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-md);
max-width: 600px;
}
.filter-card {
cursor: pointer;
}
.filter-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
/* AI Action button */
.stat-card.ai-action {
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
color: white;
border: none;
font-family: inherit;
}
.stat-card.ai-action .stat-value {
color: white;
}
.stat-card.ai-action .stat-label {
color: rgba(255,255,255,0.9);
}
.stat-card.ai-action:hover {
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
}
.stat-card.ai-action:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.stat-card.ai-action:disabled:hover {
box-shadow: var(--shadow);
}
/* AI evaluation result */
.ai-result {
background: #f3e8ff;
border: 1px solid #c4b5fd;
color: #6b21a8;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
font-size: var(--font-size-sm);
}
.ai-result.success {
background: #dcfce7;
border-color: #86efac;
color: #166534;
}
.ai-result.error {
background: #fee2e2;
border-color: #fca5a5;
color: #991b1b;
}
/* AI badge in news list */
.ai-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
}
.ai-badge.relevant {
background: #dcfce7;
color: #166534;
}
.ai-badge.not-relevant {
background: #fee2e2;
color: #991b1b;
}
/* Star rating display */
.ai-stars {
display: inline-flex;
gap: 1px;
font-size: 11px;
letter-spacing: -1px;
}
.ai-stars .star-filled { color: #f59e0b; }
.ai-stars .star-empty { color: #d1d5db; }
.ai-badge.score-5 { background: #dcfce7; color: #166534; }
.ai-badge.score-4 { background: #d1fae5; color: #047857; }
.ai-badge.score-3 { background: #fef3c7; color: #92400e; }
.ai-badge.score-2 { background: #fee2e2; color: #b91c1c; }
.ai-badge.score-1 { background: #fecaca; color: #991b1b; }
/* AI Evaluation Modal */
.modal-icon {
font-size: 48px;
text-align: center;
margin-bottom: var(--spacing-md);
}
.modal-icon.spinning {
animation: spin 2s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.modal-description {
text-align: center;
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
.modal-note {
text-align: center;
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--spacing-lg);
}
.ai-progress-bar {
width: 100%;
height: 8px;
background: var(--border);
border-radius: 4px;
overflow: hidden;
margin-bottom: var(--spacing-md);
}
.ai-progress-fill {
height: 100%;
background: linear-gradient(90deg, #8b5cf6, #7c3aed);
border-radius: 4px;
transition: width 0.5s ease;
width: 0%;
}
.ai-result-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
.ai-stat-item {
text-align: center;
padding: var(--spacing-md);
background: var(--background);
border-radius: var(--radius);
}
.ai-stat-item .value {
font-size: var(--font-size-2xl);
font-weight: 700;
}
.ai-stat-item .label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.ai-stat-item.success .value { color: var(--success); }
.ai-stat-item.danger .value { color: var(--danger); }
.ai-stat-item.warning .value { color: var(--warning); }
#aiResultIcon.success { color: var(--success); }
#aiResultIcon.error { color: var(--danger); }
/* Filters bar */
.filters-bar {
display: flex;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
flex-wrap: wrap;
align-items: center;
}
.filter-group {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.filter-group label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.filter-checkbox {
display: flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--background);
border-radius: var(--radius);
cursor: pointer;
font-size: var(--font-size-sm);
}
.filter-checkbox input {
margin: 0;
}
.old-news-warning {
background: #fef3c7;
color: #92400e;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
font-size: var(--font-size-sm);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: var(--spacing-sm);
margin-top: var(--spacing-lg);
padding-top: var(--spacing-lg);
border-top: 1px solid var(--border);
}
.pagination a,
.pagination span {
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius);
font-size: var(--font-size-sm);
text-decoration: none;
color: var(--text-primary);
}
.pagination a {
background: var(--background);
border: 1px solid var(--border);
}
.pagination a:hover {
background: var(--border);
}
.pagination .current {
background: var(--primary);
color: white;
font-weight: 600;
}
.pagination .disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-info {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* Old news badge */
.old-news-badge {
background: #fef3c7;
color: #92400e;
padding: 2px 6px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
}
.panel-section {
background: var(--surface);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
box-shadow: var(--shadow);
margin-bottom: var(--spacing-xl);
}
.panel-section h2 {
font-size: var(--font-size-lg);
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--border);
}
.pending-news-list {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.pending-news-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: var(--spacing-md);
background: var(--background);
border-radius: var(--radius);
gap: var(--spacing-md);
}
.news-info {
flex: 1;
min-width: 0;
}
.news-info h4 {
font-size: var(--font-size-base);
margin-bottom: var(--spacing-xs);
word-break: break-word;
}
.news-info h4 a {
color: inherit;
text-decoration: none;
}
.news-info h4 a:hover {
color: var(--primary);
}
.news-meta {
font-size: var(--font-size-xs);
color: var(--text-secondary);
display: flex;
gap: var(--spacing-md);
}
.news-actions {
display: flex;
gap: var(--spacing-xs);
flex-shrink: 0;
}
.btn-approve {
background: var(--success);
color: white;
}
.btn-approve:hover {
background: #059669;
}
.btn-reject {
background: var(--danger);
color: white;
}
.btn-reject:hover {
background: #dc2626;
}
.projects-table {
width: 100%;
border-collapse: collapse;
}
.projects-table th,
.projects-table td {
padding: var(--spacing-sm) var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.projects-table th {
font-weight: 600;
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
}
.status-planned { background: #fef3c7; color: #92400e; }
.status-in_progress { background: #dbeafe; color: #1e40af; }
.status-completed { background: #dcfce7; color: #166534; }
.search-section {
background: linear-gradient(135deg, #059669 0%, #047857 100%);
color: white;
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
margin-bottom: var(--spacing-xl);
}
.search-section h3 {
margin-bottom: var(--spacing-md);
}
.search-form {
display: flex;
gap: var(--spacing-sm);
}
.search-form input {
flex: 1;
padding: var(--spacing-sm) var(--spacing-md);
border: none;
border-radius: var(--radius);
font-size: var(--font-size-base);
}
.search-form button {
padding: var(--spacing-sm) var(--spacing-lg);
background: white;
color: var(--primary);
border: none;
border-radius: var(--radius);
font-weight: 600;
cursor: pointer;
transition: var(--transition);
}
.search-form button:hover {
background: #f0fdf4;
}
.search-form button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.fetch-jobs-list {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.fetch-job {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-sm) var(--spacing-md);
background: var(--background);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
}
.fetch-job-status {
padding: 2px 6px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
}
.fetch-job-status.completed { background: #dcfce7; color: #166534; }
.fetch-job-status.failed { background: #fee2e2; color: #991b1b; }
.fetch-job-status.running { background: #dbeafe; color: #1e40af; }
/* Progress bar */
.progress-container {
display: none;
margin-top: var(--spacing-lg);
background: rgba(255,255,255,0.1);
border-radius: var(--radius);
padding: var(--spacing-md);
}
.progress-container.active {
display: block;
}
.progress-header {
display: flex;
justify-content: space-between;
margin-bottom: var(--spacing-sm);
font-size: var(--font-size-sm);
}
.progress-bar-container {
height: 8px;
background: rgba(255,255,255,0.2);
border-radius: 4px;
overflow: hidden;
margin-bottom: var(--spacing-md);
}
.progress-bar-fill {
height: 100%;
background: white;
border-radius: 4px;
transition: width 0.3s ease;
width: 0%;
}
.progress-steps {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
font-size: var(--font-size-xs);
max-height: 200px;
overflow-y: auto;
}
.progress-step {
display: flex;
align-items: center;
gap: var(--spacing-sm);
opacity: 0.6;
}
.progress-step.active {
opacity: 1;
font-weight: 600;
}
.progress-step.completed {
opacity: 1;
}
.progress-step-icon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.progress-step.active .progress-step-icon::before {
content: "⏳";
}
.progress-step.completed .progress-step-icon::before {
content: "✓";
}
.progress-step.pending .progress-step-icon::before {
content: "○";
}
.progress-step-count {
margin-left: auto;
background: rgba(255,255,255,0.2);
padding: 2px 6px;
border-radius: var(--radius-sm);
}
/* Source stats */
.source-stats {
display: none;
margin-top: var(--spacing-md);
padding: var(--spacing-md);
background: rgba(255,255,255,0.1);
border-radius: var(--radius);
}
.source-stats.active {
display: block;
}
.source-stats h4 {
margin-bottom: var(--spacing-sm);
font-size: var(--font-size-sm);
}
.source-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--spacing-sm);
}
.source-stat-item {
display: flex;
justify-content: space-between;
padding: var(--spacing-xs) var(--spacing-sm);
background: rgba(255,255,255,0.1);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
}
.source-stat-item .count {
font-weight: 700;
}
/* News source badges */
.source-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
}
.source-badge.brave { background: #fff3cd; color: #856404; }
.source-badge.rss_local_media { background: #d1ecf1; color: #0c5460; }
.source-badge.rss_government { background: #d4edda; color: #155724; }
.source-badge.rss_aggregator { background: #e2e3e5; color: #383d41; }
.source-badge.manual { background: #f8d7da; color: #721c24; }
.confidence-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
}
.confidence-badge.high { background: #d4edda; color: #155724; }
.confidence-badge.medium { background: #fff3cd; color: #856404; }
.confidence-badge.low { background: #f8d7da; color: #721c24; }
.empty-state {
text-align: center;
padding: var(--spacing-xl);
color: var(--text-secondary);
}
/* Modal */
.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%;
max-height: 90vh;
overflow-y: auto;
}
.modal h3 {
margin-bottom: var(--spacing-lg);
}
.form-group {
margin-bottom: var(--spacing-md);
}
.form-group label {
display: block;
margin-bottom: var(--spacing-xs);
font-weight: 500;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: var(--spacing-sm);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-base);
}
.form-group textarea {
min-height: 80px;
resize: vertical;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
margin-top: var(--spacing-lg);
}
@media (max-width: 768px) {
.admin-header {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-md);
}
.pending-news-item {
flex-direction: column;
}
.news-actions {
width: 100%;
justify-content: flex-end;
}
.search-form {
flex-direction: column;
}
}
/* Progress phases (search → filter → AI → save) */
.progress-phases {
display: flex;
gap: var(--spacing-xs);
margin-bottom: var(--spacing-md);
flex-wrap: wrap;
}
.progress-phase {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: var(--radius);
font-size: var(--font-size-xs);
background: rgba(255,255,255,0.1);
opacity: 0.5;
transition: all 0.3s ease;
}
.progress-phase.active {
opacity: 1;
background: rgba(255,255,255,0.25);
animation: pulse 1.5s ease-in-out infinite;
}
.progress-phase.completed {
opacity: 1;
background: rgba(34, 197, 94, 0.3);
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.02); }
}
.progress-phase-icon {
font-size: 1em;
}
/* Search results container */
.search-results-container {
margin-top: var(--spacing-lg);
padding: var(--spacing-lg);
background: rgba(255,255,255,0.1);
border-radius: var(--radius-lg);
animation: fadeIn 0.5s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.search-results-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
.summary-stat {
text-align: center;
padding: var(--spacing-md);
background: rgba(255,255,255,0.1);
border-radius: var(--radius);
}
.summary-stat .value {
font-size: var(--font-size-2xl);
font-weight: 700;
}
.summary-stat .label {
font-size: var(--font-size-xs);
opacity: 0.8;
}
.summary-stat.success .value { color: #86efac; }
.summary-stat.warning .value { color: #fde68a; }
.summary-stat.error .value { color: #fca5a5; }
.summary-stat.info .value { color: #93c5fd; }
/* Auto-approved articles section */
.auto-approved-section {
margin-top: var(--spacing-lg);
padding: var(--spacing-md);
background: rgba(34, 197, 94, 0.15);
border-radius: var(--radius);
border: 1px solid rgba(34, 197, 94, 0.3);
}
.auto-approved-section h4 {
margin-bottom: var(--spacing-md);
font-size: var(--font-size-sm);
}
.auto-approved-list {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
max-height: 200px;
overflow-y: auto;
}
.auto-approved-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-xs) var(--spacing-sm);
background: rgba(255,255,255,0.1);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
}
.auto-approved-item .stars {
color: #fbbf24;
flex-shrink: 0;
}
.auto-approved-item .title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.auto-approved-item .source {
color: rgba(255,255,255,0.6);
flex-shrink: 0;
font-size: 10px;
}
/* Refresh countdown */
.refresh-countdown {
margin-top: var(--spacing-lg);
padding: var(--spacing-md);
background: rgba(255,255,255,0.1);
border-radius: var(--radius);
display: flex;
justify-content: space-between;
align-items: center;
font-size: var(--font-size-sm);
}
.refresh-countdown strong {
font-size: var(--font-size-lg);
color: #fde68a;
}
/* Workflow Stepper */
.workflow-stepper {
display: flex;
justify-content: center;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-xl);
padding: var(--spacing-lg);
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
}
.workflow-step {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-lg);
border-radius: var(--radius);
cursor: pointer;
transition: var(--transition);
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.workflow-step:hover {
background: var(--primary-light, #f0fdf4);
color: var(--primary);
}
.step-num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--primary);
color: white;
font-weight: 700;
font-size: var(--font-size-sm);
flex-shrink: 0;
}
.step-label {
font-weight: 500;
}
/* Connectors between steps */
.workflow-step:not(:last-child)::after {
content: '→';
margin-left: var(--spacing-sm);
color: var(--text-muted);
font-size: var(--font-size-lg);
}
/* Step headings */
.step-heading {
display: flex;
align-items: center;
gap: var(--spacing-md);
margin: var(--spacing-xl) 0 var(--spacing-lg);
padding-bottom: var(--spacing-md);
border-bottom: 2px solid var(--primary);
flex-wrap: wrap;
}
.step-badge {
display: inline-flex;
align-items: center;
padding: var(--spacing-xs) var(--spacing-md);
background: var(--primary);
color: white;
font-weight: 700;
font-size: var(--font-size-sm);
border-radius: var(--radius);
white-space: nowrap;
}
.step-heading h2 {
margin: 0;
font-size: var(--font-size-xl);
color: var(--text-primary);
}
.step-desc {
width: 100%;
margin: var(--spacing-xs) 0 0;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* Reference section (collapsible) */
.reference-section {
margin-top: var(--spacing-xl);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.reference-section > summary {
padding: var(--spacing-md) var(--spacing-lg);
background: var(--surface-secondary, #f9fafb);
cursor: pointer;
font-weight: 600;
color: var(--text-secondary);
list-style: none;
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.reference-section > summary::before {
content: '▶';
font-size: var(--font-size-xs);
transition: transform 0.2s ease;
}
.reference-section[open] > summary::before {
transform: rotate(90deg);
}
.reference-section > summary::-webkit-details-marker {
display: none;
}
.reference-section > :not(summary) {
padding: 0 var(--spacing-lg);
}
/* Workflow step states */
.workflow-step.done .step-num {
background: #22c55e;
}
.workflow-step.attention .step-num {
background: #f59e0b;
animation: pulse-attention 2s ease-in-out infinite;
}
.workflow-step.attention .step-badge-count {
position: absolute;
top: -6px;
right: -8px;
background: #ef4444;
color: white;
font-size: 10px;
font-weight: 700;
min-width: 18px;
height: 18px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
}
.workflow-step .step-num-wrap {
position: relative;
display: inline-flex;
}
.workflow-step.inactive .step-num {
background: #d1d5db;
}
.workflow-step.inactive {
opacity: 0.6;
}
@keyframes pulse-attention {
0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.4); }
50% { box-shadow: 0 0 0 8px rgba(245, 158, 11, 0); }
}
/* Status banners */
.step-status-banner {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-md) var(--spacing-lg);
border-radius: var(--radius);
margin-bottom: var(--spacing-lg);
font-size: var(--font-size-sm);
line-height: 1.5;
}
.step-status-banner .banner-icon {
font-size: 20px;
flex-shrink: 0;
}
.step-status-banner.banner-green {
background: #dcfce7;
border: 1px solid #86efac;
color: #166534;
}
.step-status-banner.banner-orange {
background: #fef3c7;
border: 1px solid #fcd34d;
color: #92400e;
}
.step-status-banner.banner-blue {
background: #dbeafe;
border: 1px solid #93c5fd;
color: #1e40af;
}
.step-status-banner.banner-gray {
background: #f3f4f6;
border: 1px solid #d1d5db;
color: #6b7280;
}
/* Transition banners between steps */
.step-transition {
display: flex;
align-items: center;
gap: var(--spacing-lg);
padding: var(--spacing-lg) var(--spacing-xl);
border-radius: var(--radius-lg);
margin: var(--spacing-xl) 0;
}
.step-transition .transition-icon {
font-size: 32px;
flex-shrink: 0;
}
.step-transition .transition-content {
flex: 1;
}
.step-transition .transition-title {
font-weight: 700;
font-size: var(--font-size-lg);
margin-bottom: 4px;
}
.step-transition .transition-desc {
font-size: var(--font-size-sm);
opacity: 0.85;
}
.step-transition .btn-transition {
padding: var(--spacing-sm) var(--spacing-lg);
border-radius: var(--radius);
border: none;
font-weight: 600;
font-size: var(--font-size-sm);
cursor: pointer;
white-space: nowrap;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
}
.step-transition.tr-purple {
background: linear-gradient(135deg, #ede9fe 0%, #ddd6fe 100%);
border: 1px solid #c4b5fd;
color: #5b21b6;
}
.step-transition.tr-purple .btn-transition {
background: #7c3aed;
color: white;
}
.step-transition.tr-green {
background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%);
border: 1px solid #86efac;
color: #166534;
}
.step-transition.tr-green .btn-transition {
background: #22c55e;
color: white;
}
.step-transition.tr-orange {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
border: 1px solid #fcd34d;
color: #92400e;
}
.step-transition.tr-orange .btn-transition {
background: #f59e0b;
color: white;
}
.step-transition.tr-blue {
background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
border: 1px solid #93c5fd;
color: #1e40af;
}
.step-transition.tr-blue .btn-transition {
background: #3b82f6;
color: white;
}
/* Responsive stepper */
@media (max-width: 768px) {
.workflow-stepper {
flex-direction: column;
gap: var(--spacing-xs);
padding: var(--spacing-md);
}
.workflow-step::after {
display: none;
}
.step-heading {
margin: var(--spacing-lg) 0 var(--spacing-md);
}
}
</style>
{% endblock %}
{% block content %}
{# Workflow step states #}
{% set step1_done = stats.total_news > 0 %}
{% set step2_attention = stats.ai_not_evaluated > 0 %}
{% set step2_done = stats.ai_not_evaluated == 0 and stats.total_news > 0 %}
{% set step3_attention = stats.pending_news > 0 %}
{% set step3_done = stats.pending_news == 0 and stats.approved_news > 0 %}
{% set step4_ready = stats.approved_news > 0 and stats.pending_news == 0 %}
<div class="admin-header">
<div>
<h1>Zielony Okręg Przemysłowy Kaszubia</h1>
<p class="text-muted">Panel zarządzania bazą wiedzy</p>
</div>
<div class="admin-actions">
<a href="{{ url_for('public.zopk_index') }}" class="btn btn-secondary" target="_blank">Zobacz stronę publiczną</a>
<a href="{{ url_for('admin.admin_zopk_news') }}" class="btn btn-secondary">Zarządzaj newsami</a>
<a href="{{ url_for('admin.admin_zopk_timeline') }}" class="btn btn-secondary">Oś czasu</a>
<button class="btn btn-primary" onclick="openAddNewsModal()">+ Dodaj news</button>
</div>
</div>
<!-- Workflow Stepper -->
<div class="workflow-stepper">
<div class="workflow-step {{ 'done' if step1_done else '' }}" onclick="document.getElementById('krok-1').scrollIntoView({behavior:'smooth'})">
<span class="step-num-wrap">
<span class="step-num">{% if step1_done %}&#10003;{% else %}1{% endif %}</span>
</span>
<span class="step-label">Wyszukaj artykuły</span>
</div>
<div class="workflow-step {{ 'done' if step2_done else ('attention' if step2_attention else ('inactive' if not step1_done else '')) }}" onclick="document.getElementById('krok-2').scrollIntoView({behavior:'smooth'})">
<span class="step-num-wrap">
<span class="step-num">{% if step2_done %}&#10003;{% else %}2{% endif %}</span>
{% if step2_attention %}<span class="step-badge-count">{{ stats.ai_not_evaluated }}</span>{% endif %}
</span>
<span class="step-label">Oceń przez AI</span>
</div>
<div class="workflow-step {{ 'done' if step3_done else ('attention' if step3_attention else ('inactive' if not step2_done else '')) }}" onclick="document.getElementById('krok-3').scrollIntoView({behavior:'smooth'})">
<span class="step-num-wrap">
<span class="step-num">{% if step3_done %}&#10003;{% else %}3{% endif %}</span>
{% if step3_attention %}<span class="step-badge-count">{{ stats.pending_news }}</span>{% endif %}
</span>
<span class="step-label">Moderuj newsy</span>
</div>
<div class="workflow-step {{ 'done' if step4_ready else ('inactive' if not step3_done else '') }}" onclick="document.getElementById('krok-4').scrollIntoView({behavior:'smooth'})">
<span class="step-num-wrap">
<span class="step-num">4</span>
</span>
<span class="step-label">Przetwórz wiedzę</span>
</div>
</div>
<div class="step-heading" id="krok-1">
<span class="step-badge">Krok 1</span>
<h2>Wyszukaj nowe artykuły</h2>
<p class="step-desc">Pobierz najnowsze artykuły z wielu źródeł (Brave, RSS, Google News)</p>
</div>
{% if stats.total_news == 0 %}
<div class="step-status-banner banner-blue">
<span class="banner-icon">&#128269;</span>
<span>Baza jest pusta. Wpisz zapytanie i kliknij <strong>Szukaj artykuły</strong>, aby pobrać pierwsze newsy.</span>
</div>
{% elif stats.ai_not_evaluated > 0 %}
<div class="step-status-banner banner-green">
<span class="banner-icon">&#9989;</span>
<span>W bazie jest <strong>{{ stats.total_news }}</strong> artykułów. Przejdź do Kroku 2, aby AI oceniło <strong>{{ stats.ai_not_evaluated }}</strong> nieocenionych.</span>
</div>
{% else %}
<div class="step-status-banner banner-green">
<span class="banner-icon">&#9989;</span>
<span>W bazie jest <strong>{{ stats.total_news }}</strong> artykułów. Wszystkie ocenione przez AI. Możesz szukać nowe.</span>
</div>
{% endif %}
<!-- Search Section -->
<div class="search-section">
<h3>Wyszukaj nowe artykuły</h3>
<p style="opacity: 0.9; margin-bottom: var(--spacing-md); font-size: var(--font-size-sm);">Multi-source search: Brave API, RSS (trojmiasto.pl, Dziennik Bałtycki), Google News, gov.pl</p>
<div class="search-form">
<input type="text" id="searchQuery" value="Zielony Okręg Przemysłowy Kaszubia" placeholder="Wpisz zapytanie...">
<button type="button" onclick="searchNews()" id="searchBtn">Szukaj artykułów</button>
</div>
<!-- Progress Container -->
<div class="progress-container" id="progressContainer">
<div class="progress-header">
<span id="progressStatus">Inicjalizacja...</span>
<span id="progressPercent">0%</span>
</div>
<div class="progress-bar-container">
<div class="progress-bar-fill" id="progressBar"></div>
</div>
<div class="progress-phases" id="progressPhases">
<!-- Phases will be rendered by JS -->
</div>
<div class="progress-steps" id="progressSteps"></div>
</div>
<!-- Results Container (shown after completion) -->
<div class="search-results-container" id="searchResultsContainer" style="display: none;">
<!-- Summary Stats -->
<div class="search-results-summary" id="searchResultsSummary"></div>
<!-- Auto-approved articles list -->
<div class="auto-approved-section" id="autoApprovedSection" style="display: none;">
<h4>✅ Artykuły automatycznie zaakceptowane (3+★)</h4>
<div class="auto-approved-list" id="autoApprovedList"></div>
</div>
<!-- Detailed Statistics Section -->
<div class="detailed-stats-section" id="detailedStatsSection" style="margin-top: var(--spacing-lg); display: none;">
<h4 style="margin-bottom: var(--spacing-sm); font-size: var(--font-size-sm); color: var(--text-secondary);">
📊 Szczegóły procesu
</h4>
<div class="detailed-stats-grid" id="detailedStatsGrid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--spacing-sm);"></div>
</div>
<!-- AI Rejected Articles Section -->
<div class="ai-rejected-section" id="aiRejectedSection" style="display: none; margin-top: var(--spacing-lg);">
<h4 style="color: #ef4444;">❌ Artykuły odrzucone przez AI</h4>
<div class="ai-rejected-list" id="aiRejectedList" style="max-height: 200px; overflow-y: auto;"></div>
</div>
<!-- OK Button to close -->
<div class="results-actions" style="margin-top: var(--spacing-xl); text-align: center; padding-top: var(--spacing-lg); border-top: 1px solid var(--border);">
<button type="button" class="btn btn-primary btn-lg" onclick="location.reload()" style="padding: var(--spacing-md) var(--spacing-2xl); font-size: var(--font-size-lg);">
✓ OK - Zamknij i odśwież
</button>
<p style="margin-top: var(--spacing-sm); font-size: var(--font-size-xs); color: var(--text-secondary);">
Kliknij aby zamknąć okno wyników i odświeżyć listę newsów
</p>
</div>
</div>
<div id="searchResult" style="margin-top: var(--spacing-md); display: none;"></div>
</div>
<!-- Transition 1→2 -->
{% if step1_done and step2_attention %}
<div class="step-transition tr-purple" onclick="document.getElementById('krok-2').scrollIntoView({behavior:'smooth'})">
<span class="transition-icon">&#129302;</span>
<div class="transition-content">
<div class="transition-title">Pora na ocenę AI</div>
<div class="transition-desc">{{ stats.ai_not_evaluated }} artykułów czeka na ocenę trafności przez Gemini AI.</div>
</div>
<a href="#krok-2" class="btn-transition" onclick="event.stopPropagation(); document.getElementById('krok-2').scrollIntoView({behavior:'smooth'})">Przejdź do Kroku 2 &rarr;</a>
</div>
{% elif step2_done %}
<div class="step-transition tr-green" onclick="document.getElementById('krok-3').scrollIntoView({behavior:'smooth'})">
<span class="transition-icon">&#9989;</span>
<div class="transition-content">
<div class="transition-title">AI skończyło pracę</div>
<div class="transition-desc">{{ stats.ai_relevant }} trafnych, {{ stats.ai_not_relevant }} nietrafnych. Czas na moderację.</div>
</div>
<a href="#krok-3" class="btn-transition" onclick="event.stopPropagation(); document.getElementById('krok-3').scrollIntoView({behavior:'smooth'})">Przejdź do Kroku 3 &rarr;</a>
</div>
{% endif %}
<div class="step-heading" id="krok-2">
<span class="step-badge">Krok 2</span>
<h2>Oceń artykuły przez AI</h2>
<p class="step-desc">Gemini AI oceni relevancję i nada gwiazdki (auto-approve 3+★)</p>
</div>
{% if stats.total_news == 0 %}
<div class="step-status-banner banner-gray">
<span class="banner-icon">&#128308;</span>
<span>Brak artykułów. Najpierw wyszukaj w <strong>Kroku 1</strong>.</span>
</div>
{% elif stats.ai_not_evaluated > 0 %}
<div class="step-status-banner banner-orange">
<span class="banner-icon">&#129302;</span>
<span><strong>{{ stats.ai_not_evaluated }}</strong> artykułów czeka na ocenę AI. Kliknij przycisk <strong>AI: Oceń {{ stats.ai_not_evaluated }}</strong> poniżej.</span>
</div>
{% else %}
<div class="step-status-banner banner-green">
<span class="banner-icon">&#9989;</span>
<span>Wszystkie ocenione! AI: <strong>{{ stats.ai_relevant }}</strong> trafnych, <strong>{{ stats.ai_not_relevant }}</strong> nietrafnych. Przejdź do <strong>Kroku 3</strong>.</span>
</div>
{% endif %}
<!-- Section 3: AI Evaluation filters -->
<div class="stats-section">
<h3 class="stats-section-title">Ocena AI (Gemini) <small>(kliknij aby wybrać)</small></h3>
<!-- AI Model Info Banner -->
<div class="ai-model-info" style="background: linear-gradient(135deg, #e0f2fe 0%, #bae6fd 100%); border: 1px solid #7dd3fc; border-radius: var(--radius); padding: var(--spacing-sm) var(--spacing-md); margin-bottom: var(--spacing-md); font-size: var(--font-size-sm);">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: var(--spacing-xs);">
<div>
<strong>🤖 Model:</strong> <code style="background: #fff; padding: 2px 6px; border-radius: 4px;">gemini-3-flash-preview</code>
</div>
<div>
<strong>📅 Prompt v2:</strong> 2026-01-15
</div>
<div title="Via Pomerania, S6, Droga Czerwona, Pakt Bezpieczeństwa, NORDA, Deklaracja Bałtycka">
<strong>✅ Nowe tematy:</strong> +7 projektów infrastrukturalnych
</div>
</div>
</div>
<div class="stats-grid" style="grid-template-columns: repeat(4, 1fr); max-width: 800px;">
<a href="?status=ai_relevant" class="stat-card filter-card success {{ 'active' if status_filter == 'ai_relevant' else '' }}">
<div class="stat-value">{{ stats.ai_relevant }}</div>
<div class="stat-label">Pasuje wg AI</div>
</a>
<a href="?status=ai_not_relevant" class="stat-card filter-card danger {{ 'active' if status_filter == 'ai_not_relevant' else '' }}">
<div class="stat-value">{{ stats.ai_not_relevant }}</div>
<div class="stat-label">Nie pasuje wg AI</div>
</a>
<a href="?status=ai_not_evaluated" class="stat-card filter-card warning {{ 'active' if status_filter == 'ai_not_evaluated' else '' }}">
<div class="stat-value">{{ stats.ai_not_evaluated }}</div>
<div class="stat-label">Nieocenione</div>
</a>
<button type="button" class="stat-card filter-card ai-action" onclick="evaluateWithAI()" id="aiEvalBtn" {% if stats.ai_not_evaluated == 0 %}disabled{% endif %} title="Gemini AI oceni {{ stats.ai_not_evaluated }} nieocenionych newsów">
<div class="stat-value" style="font-size: var(--font-size-xl);">🤖</div>
<div class="stat-label">AI: Oceń {{ stats.ai_not_evaluated }}</div>
</button>
</div>
{% if stats.ai_missing_score > 0 %}
<div class="stats-grid" style="grid-template-columns: repeat(2, 1fr); max-width: 400px; margin-top: var(--spacing-md);">
<div class="stat-card warning info-only">
<div class="stat-value">{{ stats.ai_missing_score }}</div>
<div class="stat-label">Brak gwiazdek</div>
</div>
<button type="button" class="stat-card filter-card ai-action" onclick="reevaluateScores()" id="aiRescoreBtn" title="Gemini AI przeoceni {{ stats.ai_missing_score }} newsów i doda oceny 1-5 gwiazdek">
<div class="stat-value" style="font-size: var(--font-size-xl);">🤖⭐</div>
<div class="stat-label">AI: Dodaj gwiazdki</div>
</button>
</div>
{% endif %}
<!-- Re-evaluate low score news with key topics (Via Pomerania, NORDA, etc.) -->
<div class="stats-grid" style="grid-template-columns: repeat(1, 1fr); max-width: 400px; margin-top: var(--spacing-md);">
<button type="button" class="stat-card filter-card ai-action" onclick="reevaluateLowScores()" id="aiReevalLowBtn" title="Re-ewaluacja newsów z oceną 1-2★ zawierających nowe tematy (Via Pomerania, NORDA, S6...)">
<div class="stat-value" style="font-size: var(--font-size-xl);">🔄⭐</div>
<div class="stat-label">Re-ewaluuj niskie oceny (Via Pomerania, NORDA...)</div>
</button>
</div>
<div id="aiEvalResultLegacy" style="margin-top: var(--spacing-md); display: none;"></div>
</div>
<!-- Transition 2→3 -->
{% if step3_attention %}
<div class="step-transition tr-orange" onclick="document.getElementById('krok-3').scrollIntoView({behavior:'smooth'})">
<span class="transition-icon">&#128221;</span>
<div class="transition-content">
<div class="transition-title">{{ stats.pending_news }} artykułów do przejrzenia</div>
<div class="transition-desc">Zatwierdź trafne artykuły i odrzuć nietrafne, zanim przejdziesz do przetwarzania wiedzy.</div>
</div>
<a href="#krok-3" class="btn-transition" onclick="event.stopPropagation(); document.getElementById('krok-3').scrollIntoView({behavior:'smooth'})">Przejdź do moderacji &rarr;</a>
</div>
{% elif step3_done %}
<div class="step-transition tr-green" onclick="document.getElementById('krok-4').scrollIntoView({behavior:'smooth'})">
<span class="transition-icon">&#9989;</span>
<div class="transition-content">
<div class="transition-title">Moderacja zakończona</div>
<div class="transition-desc">{{ stats.approved_news }} zatwierdzonych. Pora przetworzyć wiedzę.</div>
</div>
<a href="#krok-4" class="btn-transition" onclick="event.stopPropagation(); document.getElementById('krok-4').scrollIntoView({behavior:'smooth'})">Przetwórz wiedzę &rarr;</a>
</div>
{% endif %}
<div class="step-heading" id="krok-3">
<span class="step-badge">Krok 3</span>
<h2>Moderuj newsy</h2>
<p class="step-desc">Przeglądaj, zatwierdzaj lub odrzucaj artykuły</p>
</div>
{% if stats.pending_news > 0 %}
<div class="step-status-banner banner-orange">
<span class="banner-icon">&#128221;</span>
<span><strong>{{ stats.pending_news }}</strong> artykułów czeka na Twoją decyzję. Zatwierdź lub odrzuć.</span>
</div>
{% elif stats.approved_news > 0 %}
<div class="step-status-banner banner-green">
<span class="banner-icon">&#9989;</span>
<span>Wszystko przejrzane! <strong>{{ stats.approved_news }}</strong> zatwierdzonych. Przejdź do <strong>Kroku 4</strong>.</span>
</div>
{% else %}
<div class="step-status-banner banner-gray">
<span class="banner-icon">&#128308;</span>
<span>Brak artykułów do moderacji.</span>
</div>
{% endif %}
<!-- Section 2: News filters (clickable) -->
<div class="stats-section">
<h3 class="stats-section-title">Filtruj newsy <small>(kliknij aby wybrać)</small></h3>
<div class="stats-grid stats-grid-small">
<a href="?status=pending" class="stat-card filter-card warning {{ 'active' if status_filter == 'pending' else '' }}">
<div class="stat-value">{{ stats.pending_news }}</div>
<div class="stat-label">Oczekujących</div>
</a>
<a href="?status=approved" class="stat-card filter-card success {{ 'active' if status_filter == 'approved' else '' }}">
<div class="stat-value">{{ stats.approved_news }}</div>
<div class="stat-label">Zatwierdzonych</div>
</a>
<a href="?status=rejected" class="stat-card filter-card danger {{ 'active' if status_filter == 'rejected' else '' }}">
<div class="stat-value">{{ stats.rejected_news }}</div>
<div class="stat-label">Odrzuconych</div>
</a>
</div>
</div>
<!-- Old news warning -->
{% if stats.old_news > 0 and not show_old %}
<div class="old-news-warning">
<span>⚠️</span>
<span><strong>{{ stats.old_news }} newsów</strong> z przed 2024 roku zostało ukrytych (ZOP Kaszubia powstał w 2024).
<a href="?status={{ status_filter }}&show_old=true">Pokaż wszystkie</a> lub
<a href="#" onclick="rejectOldNews(); return false;">odrzuć wszystkie stare</a>.</span>
</div>
{% endif %}
<!-- News List -->
<div class="panel-section">
<h2>
{% if status_filter == 'pending' %}Newsy oczekujące na moderację
{% elif status_filter == 'approved' %}Newsy zatwierdzone
{% elif status_filter == 'rejected' %}Newsy odrzucone
{% elif status_filter == 'ai_relevant' %}🤖 Newsy pasujące wg AI
{% elif status_filter == 'ai_not_relevant' %}🤖 Newsy NIE pasujące wg AI
{% else %}Wszystkie newsy{% endif %}
({{ total_news_filtered }})
</h2>
{% if news_items %}
<div class="pending-news-list">
{% for news in news_items %}
<div class="pending-news-item" id="news-{{ news.id }}">
<div class="news-info">
<h4><a href="{{ news.url }}" target="_blank" rel="noopener">{{ news.title }}</a></h4>
<div class="news-meta">
{% if news.source_type %}
<span class="source-badge {{ news.source_type|replace(' ', '_') }}">
{% if news.source_type == 'brave' %}🔍 Brave
{% elif news.source_type == 'rss_local_media' %}📰 Media lokalne
{% elif news.source_type == 'rss_government' %}🏛️ Rząd
{% elif news.source_type == 'rss_aggregator' %}📡 Agregator
{% elif news.source_type == 'manual' %}✏️ Ręcznie
{% else %}{{ news.source_type }}{% endif %}
</span>
{% endif %}
<span>{{ news.source_name or news.source_domain or '-' }}</span>
{% set news_year = news.published_at.year if news.published_at else None %}
<span>{{ 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 '-') }}</span>
{% if news_year and news_year < min_year %}
<span class="old-news-badge">⚠️ Sprzed {{ min_year }}</span>
{% endif %}
{% if news.confidence_score %}
<span class="confidence-badge {{ 'high' if news.confidence_score >= 4 else ('medium' if news.confidence_score >= 2 else 'low') }}">
{% if news.source_count and news.source_count > 1 %}{{ news.source_count }} źródeł{% else %}1 źródło{% endif %}
</span>
{% endif %}
{% if news.status == 'approved' or news.status == 'auto_approved' %}
<span class="confidence-badge high">✓ {{ 'Auto' if news.status == 'auto_approved' else '' }}Zatwierdzony</span>
{% elif news.status == 'rejected' %}
<span class="confidence-badge low">✗ Odrzucony</span>
{% endif %}
{# AI Evaluation badge with star rating #}
{% if news.ai_relevant is not none %}
<span class="ai-badge score-{{ news.ai_relevance_score or 3 }}" title="{{ news.ai_evaluation_reason or '' }}">
🤖
<span class="ai-stars">
{% for i in range(1, 6) %}
<span class="{{ 'star-filled' if i <= (news.ai_relevance_score or 0) else 'star-empty' }}"></span>
{% endfor %}
</span>
</span>
{% endif %}
</div>
</div>
<div class="news-actions">
{% if news.status == 'pending' %}
<button class="btn btn-sm btn-approve" onclick="approveNews({{ news.id }})">Zatwierdź</button>
<button class="btn btn-sm btn-reject" onclick="rejectNews({{ news.id }})">Odrzuć</button>
{% elif news.status == 'rejected' %}
<button class="btn btn-sm btn-approve" onclick="approveNews({{ news.id }})">Przywróć</button>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<!-- Pagination -->
{% if total_pages > 1 %}
<div class="pagination">
{% if current_page > 1 %}
<a href="?page={{ current_page - 1 }}&status={{ status_filter }}{% if show_old %}&show_old=true{% endif %}">← Poprzednia</a>
{% else %}
<span class="disabled">← Poprzednia</span>
{% endif %}
{% for p in range(1, total_pages + 1) %}
{% if p == current_page %}
<span class="current">{{ p }}</span>
{% elif p == 1 or p == total_pages or (p >= current_page - 2 and p <= current_page + 2) %}
<a href="?page={{ p }}&status={{ status_filter }}{% if show_old %}&show_old=true{% endif %}">{{ p }}</a>
{% elif p == current_page - 3 or p == current_page + 3 %}
<span>...</span>
{% endif %}
{% endfor %}
{% if current_page < total_pages %}
<a href="?page={{ current_page + 1 }}&status={{ status_filter }}{% if show_old %}&show_old=true{% endif %}">Następna →</a>
{% else %}
<span class="disabled">Następna →</span>
{% endif %}
<span class="pagination-info">
({{ (current_page - 1) * per_page + 1 }}-{{ [current_page * per_page, total_news_filtered]|min }} z {{ total_news_filtered }})
</span>
</div>
{% endif %}
{% else %}
<div class="empty-state">
<p>Brak newsów{% if status_filter != 'all' %} o tym statusie{% endif %}.</p>
</div>
{% endif %}
</div>
<!-- Transition 3→4 -->
{% if step3_done %}
<div class="step-transition tr-blue" onclick="document.getElementById('krok-4').scrollIntoView({behavior:'smooth'})">
<span class="transition-icon">&#129504;</span>
<div class="transition-content">
<div class="transition-title">Czas na wiedzę!</div>
<div class="transition-desc">{{ stats.approved_news }} zatwierdzonych artykułów czeka na przetworzenie: scraping, ekstrakcja i embeddingi.</div>
</div>
<a href="#krok-4" class="btn-transition" onclick="event.stopPropagation(); document.getElementById('krok-4').scrollIntoView({behavior:'smooth'})">Przetwórz wiedzę &rarr;</a>
</div>
{% elif stats.pending_news > 0 %}
<div class="step-transition tr-orange">
<span class="transition-icon">&#9888;</span>
<div class="transition-content">
<div class="transition-title">Najpierw dokończ moderację</div>
<div class="transition-desc">Pozostało <strong>{{ stats.pending_news }}</strong> artykułów do przejrzenia, zanim przejdziesz do przetwarzania wiedzy.</div>
</div>
<a href="#krok-3" class="btn-transition" onclick="document.getElementById('krok-3').scrollIntoView({behavior:'smooth'})">Wróć do Kroku 3 &rarr;</a>
</div>
{% endif %}
<div class="step-heading" id="krok-4">
<span class="step-badge">Krok 4</span>
<h2>Przetwórz bazę wiedzy</h2>
<p class="step-desc">Scrapuj treść → wyekstrahuj wiedzę → wygeneruj embeddingi</p>
</div>
{% if stats.approved_news > 0 %}
<div class="step-status-banner banner-blue">
<span class="banner-icon">&#129504;</span>
<span><strong>{{ stats.approved_news }}</strong> zatwierdzonych gotowych do przetworzenia: <strong>Scrapuj</strong> &rarr; <strong>Ekstrakcja</strong> &rarr; <strong>Embeddingi</strong>.</span>
</div>
{% else %}
<div class="step-status-banner banner-gray">
<span class="banner-icon">&#128308;</span>
<span>Brak zatwierdzonych artykułów. Zatwierdź artykuły w <strong>Kroku 3</strong>.</span>
</div>
{% endif %}
<!-- AI Knowledge Base -->
<div class="panel-section">
<h2>🧠 Baza Wiedzy AI</h2>
<div id="knowledge-stats-loading" class="text-center py-4">
<span class="spinner"></span> Ładowanie statystyk...
</div>
<div id="knowledge-stats-content" style="display: none;">
<!-- Content Scraping Stats -->
<div class="stats-section">
<h3 class="stats-section-title">Scraping treści</h3>
<div class="stats-grid stats-grid-small">
<div class="stat-card info-only">
<div class="stat-value" id="kb-total-approved">-</div>
<div class="stat-label">Zatwierdzonych newsów</div>
</div>
<div class="stat-card info-only success">
<div class="stat-value" id="kb-scraped">-</div>
<div class="stat-label">Zescrapowanych</div>
</div>
<div class="stat-card info-only warning">
<div class="stat-value" id="kb-pending-scrape">-</div>
<div class="stat-label">Oczekuje na scraping</div>
</div>
<button class="stat-card filter-card ai-action" onclick="scrapeContent()" id="scrapeBtn" title="Scrapuj treść artykułów">
<div class="stat-value" style="font-size: var(--font-size-xl);">📄</div>
<div class="stat-label">Scrapuj treść</div>
</button>
</div>
</div>
<!-- Knowledge Extraction Stats -->
<div class="stats-section">
<h3 class="stats-section-title">Ekstrakcja wiedzy (Gemini AI)</h3>
<div class="stats-grid stats-grid-small">
<div class="stat-card info-only">
<div class="stat-value" id="kb-total-chunks">-</div>
<div class="stat-label">Chunks</div>
</div>
<div class="stat-card info-only">
<div class="stat-value" id="kb-total-facts">-</div>
<div class="stat-label">Faktów</div>
</div>
<div class="stat-card info-only">
<div class="stat-value" id="kb-total-entities">-</div>
<div class="stat-label">Encji</div>
</div>
<button class="stat-card filter-card ai-action" onclick="extractKnowledge()" id="extractBtn" title="Wyekstrahuj wiedzę z zescrapowanych artykułów">
<div class="stat-value" style="font-size: var(--font-size-xl);">🤖</div>
<div class="stat-label">Ekstrakcja AI</div>
</button>
</div>
</div>
<!-- Embeddings Stats -->
<div class="stats-section">
<h3 class="stats-section-title">Semantic Search (Embeddings)</h3>
<div class="stats-grid stats-grid-small">
<div class="stat-card info-only success">
<div class="stat-value" id="kb-with-embeddings">-</div>
<div class="stat-label">Z embeddingami</div>
</div>
<div class="stat-card info-only warning">
<div class="stat-value" id="kb-pending-embeddings">-</div>
<div class="stat-label">Bez embeddingów</div>
</div>
<button class="stat-card filter-card ai-action" onclick="generateEmbeddings()" id="embeddingsBtn" title="Wygeneruj embeddingi dla semantic search">
<div class="stat-value" style="font-size: var(--font-size-xl);">🔍</div>
<div class="stat-label">Generuj embeddingi</div>
</button>
</div>
</div>
<!-- Top Entities -->
<div class="stats-section" id="top-entities-section" style="display: none;">
<h3 class="stats-section-title">Najczęściej wymieniane encje</h3>
<div id="top-entities-list" class="entity-pills"></div>
</div>
<!-- Link to detailed dashboard -->
<div class="stats-section" style="text-align: center; padding-top: var(--spacing-lg);">
<a href="{{ url_for('admin.admin_zopk_knowledge_dashboard') }}" class="btn btn-primary">
📊 Szczegółowy panel bazy wiedzy →
</a>
</div>
</div>
<div id="knowledge-stats-error" style="display: none;" class="alert alert-danger">
Błąd ładowania statystyk
</div>
</div>
<details class="reference-section" id="dane-referencyjne">
<summary>Dane referencyjne (statystyki, projekty, historia)</summary>
<!-- Section 1: General stats (info only, not clickable) -->
<div class="stats-section">
<h3 class="stats-section-title">Baza wiedzy ZOP Kaszubia</h3>
<div class="stats-grid stats-grid-small">
<div class="stat-card info-only">
<div class="stat-value">{{ stats.total_projects }}</div>
<div class="stat-label">Projektów</div>
</div>
<div class="stat-card info-only">
<div class="stat-value">{{ stats.total_stakeholders }}</div>
<div class="stat-label">Interesariuszy</div>
</div>
<div class="stat-card info-only">
<div class="stat-value">{{ stats.total_resources }}</div>
<div class="stat-label">Materiałów</div>
</div>
</div>
</div>
<!-- Projects -->
<div class="panel-section">
<h2>Projekty strategiczne</h2>
{% if projects %}
<table class="projects-table">
<thead>
<tr>
<th>Nazwa</th>
<th>Typ</th>
<th>Status</th>
<th>Region</th>
</tr>
</thead>
<tbody>
{% for project in projects %}
<tr>
<td>
<strong>{{ project.name }}</strong>
<br><small class="text-muted">{{ project.slug }}</small>
</td>
<td>{{ project.project_type or '-' }}</td>
<td>
<span class="status-badge status-{{ project.status }}">
{% if project.status == 'planned' %}Planowany{% elif project.status == 'in_progress' %}W realizacji{% else %}{{ project.status }}{% endif %}
</span>
</td>
<td>{{ project.region or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<p>Brak projektów.</p>
</div>
{% endif %}
</div>
<!-- Recent Fetch Jobs -->
{% if fetch_jobs %}
<div class="panel-section">
<h2>Ostatnie wyszukiwania</h2>
<div class="fetch-jobs-list">
{% for job in fetch_jobs %}
<div class="fetch-job">
<div>
<strong>{{ job.search_query }}</strong>
<br><small class="text-muted">{{ job.created_at|local_time('%d.%m.%Y %H:%M') if job.created_at else '-' }}</small>
</div>
<div>
Znaleziono: {{ job.results_found or 0 }} | Nowych: {{ job.results_new or 0 }}
</div>
<span class="fetch-job-status {{ job.status }}">{{ job.status }}</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</details>
<!-- AI Evaluation Modal -->
<div class="modal-overlay" id="aiEvalModal">
<div class="modal">
<div id="aiEvalConfirm">
<div class="modal-icon">🤖</div>
<h3>Ocena AI (Gemini)</h3>
<p class="modal-description">Czy chcesz uruchomić ocenę AI dla <strong>max 20</strong> z {{ stats.ai_not_evaluated }} nieocenionych newsów?</p>
<p class="modal-note">Gemini oceni każdy news pod kątem związku z ZOP Kaszubia.<br>
<strong>Uwaga:</strong> Proces trwa ok. 30-60 sekund (każdy news = osobne zapytanie do AI).</p>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeAiEvalModal()">Anuluj</button>
<button type="button" class="btn btn-primary" onclick="startAiEvaluation()">
<span>🚀</span> Rozpocznij ocenę
</button>
</div>
</div>
<div id="aiEvalProgress" style="display: none;">
<div class="modal-icon spinning">⚙️</div>
<h3>Trwa ocena AI...</h3>
<p class="modal-description" id="aiEvalStatusText">Analizowanie newsów przez Gemini...</p>
<div class="ai-progress-bar">
<div class="ai-progress-fill" id="aiProgressFill"></div>
</div>
<p class="modal-note" id="aiEvalCounter">Proszę czekać...</p>
</div>
<div id="aiEvalResult" style="display: none;">
<div class="modal-icon" id="aiResultIcon"></div>
<h3 id="aiResultTitle">Ocena zakończona</h3>
<div class="ai-result-stats" id="aiResultStats"></div>
<div class="modal-actions">
<button type="button" class="btn btn-primary" onclick="closeAiEvalModal(); location.reload();">Zamknij i odśwież</button>
</div>
</div>
</div>
</div>
<!-- AI Operations Progress Modal -->
<div class="modal-overlay" id="aiOpsModal">
<div class="modal modal-wide">
<div class="modal-header">
<div class="modal-icon" id="aiOpsIcon"></div>
<h3 id="aiOpsTitle">Operacja w toku...</h3>
<button type="button" class="modal-close-btn" id="aiOpsCloseBtn" style="display: none;" onclick="closeAiOpsModal()">×</button>
</div>
<!-- Progress Section -->
<div class="ai-ops-progress">
<div class="ai-progress-bar">
<div class="ai-progress-fill" id="aiOpsProgressFill" style="width: 0%;"></div>
</div>
<div class="ai-ops-stats">
<span id="aiOpsPercent">0%</span>
<span id="aiOpsCounter">0 / 0</span>
</div>
</div>
<!-- Current Operation -->
<div class="ai-ops-current" id="aiOpsCurrentOp">
<span class="spinner-small"></span>
<span id="aiOpsCurrentText">Inicjalizacja...</span>
</div>
<!-- Live Log (scrollable) -->
<div class="ai-ops-log-container">
<div class="ai-ops-log-header">
<span>📋 Log operacji</span>
<span id="aiOpsLogCount">0 wpisów</span>
</div>
<div class="ai-ops-log" id="aiOpsLog">
<!-- Log entries will be added here -->
</div>
</div>
<!-- Summary Stats (shown on complete) -->
<div class="ai-ops-summary" id="aiOpsSummary" style="display: none;">
<div class="summary-row">
<span class="summary-label">✓ Sukces:</span>
<span class="summary-value" id="aiOpsSummarySuccess">0</span>
</div>
<div class="summary-row">
<span class="summary-label">⊘ Pominięte:</span>
<span class="summary-value" id="aiOpsSummaryFailed">0</span>
</div>
<div class="summary-row" id="aiOpsSummarySkippedRow" style="display: none;">
<span class="summary-label">⊘ Pominięto:</span>
<span class="summary-value" id="aiOpsSummarySkipped">0</span>
</div>
<div class="summary-row">
<span class="summary-label">⏱️ Czas:</span>
<span class="summary-value" id="aiOpsSummaryTime">0s</span>
</div>
</div>
<!-- Actions -->
<div class="modal-actions" id="aiOpsActions" style="display: none;">
<button type="button" class="btn btn-primary" onclick="closeAiOpsModal(); loadKnowledgeStats();">Zamknij i odśwież</button>
</div>
</div>
</div>
<style>
/* AI Operations Modal Styles */
.modal-wide {
max-width: 700px;
width: 95%;
}
.modal-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
position: relative;
}
.modal-header h3 {
margin: 0;
flex: 1;
}
.modal-close-btn {
position: absolute;
right: 0;
top: 0;
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--text-muted);
padding: 5px 10px;
}
.modal-close-btn:hover {
color: var(--text-primary);
}
.ai-ops-progress {
margin-bottom: 15px;
}
.ai-ops-stats {
display: flex;
justify-content: space-between;
font-size: 14px;
margin-top: 8px;
color: var(--text-muted);
}
#aiOpsPercent {
font-weight: 600;
color: var(--color-primary);
}
.ai-ops-current {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 15px;
background: var(--bg-secondary);
border-radius: 8px;
margin-bottom: 15px;
font-size: 14px;
}
.ai-ops-log-container {
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
margin-bottom: 15px;
}
.ai-ops-log-header {
display: flex;
justify-content: space-between;
padding: 8px 12px;
background: var(--bg-tertiary);
font-size: 13px;
font-weight: 500;
border-bottom: 1px solid var(--border-color);
}
.ai-ops-log {
height: 250px;
overflow-y: auto;
padding: 10px;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 12px;
background: var(--bg-primary);
}
.log-entry {
padding: 4px 8px;
margin: 2px 0;
border-radius: 4px;
display: flex;
align-items: flex-start;
gap: 8px;
}
.log-entry.processing {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.log-entry.success {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
.log-entry.failed {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.log-entry.skipped {
background: rgba(234, 179, 8, 0.1);
color: #eab308;
}
.log-entry.complete {
background: rgba(168, 85, 247, 0.1);
color: #a855f7;
font-weight: 600;
}
.log-time {
color: var(--text-muted);
font-size: 11px;
white-space: nowrap;
}
.log-message {
flex: 1;
word-break: break-word;
}
.ai-ops-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
padding: 15px;
background: var(--bg-secondary);
border-radius: 8px;
margin-bottom: 15px;
}
.summary-row {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.summary-label {
font-size: 12px;
color: var(--text-muted);
}
.summary-value {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
}
/* Spinning icon animation */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.modal-icon.spinning {
animation: spin 2s linear infinite;
}
/* Responsive log height */
@media (max-height: 800px) {
.ai-ops-log {
height: 180px;
}
}
</style>
<!-- Add News Modal -->
<div class="modal-overlay" id="addNewsModal">
<div class="modal">
<h3>Dodaj news ręcznie</h3>
<form id="addNewsForm">
<div class="form-group">
<label for="newsTitle">Tytuł *</label>
<input type="text" id="newsTitle" name="title" required>
</div>
<div class="form-group">
<label for="newsUrl">URL artykułu *</label>
<input type="url" id="newsUrl" name="url" required placeholder="https://...">
</div>
<div class="form-group">
<label for="newsDescription">Opis</label>
<textarea id="newsDescription" name="description" placeholder="Krótki opis artykułu..."></textarea>
</div>
<div class="form-group">
<label for="newsSource">Źródło</label>
<input type="text" id="newsSource" name="source_name" placeholder="np. trojmiasto.pl">
</div>
<div class="form-group">
<label for="newsProject">Projekt</label>
<select id="newsProject" name="project_id">
<option value="">-- Brak --</option>
{% for project in projects %}
<option value="{{ project.id }}">{{ project.name }}</option>
{% endfor %}
</select>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeAddNewsModal()">Anuluj</button>
<button type="submit" class="btn btn-primary">Dodaj</button>
</div>
</form>
</div>
</div>
<!-- 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">Czy na pewno chcesz kontynuować?</p>
</div>
<!-- Optional input for prompt -->
<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>
<!-- Toast notifications -->
<div id="toastContainer" style="position: fixed; top: 80px; right: 20px; z-index: 1100; display: flex; flex-direction: column; gap: 10px;"></div>
<style>
/* Toast styles */
.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); }
.toast.warning { border-left-color: #f59e0b; }
.toast-icon { font-size: 1.2em; }
.toast-message { flex: 1; }
@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; }
}
/* Spinner */
.spinner, .spinner-small {
display: inline-block;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.spinner {
width: 24px;
height: 24px;
}
.spinner-small {
width: 16px;
height: 16px;
border-width: 2px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Entity Pills */
.entity-pills {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-sm);
}
.entity-pill {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-full, 9999px);
font-size: var(--font-size-sm);
background: var(--surface-secondary, #f5f5f5);
border: 1px solid var(--border);
}
.entity-pill small {
color: var(--text-muted);
}
.entity-pill.entity-company { background: #dbeafe; border-color: #93c5fd; }
.entity-pill.entity-person { background: #fef3c7; border-color: #fcd34d; }
.entity-pill.entity-place { background: #d1fae5; border-color: #6ee7b7; }
.entity-pill.entity-organization { background: #e0e7ff; border-color: #a5b4fc; }
.entity-pill.entity-project { background: #fce7f3; border-color: #f9a8d4; }
/* Text utilities */
.text-center { text-align: center; }
.py-4 { padding-top: 1rem; padding-bottom: 1rem; }
/* Alert */
.alert { padding: var(--spacing-md); border-radius: var(--radius); }
.alert-danger { background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; }
</style>
{% endblock %}
{% block extra_js %}
const csrfToken = '{{ csrf_token() }}';
// ===========================================
// Universal Modal System
// ===========================================
let confirmModalResolve = null;
function showConfirm(message, options = {}) {
return new Promise((resolve) => {
confirmModalResolve = resolve;
const modal = document.getElementById('confirmModal');
const icon = document.getElementById('confirmModalIcon');
const title = document.getElementById('confirmModalTitle');
const msg = document.getElementById('confirmModalMessage');
const cancelBtn = document.getElementById('confirmModalCancel');
const okBtn = document.getElementById('confirmModalOk');
const inputGroup = document.getElementById('confirmModalInputGroup');
const input = document.getElementById('confirmModalInput');
icon.textContent = options.icon || '❓';
title.textContent = options.title || 'Potwierdzenie';
msg.innerHTML = message;
cancelBtn.textContent = options.cancelText || 'Anuluj';
okBtn.textContent = options.okText || 'OK';
okBtn.className = 'btn ' + (options.okClass || 'btn-primary');
// Show/hide input for prompt mode
if (options.showInput) {
inputGroup.style.display = 'block';
document.getElementById('confirmModalInputLabel').textContent = options.inputLabel || 'Wprowadź wartość:';
input.value = options.inputValue || '';
input.placeholder = options.inputPlaceholder || '';
} else {
inputGroup.style.display = 'none';
}
// Show/hide cancel button for alert mode
cancelBtn.style.display = options.alertOnly ? 'none' : '';
modal.classList.add('active');
if (options.showInput) {
setTimeout(() => input.focus(), 100);
}
});
}
function closeConfirmModal(result) {
document.getElementById('confirmModal').classList.remove('active');
if (confirmModalResolve) {
confirmModalResolve(result);
confirmModalResolve = null;
}
}
// Modal button handlers
document.getElementById('confirmModalOk').addEventListener('click', () => {
const inputGroup = document.getElementById('confirmModalInputGroup');
if (inputGroup.style.display !== 'none') {
closeConfirmModal(document.getElementById('confirmModalInput').value);
} else {
closeConfirmModal(true);
}
});
document.getElementById('confirmModalCancel').addEventListener('click', () => {
closeConfirmModal(false);
});
document.getElementById('confirmModal').addEventListener('click', (e) => {
if (e.target.id === 'confirmModal') {
closeConfirmModal(false);
}
});
// Keyboard support
document.getElementById('confirmModal').addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeConfirmModal(false);
if (e.key === 'Enter' && document.getElementById('confirmModalInputGroup').style.display === 'none') {
closeConfirmModal(true);
}
});
// Toast notification system
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}`;
toast.innerHTML = `
<span class="toast-icon">${icons[type] || icons.info}</span>
<span class="toast-message">${message}</span>
`;
container.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideOut 0.3s ease forwards';
setTimeout(() => toast.remove(), 300);
}, duration);
}
function openAddNewsModal() {
document.getElementById('addNewsModal').classList.add('active');
}
function closeAddNewsModal() {
document.getElementById('addNewsModal').classList.remove('active');
document.getElementById('addNewsForm').reset();
}
document.getElementById('addNewsForm').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = {
title: document.getElementById('newsTitle').value,
url: document.getElementById('newsUrl').value,
description: document.getElementById('newsDescription').value,
source_name: document.getElementById('newsSource').value,
project_id: document.getElementById('newsProject').value || null
};
try {
const response = await fetch('{{ url_for("admin.admin_zopk_news_add") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify(formData)
});
const data = await response.json();
if (data.success) {
closeAddNewsModal();
showToast('News został dodany', '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 approveNews(newsId) {
const confirmed = await showConfirm('Czy na pewno chcesz zatwierdzić ten news?', {
icon: '✓',
title: 'Zatwierdź news',
okText: 'Zatwierdź',
okClass: 'btn-success'
});
if (!confirmed) return;
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) {
document.getElementById(`news-${newsId}`).remove();
showToast('News został zatwierdzony', 'success');
} 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; // User cancelled
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) {
document.getElementById(`news-${newsId}`).remove();
showToast('News został odrzucony', 'success');
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia: ' + error.message, 'error');
}
}
async function rejectOldNews() {
const minYear = {{ min_year }};
const confirmed = await showConfirm(
`Czy na pewno chcesz odrzucić wszystkie newsy sprzed <strong>${minYear}</strong> roku?<br><br>` +
`<small style="color: var(--text-muted)">ZOP Kaszubia powstał w 2024 roku, więc starsze artykuły najprawdopodobniej nie dotyczą tego projektu.</small>`,
{
icon: '🗑️',
title: 'Odrzuć stare newsy',
okText: 'Odrzuć wszystkie',
okClass: 'btn-danger'
}
);
if (!confirmed) {
return;
}
try {
const response = await fetch('/admin/zopk/news/reject-old', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ min_year: minYear })
});
const data = await response.json();
if (data.success) {
showToast(data.message, 'success', 3000);
setTimeout(() => location.reload(), 1500);
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia: ' + error.message, 'error');
}
}
// AI Evaluation Modal functions
function evaluateWithAI() {
// Open modal instead of confirm()
document.getElementById('aiEvalModal').classList.add('active');
// Reset modal state
document.getElementById('aiEvalConfirm').style.display = 'block';
document.getElementById('aiEvalProgress').style.display = 'none';
document.getElementById('aiEvalResult').style.display = 'none';
}
function closeAiEvalModal() {
document.getElementById('aiEvalModal').classList.remove('active');
}
async function startAiEvaluation() {
const btn = document.getElementById('aiEvalBtn');
const progressFill = document.getElementById('aiProgressFill');
const statusText = document.getElementById('aiEvalStatusText');
const counter = document.getElementById('aiEvalCounter');
// Switch to progress view
document.getElementById('aiEvalConfirm').style.display = 'none';
document.getElementById('aiEvalProgress').style.display = 'block';
// Disable button
btn.disabled = true;
btn.querySelector('.stat-label').textContent = 'AI pracuje...';
// Animated progress with better messaging
let progress = 0;
let seconds = 0;
const progressInterval = setInterval(() => {
progress = Math.min(progress + Math.random() * 8, 95);
seconds++;
progressFill.style.width = `${progress}%`;
// Show elapsed time and encouraging messages
if (seconds < 30) {
statusText.textContent = `Gemini analizuje newsy... ${Math.round(progress)}%`;
} else if (seconds < 60) {
statusText.textContent = `Prawie gotowe... ${Math.round(progress)}%`;
} else {
statusText.textContent = `Jeszcze chwilę... ${Math.round(progress)}%`;
}
counter.textContent = `Upłynęło: ${seconds}s (każdy news wymaga wywołania AI)`;
}, 1000);
try {
// Use AbortController for timeout (3 minutes max)
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 180000);
const response = await fetch('/admin/zopk/news/evaluate-ai', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ limit: 20 }), // Reduced limit for faster response
signal: controller.signal
});
clearTimeout(timeoutId);
clearInterval(progressInterval);
progressFill.style.width = '100%';
const data = await response.json();
// Switch to result view
document.getElementById('aiEvalProgress').style.display = 'none';
document.getElementById('aiEvalResult').style.display = 'block';
const resultIcon = document.getElementById('aiResultIcon');
const resultTitle = document.getElementById('aiResultTitle');
const resultStats = document.getElementById('aiResultStats');
if (data.success) {
resultIcon.textContent = '✓';
resultIcon.className = 'modal-icon success';
resultTitle.textContent = 'Ocena zakończona!';
resultStats.innerHTML = `
<div class="ai-stat-item success">
<div class="value">${data.relevant_count}</div>
<div class="label">Pasuje do ZOP Kaszubia</div>
</div>
<div class="ai-stat-item danger">
<div class="value">${data.not_relevant_count}</div>
<div class="label">Nie pasuje</div>
</div>
<div class="ai-stat-item warning">
<div class="value">${data.total_evaluated}</div>
<div class="label">Ocenionych</div>
</div>
<p style="text-align: center; margin-top: var(--spacing-md); color: var(--text-secondary);">
Odświeżam stronę za 2 sekundy...
</p>
`;
// Auto-reload after showing results
setTimeout(() => location.reload(), 2000);
} else {
resultIcon.textContent = '✗';
resultIcon.className = 'modal-icon error';
resultTitle.textContent = 'Wystąpił błąd';
resultStats.innerHTML = `<p style="text-align: center; color: var(--danger);">${data.error}</p>`;
btn.disabled = false;
btn.querySelector('.stat-label').textContent = 'AI: Oceń ponownie';
}
} catch (error) {
clearInterval(progressInterval);
document.getElementById('aiEvalProgress').style.display = 'none';
document.getElementById('aiEvalResult').style.display = 'block';
const resultIcon = document.getElementById('aiResultIcon');
const resultTitle = document.getElementById('aiResultTitle');
const resultStats = document.getElementById('aiResultStats');
resultIcon.textContent = '✗';
resultIcon.className = 'modal-icon error';
// Check if timeout
if (error.name === 'AbortError') {
resultTitle.textContent = 'Przekroczono limit czasu';
resultStats.innerHTML = `<p style="text-align: center; color: var(--danger);">
Przetwarzanie trwało zbyt długo (>3 min).<br>
Spróbuj ponownie lub sprawdź logi serwera.
</p>`;
} else {
resultTitle.textContent = 'Błąd połączenia';
resultStats.innerHTML = `<p style="text-align: center; color: var(--danger);">${error.message}</p>`;
}
btn.disabled = false;
btn.querySelector('.stat-label').textContent = 'AI: Oceń ponownie';
}
}
// Re-evaluate items missing scores (upgrade to 1-5 stars)
async function reevaluateScores() {
const btn = document.getElementById('aiRescoreBtn');
if (!btn) return;
// Show modal for re-evaluation
document.getElementById('aiEvalModal').classList.add('active');
document.getElementById('aiEvalConfirm').style.display = 'none';
document.getElementById('aiEvalProgress').style.display = 'block';
document.getElementById('aiEvalResult').style.display = 'none';
const progressBar = document.getElementById('aiProgressFill');
const progressStatus = document.getElementById('aiProgressStatus');
btn.disabled = true;
btn.querySelector('.stat-label').textContent = 'AI pracuje...';
// Simulated progress for better UX
let progress = 0;
let startTime = Date.now();
const progressInterval = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
if (progress < 90) {
progress += Math.random() * 3;
progressBar.style.width = Math.min(progress, 90) + '%';
}
progressStatus.textContent = `AI dodaje gwiazdki... (${elapsed}s)`;
}, 500);
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 180000);
const response = await fetch('/admin/zopk/news/reevaluate-scores', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ limit: 30 }),
signal: controller.signal
});
clearTimeout(timeoutId);
clearInterval(progressInterval);
const data = await response.json();
progressBar.style.width = '100%';
document.getElementById('aiEvalProgress').style.display = 'none';
document.getElementById('aiEvalResult').style.display = 'block';
const resultIcon = document.getElementById('aiResultIcon');
const resultTitle = document.getElementById('aiResultTitle');
const resultStats = document.getElementById('aiResultStats');
if (data.success) {
resultIcon.textContent = '⭐';
resultIcon.className = 'modal-icon success';
resultTitle.textContent = 'Gwiazdki dodane!';
resultStats.innerHTML = `
<div style="display: flex; gap: 20px; justify-content: center; margin-bottom: var(--spacing-md);">
<div><strong>${data.total_evaluated}</strong><br><small>Ocenionych</small></div>
<div style="color: var(--success);"><strong>${data.relevant_count}</strong><br><small>Pasuje</small></div>
<div style="color: var(--danger);"><strong>${data.not_relevant_count}</strong><br><small>Nie pasuje</small></div>
</div>
<p style="text-align: center; color: var(--text-secondary);">${data.message}</p>
`;
setTimeout(() => location.reload(), 2000);
} else {
resultIcon.textContent = '✗';
resultIcon.className = 'modal-icon error';
resultTitle.textContent = 'Wystąpił błąd';
resultStats.innerHTML = `<p style="text-align: center; color: var(--danger);">${data.error}</p>`;
btn.disabled = false;
btn.querySelector('.stat-label').textContent = 'AI: Spróbuj ponownie';
}
} catch (error) {
clearInterval(progressInterval);
document.getElementById('aiEvalProgress').style.display = 'none';
document.getElementById('aiEvalResult').style.display = 'block';
const resultIcon = document.getElementById('aiResultIcon');
const resultTitle = document.getElementById('aiResultTitle');
const resultStats = document.getElementById('aiResultStats');
resultIcon.textContent = '✗';
resultIcon.className = 'modal-icon error';
resultTitle.textContent = 'Błąd połączenia';
resultStats.innerHTML = `<p style="text-align: center; color: var(--danger);">${error.message}</p>`;
btn.disabled = false;
btn.querySelector('.stat-label').textContent = 'AI: Spróbuj ponownie';
}
}
// Re-evaluate low score news (1-2★) with key topics (Via Pomerania, NORDA, etc.)
async function reevaluateLowScores() {
const btn = document.getElementById('aiReevalLowBtn');
if (!btn) return;
// Show modal for re-evaluation
document.getElementById('aiEvalModal').classList.add('active');
document.getElementById('aiEvalConfirm').style.display = 'none';
document.getElementById('aiEvalProgress').style.display = 'block';
document.getElementById('aiEvalResult').style.display = 'none';
const progressBar = document.getElementById('aiProgressFill');
const progressStatus = document.getElementById('aiProgressStatus');
btn.disabled = true;
btn.querySelector('.stat-label').textContent = 'Re-ewaluacja...';
// Simulated progress for better UX
let progress = 0;
let startTime = Date.now();
const progressInterval = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
if (progress < 90) {
progress += Math.random() * 3;
progressBar.style.width = Math.min(progress, 90) + '%';
}
progressStatus.textContent = `Re-ewaluacja newsów z Via Pomerania, NORDA... (${elapsed}s)`;
}, 500);
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 180000);
const response = await fetch('/admin/zopk/news/reevaluate-low-scores', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ limit: 50 }),
signal: controller.signal
});
clearTimeout(timeoutId);
clearInterval(progressInterval);
const data = await response.json();
progressBar.style.width = '100%';
document.getElementById('aiEvalProgress').style.display = 'none';
document.getElementById('aiEvalResult').style.display = 'block';
const resultIcon = document.getElementById('aiResultIcon');
const resultTitle = document.getElementById('aiResultTitle');
const resultStats = document.getElementById('aiResultStats');
if (data.success) {
resultIcon.textContent = '🔄';
resultIcon.className = 'modal-icon success';
resultTitle.textContent = 'Re-ewaluacja zakończona!';
// Build details list if available
let detailsHtml = '';
if (data.details && data.details.length > 0) {
detailsHtml = `
<div style="max-height: 200px; overflow-y: auto; margin-top: var(--spacing-md); text-align: left; font-size: var(--font-size-sm);">
${data.details.map(d => `
<div style="padding: 4px 0; border-bottom: 1px solid var(--border);">
<span style="color: ${d.change === 'upgraded' ? 'var(--success)' : d.change === 'downgraded' ? 'var(--danger)' : 'var(--text-secondary)'};">
${d.old_score}★ → ${d.new_score}★
</span>
${d.title}...
</div>
`).join('')}
</div>
`;
}
resultStats.innerHTML = `
<div style="display: flex; gap: 20px; justify-content: center; margin-bottom: var(--spacing-md);">
<div><strong>${data.total_evaluated}</strong><br><small>Ocenionych</small></div>
<div style="color: var(--success);"><strong>${data.upgraded}</strong><br><small>⬆️ Podwyższono</small></div>
<div style="color: var(--danger);"><strong>${data.downgraded}</strong><br><small>⬇️ Obniżono</small></div>
<div style="color: var(--text-secondary);"><strong>${data.unchanged}</strong><br><small>Bez zmian</small></div>
</div>
<p style="text-align: center; color: var(--text-secondary);">${data.message}</p>
${detailsHtml}
`;
if (data.upgraded > 0 || data.downgraded > 0) {
setTimeout(() => location.reload(), 3000);
}
} else {
resultIcon.textContent = '✗';
resultIcon.className = 'modal-icon error';
resultTitle.textContent = 'Wystąpił błąd';
resultStats.innerHTML = `<p style="text-align: center; color: var(--danger);">${data.error}</p>`;
btn.disabled = false;
btn.querySelector('.stat-label').textContent = 'Re-ewaluuj niskie oceny';
}
} catch (error) {
clearInterval(progressInterval);
document.getElementById('aiEvalProgress').style.display = 'none';
document.getElementById('aiEvalResult').style.display = 'block';
const resultIcon = document.getElementById('aiResultIcon');
const resultTitle = document.getElementById('aiResultTitle');
const resultStats = document.getElementById('aiResultStats');
resultIcon.textContent = '✗';
resultIcon.className = 'modal-icon error';
resultTitle.textContent = 'Błąd połączenia';
resultStats.innerHTML = `<p style="text-align: center; color: var(--danger);">${error.message}</p>`;
btn.disabled = false;
btn.querySelector('.stat-label').textContent = 'Re-ewaluuj niskie oceny';
}
}
// Close modal on click outside
document.getElementById('aiEvalModal').addEventListener('click', function(e) {
if (e.target === this) {
closeAiEvalModal();
}
});
// Source names mapping for progress display
const SOURCE_NAMES = {
'brave': '🔍 Brave Search API',
'trojmiasto': '📰 trojmiasto.pl',
'dziennik_baltycki': '📰 Dziennik Bałtycki',
'gov_mon': '🏛️ Ministerstwo Obrony Narodowej',
'gov_przemysl': '🏛️ Min. Rozwoju i Technologii',
'google_news_zopk': '📡 Google News (ZOP Kaszubia)',
'google_news_offshore': '📡 Google News (offshore)',
'google_news_nuclear': '📡 Google News (elektrownia jądrowa)',
'google_news_samsonowicz': '📡 Google News (Samsonowicz)',
'google_news_kongsberg': '📡 Google News (Kongsberg)',
// New local media sources
'google_news_norda_fm': '📻 Norda FM',
'google_news_ttm': '📺 Twoja Telewizja Morska',
'google_news_nadmorski24': '📰 Nadmorski24.pl',
'google_news_samsonowicz_fb': '👤 Facebook (Samsonowicz)',
'google_news_norda': '📡 Google News (Norda Biznes)',
'google_news_spoko': '📡 Google News (Spoko Gospodarcze)'
};
const ALL_SOURCES = Object.keys(SOURCE_NAMES);
async function searchNews() {
const btn = document.getElementById('searchBtn');
const progressContainer = document.getElementById('progressContainer');
const progressBar = document.getElementById('progressBar');
const progressStatus = document.getElementById('progressStatus');
const progressPercent = document.getElementById('progressPercent');
const progressPhases = document.getElementById('progressPhases');
const progressSteps = document.getElementById('progressSteps');
const resultsContainer = document.getElementById('searchResultsContainer');
const resultsSummary = document.getElementById('searchResultsSummary');
const autoApprovedSection = document.getElementById('autoApprovedSection');
const autoApprovedList = document.getElementById('autoApprovedList');
const query = document.getElementById('searchQuery').value;
// Process phases definition
const PHASES = [
{ id: 'search', icon: '🔍', label: 'Wyszukiwanie' },
{ id: 'filter', icon: '🚫', label: 'Filtrowanie' },
{ id: 'ai', icon: '🤖', label: 'Analiza AI' },
{ id: 'save', icon: '💾', label: 'Zapisywanie' }
];
const PHASE_ORDER = { 'search': 0, 'filter': 1, 'ai': 2, 'save': 3 };
// Reset UI
btn.disabled = true;
btn.textContent = 'Szukam...';
resultsContainer.style.display = 'none';
autoApprovedSection.style.display = 'none';
progressContainer.classList.add('active');
progressBar.style.width = '0%';
progressBar.style.background = '';
progressPercent.textContent = '0%';
// Build progress phases UI
progressPhases.innerHTML = PHASES.map(phase => `
<div class="progress-phase pending" id="phase-${phase.id}">
<span class="progress-phase-icon">${phase.icon}</span>
<span>${phase.label}</span>
</div>
`).join('');
progressSteps.innerHTML = '<div class="progress-step active"><span class="progress-step-icon"></span><span>Inicjalizacja...</span></div>';
// Helper: update phase indicators based on current phase
function updatePhaseUI(currentPhase) {
const currentIdx = PHASE_ORDER[currentPhase] ?? -1;
PHASES.forEach((phase, idx) => {
const el = document.getElementById(`phase-${phase.id}`);
if (el) {
el.classList.remove('pending', 'active', 'completed');
if (idx < currentIdx) el.classList.add('completed');
else if (idx === currentIdx) el.classList.add('active');
else el.classList.add('pending');
}
});
}
// Helper: display final results (shared between SSE and fallback)
function displaySearchResults(data) {
// Mark all phases as completed
PHASES.forEach(phase => {
const el = document.getElementById(`phase-${phase.id}`);
if (el) {
el.classList.remove('pending', 'active');
el.classList.add('completed');
}
});
progressBar.style.width = '100%';
progressPercent.textContent = '100%';
progressStatus.textContent = '✅ Wyszukiwanie zakończone!';
// Display process log as steps
if (data.process_log && data.process_log.length > 0) {
const importantSteps = data.process_log.filter(log =>
log.step.includes('done') || log.step.includes('complete') || log.phase === 'complete'
).slice(-6);
progressSteps.innerHTML = importantSteps.map(log => `
<div class="progress-step completed">
<span class="progress-step-icon"></span>
<span>${log.message}</span>
${log.count > 0 ? `<span class="progress-step-count">${log.count}</span>` : ''}
</div>
`).join('');
}
setTimeout(() => { progressContainer.classList.remove('active'); }, 1500);
resultsContainer.style.display = 'block';
resultsSummary.innerHTML = `
<div class="summary-stat info">
<div class="value">${data.total_found || 0}</div>
<div class="label">Znaleziono</div>
</div>
<div class="summary-stat warning">
<div class="value">${(data.blacklisted || 0) + (data.keyword_filtered || 0)}</div>
<div class="label">Odfiltrowano</div>
</div>
<div class="summary-stat error">
<div class="value">${data.ai_rejected || 0}</div>
<div class="label">AI odrzucił</div>
</div>
<div class="summary-stat success">
<div class="value">${data.ai_approved || 0}</div>
<div class="label">AI zaakceptował</div>
</div>
<div class="summary-stat success">
<div class="value">${data.saved_new || 0}</div>
<div class="label">Nowe w bazie</div>
</div>
`;
if (data.auto_approved_articles && data.auto_approved_articles.length > 0) {
autoApprovedSection.style.display = 'block';
autoApprovedList.innerHTML = data.auto_approved_articles.map(article => {
const stars = '★'.repeat(article.score) + '☆'.repeat(5 - article.score);
return `
<div class="auto-approved-item">
<span class="stars">${stars}</span>
<span class="title">${article.title}</span>
<span class="source">${article.source || ''}</span>
</div>
`;
}).join('');
}
const detailedStatsSection = document.getElementById('detailedStatsSection');
const detailedStatsGrid = document.getElementById('detailedStatsGrid');
const aiRejectedSection = document.getElementById('aiRejectedSection');
const aiRejectedList = document.getElementById('aiRejectedList');
const detailedStats = [
{ label: 'Zapytanie', value: query || 'ZOP Kaszubia', icon: '🔍', type: 'info' },
{ label: 'Źródła przeszukane', value: `Brave API + RSS`, icon: '📡', type: 'info' },
{ label: 'Łącznie znaleziono', value: data.total_found || 0, icon: '📰', type: 'info' },
{ label: 'Zablokowane (blacklist)', value: data.blacklisted || 0, icon: '🚫', type: 'warning' },
{ label: 'Odfiltrowane (słowa kluczowe)', value: data.keyword_filtered || 0, icon: '🔤', type: 'warning' },
{ label: 'Przekazane do AI', value: data.sent_to_ai || 0, icon: '🤖', type: 'info' },
{ label: 'AI zaakceptował (3+★)', value: data.ai_approved || 0, icon: '✅', type: 'success' },
{ label: 'AI odrzucił (1-2★)', value: data.ai_rejected || 0, icon: '❌', type: 'error' },
{ label: 'Duplikaty (już w bazie)', value: data.duplicates || 0, icon: '🔄', type: 'info' },
{ label: 'Zapisano nowych', value: data.saved_new || 0, icon: '💾', type: 'success' },
{ label: 'Do bazy wiedzy', value: data.knowledge_entities_created || 0, icon: '🧠', type: 'success' },
{ label: 'Czas przetwarzania', value: data.processing_time ? `${data.processing_time.toFixed(1)}s` : '-', icon: '⏱️', type: 'info' }
];
detailedStatsGrid.innerHTML = detailedStats.map(stat => `
<div class="detailed-stat-item" style="
background: ${stat.type === 'success' ? '#dcfce7' : stat.type === 'error' ? '#fee2e2' : stat.type === 'warning' ? '#fef3c7' : '#f3f4f6'};
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
display: flex;
justify-content: space-between;
align-items: center;
">
<span style="display: flex; align-items: center; gap: var(--spacing-xs);">
<span>${stat.icon}</span>
<span style="font-size: var(--font-size-sm);">${stat.label}</span>
</span>
<strong style="color: ${stat.type === 'success' ? '#166534' : stat.type === 'error' ? '#991b1b' : stat.type === 'warning' ? '#92400e' : '#374151'};">
${stat.value}
</strong>
</div>
`).join('');
detailedStatsSection.style.display = 'block';
if (data.ai_rejected_articles && data.ai_rejected_articles.length > 0) {
aiRejectedSection.style.display = 'block';
aiRejectedList.innerHTML = data.ai_rejected_articles.map(article => {
const stars = '★'.repeat(article.score || 1) + '☆'.repeat(5 - (article.score || 1));
return `
<div class="ai-rejected-item" style="
padding: var(--spacing-xs) var(--spacing-sm);
border-bottom: 1px solid #fee2e2;
font-size: var(--font-size-sm);
display: flex;
gap: var(--spacing-sm);
align-items: center;
">
<span style="color: #f59e0b;">${stars}</span>
<span style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${article.title}</span>
<span style="color: var(--text-secondary); font-size: var(--font-size-xs);">${article.source || ''}</span>
</div>
`;
}).join('');
}
resultsContainer.scrollIntoView({ behavior: 'smooth', block: 'start' });
btn.disabled = false;
btn.textContent = 'Szukaj artykułów';
}
// Helper: display error
function displaySearchError(message) {
progressBar.style.width = '100%';
progressBar.style.background = '#fca5a5';
progressStatus.textContent = 'Błąd wyszukiwania';
PHASES.forEach(phase => {
const el = document.getElementById(`phase-${phase.id}`);
if (el) el.classList.remove('active');
});
progressSteps.innerHTML = `
<div class="progress-step" style="color: #fca5a5;">
<span class="progress-step-icon"></span>
<span>${message}</span>
</div>
`;
btn.disabled = false;
btn.textContent = 'Szukaj artykułów';
}
// Try SSE streaming first, fallback to old POST endpoint
try {
const sseUrl = `{{ url_for("admin.api_zopk_search_news_stream") }}?query=${encodeURIComponent(query)}`;
const source = new EventSource(sseUrl);
let sseWorking = false;
source.onmessage = function(event) {
sseWorking = true;
const data = JSON.parse(event.data);
if (data.type === 'progress') {
updatePhaseUI(data.phase);
progressStatus.textContent = data.message;
// Update progress steps with latest message
const stepHtml = `
<div class="progress-step active">
<span class="progress-step-icon"></span>
<span>${data.message}</span>
${data.total > 0 ? `<span class="progress-step-count">${data.current}/${data.total}</span>` : ''}
</div>
`;
// Keep last 4 steps visible
const existingSteps = progressSteps.querySelectorAll('.progress-step');
if (existingSteps.length >= 4) {
existingSteps[0].remove();
}
progressSteps.insertAdjacentHTML('beforeend', stepHtml);
// Calculate overall progress based on phase + sub-progress
const phaseIdx = PHASE_ORDER[data.phase] ?? 0;
let phasePct = 0;
if (data.total > 0) {
phasePct = data.current / data.total;
}
const overallPct = Math.round(((phaseIdx + phasePct) / PHASES.length) * 100);
progressBar.style.width = `${Math.min(overallPct, 95)}%`;
progressPercent.textContent = `${Math.min(overallPct, 95)}%`;
}
else if (data.type === 'heartbeat') {
// Keep alive, no UI update
}
else if (data.type === 'complete') {
source.close();
displaySearchResults(data.results);
}
else if (data.type === 'error') {
source.close();
displaySearchError(data.message);
}
};
source.onerror = function() {
source.close();
if (!sseWorking) {
// SSE never worked, fallback to old POST endpoint
searchNewsFallback(query, PHASES, btn, progressContainer, progressBar, progressStatus, progressPercent, progressPhases, progressSteps, resultsContainer, resultsSummary, autoApprovedSection, autoApprovedList);
} else {
displaySearchError('Połączenie ze streamem przerwane');
}
};
} catch (error) {
displaySearchError('Błąd: ' + error.message);
}
}
// Fallback: old synchronous POST endpoint (used when SSE fails)
async function searchNewsFallback(query, PHASES, btn, progressContainer, progressBar, progressStatus, progressPercent, progressPhases, progressSteps, resultsContainer, resultsSummary, autoApprovedSection, autoApprovedList) {
let currentPhaseIdx = 0;
const phaseMessages = [
'Przeszukuję źródła (Brave API + RSS)...',
'Filtruję wyniki (blacklist, słowa kluczowe)...',
'Analiza AI (Gemini ocenia artykuły)...',
'Zapisuję do bazy wiedzy...'
];
const progressInterval = setInterval(() => {
if (currentPhaseIdx < PHASES.length) {
PHASES.forEach((phase, idx) => {
const el = document.getElementById(`phase-${phase.id}`);
if (el) {
el.classList.remove('pending', 'active', 'completed');
if (idx < currentPhaseIdx) el.classList.add('completed');
else if (idx === currentPhaseIdx) el.classList.add('active');
else el.classList.add('pending');
}
});
progressStatus.textContent = phaseMessages[currentPhaseIdx];
const percent = Math.round(((currentPhaseIdx + 1) / PHASES.length) * 80);
progressBar.style.width = `${percent}%`;
progressPercent.textContent = `${percent}%`;
currentPhaseIdx++;
}
}, 2500);
try {
const response = await fetch('{{ url_for("admin.api_zopk_search_news") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ query: query })
});
clearInterval(progressInterval);
const data = await response.json();
if (data.success) {
// Reuse shared display function (need to reconstruct it inline since helpers are scoped)
PHASES.forEach(phase => {
const el = document.getElementById(`phase-${phase.id}`);
if (el) {
el.classList.remove('pending', 'active');
el.classList.add('completed');
}
});
progressBar.style.width = '100%';
progressPercent.textContent = '100%';
progressStatus.textContent = '✅ Wyszukiwanie zakończone (tryb klasyczny)';
if (data.process_log && data.process_log.length > 0) {
const importantSteps = data.process_log.filter(log =>
log.step.includes('done') || log.step.includes('complete') || log.phase === 'complete'
).slice(-6);
progressSteps.innerHTML = importantSteps.map(log => `
<div class="progress-step completed">
<span class="progress-step-icon"></span>
<span>${log.message}</span>
${log.count > 0 ? `<span class="progress-step-count">${log.count}</span>` : ''}
</div>
`).join('');
}
setTimeout(() => { progressContainer.classList.remove('active'); }, 1500);
resultsContainer.style.display = 'block';
resultsSummary.innerHTML = `
<div class="summary-stat info"><div class="value">${data.total_found || 0}</div><div class="label">Znaleziono</div></div>
<div class="summary-stat warning"><div class="value">${(data.blacklisted || 0) + (data.keyword_filtered || 0)}</div><div class="label">Odfiltrowano</div></div>
<div class="summary-stat error"><div class="value">${data.ai_rejected || 0}</div><div class="label">AI odrzucił</div></div>
<div class="summary-stat success"><div class="value">${data.ai_approved || 0}</div><div class="label">AI zaakceptował</div></div>
<div class="summary-stat success"><div class="value">${data.saved_new || 0}</div><div class="label">Nowe w bazie</div></div>
`;
if (data.auto_approved_articles && data.auto_approved_articles.length > 0) {
autoApprovedSection.style.display = 'block';
autoApprovedList.innerHTML = data.auto_approved_articles.map(article => {
const stars = '★'.repeat(article.score) + '☆'.repeat(5 - article.score);
return `<div class="auto-approved-item"><span class="stars">${stars}</span><span class="title">${article.title}</span><span class="source">${article.source || ''}</span></div>`;
}).join('');
}
resultsContainer.scrollIntoView({ behavior: 'smooth', block: 'start' });
btn.disabled = false;
btn.textContent = 'Szukaj artykułów';
} else {
progressBar.style.width = '100%';
progressBar.style.background = '#fca5a5';
progressStatus.textContent = 'Błąd wyszukiwania';
progressSteps.innerHTML = `<div class="progress-step" style="color: #fca5a5;"><span class="progress-step-icon"></span><span>Błąd: ${data.error}</span></div>`;
btn.disabled = false;
btn.textContent = 'Szukaj artykułów';
}
} catch (error) {
clearInterval(progressInterval);
progressBar.style.width = '100%';
progressBar.style.background = '#fca5a5';
progressStatus.textContent = 'Błąd połączenia';
progressSteps.innerHTML = `<div class="progress-step" style="color: #fca5a5;"><span class="progress-step-icon"></span><span>Błąd: ${error.message}</span></div>`;
btn.disabled = false;
btn.textContent = 'Szukaj artykułów';
}
}
// Close modal on click outside
document.getElementById('addNewsModal').addEventListener('click', function(e) {
if (e.target === this) {
closeAddNewsModal();
}
});
// ===========================================
// AI Knowledge Base Functions
// ===========================================
async function loadKnowledgeStats() {
try {
const response = await fetch('/admin/zopk/knowledge/stats');
const data = await response.json();
if (data.success) {
document.getElementById('knowledge-stats-loading').style.display = 'none';
document.getElementById('knowledge-stats-content').style.display = 'block';
// Scraping stats
document.getElementById('kb-total-approved').textContent = data.articles?.total_approved || 0;
document.getElementById('kb-scraped').textContent = data.articles?.scraped || 0;
document.getElementById('kb-pending-scrape').textContent = data.articles?.pending_scrape || 0;
// Knowledge base stats
document.getElementById('kb-total-chunks').textContent = data.knowledge_base?.total_chunks || 0;
document.getElementById('kb-total-facts').textContent = data.knowledge_base?.total_facts || 0;
document.getElementById('kb-total-entities').textContent = data.knowledge_base?.total_entities || 0;
// Embeddings stats
document.getElementById('kb-with-embeddings').textContent = data.knowledge_base?.chunks_with_embeddings || 0;
document.getElementById('kb-pending-embeddings').textContent = data.knowledge_base?.chunks_without_embeddings || 0;
// Top entities
if (data.top_entities && data.top_entities.length > 0) {
document.getElementById('top-entities-section').style.display = 'block';
const list = document.getElementById('top-entities-list');
list.innerHTML = data.top_entities.map(e =>
`<span class="entity-pill entity-${e.type}">${e.name} <small>(${e.mentions})</small></span>`
).join('');
}
} else {
throw new Error(data.error || 'Unknown error');
}
} catch (error) {
console.error('Error loading knowledge stats:', error);
document.getElementById('knowledge-stats-loading').style.display = 'none';
document.getElementById('knowledge-stats-error').style.display = 'block';
document.getElementById('knowledge-stats-error').textContent = `Błąd: ${error.message}`;
}
}
// ===========================================
// AI Operations Modal Functions
// ===========================================
let aiOpsEventSource = null;
let aiOpsLogEntries = 0;
function openAiOpsModal(title, icon) {
const modal = document.getElementById('aiOpsModal');
document.getElementById('aiOpsTitle').textContent = title;
document.getElementById('aiOpsIcon').textContent = icon;
document.getElementById('aiOpsIcon').classList.add('spinning');
// Reset state
document.getElementById('aiOpsProgressFill').style.width = '0%';
document.getElementById('aiOpsPercent').textContent = '0%';
document.getElementById('aiOpsCounter').textContent = '0 / 0';
document.getElementById('aiOpsCurrentText').textContent = 'Inicjalizacja...';
document.getElementById('aiOpsLog').innerHTML = '';
document.getElementById('aiOpsSummary').style.display = 'none';
document.getElementById('aiOpsActions').style.display = 'none';
document.getElementById('aiOpsCloseBtn').style.display = 'none';
document.getElementById('aiOpsCurrentOp').style.display = 'flex';
aiOpsLogEntries = 0;
document.getElementById('aiOpsLogCount').textContent = '0 wpisów';
modal.classList.add('active');
}
function closeAiOpsModal() {
const modal = document.getElementById('aiOpsModal');
modal.classList.remove('active');
// Close SSE connection if active
if (aiOpsEventSource) {
aiOpsEventSource.close();
aiOpsEventSource = null;
}
}
function addAiOpsLogEntry(status, message) {
const log = document.getElementById('aiOpsLog');
const time = new Date().toLocaleTimeString('pl-PL', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const entry = document.createElement('div');
entry.className = `log-entry ${status}`;
entry.innerHTML = `
<span class="log-time">${time}</span>
<span class="log-message">${message}</span>
`;
log.appendChild(entry);
log.scrollTop = log.scrollHeight;
aiOpsLogEntries++;
document.getElementById('aiOpsLogCount').textContent = `${aiOpsLogEntries} wpisów`;
}
function updateAiOpsProgress(data) {
// Update progress bar
if (data.percent !== undefined) {
document.getElementById('aiOpsProgressFill').style.width = `${data.percent}%`;
document.getElementById('aiOpsPercent').textContent = `${Math.round(data.percent)}%`;
}
// Update counter
if (data.current !== undefined && data.total !== undefined) {
document.getElementById('aiOpsCounter').textContent = `${data.current} / ${data.total}`;
}
// Update current operation text
if (data.message) {
document.getElementById('aiOpsCurrentText').textContent = data.message;
}
// Add log entry
if (data.status && data.message) {
addAiOpsLogEntry(data.status, data.message);
}
}
function completeAiOpsModal(data) {
// Stop spinning
document.getElementById('aiOpsIcon').classList.remove('spinning');
document.getElementById('aiOpsIcon').textContent = data.status === 'error' ? '❌' : '✅';
document.getElementById('aiOpsTitle').textContent = data.status === 'error' ? 'Błąd operacji' : 'Operacja zakończona';
// Hide current operation
document.getElementById('aiOpsCurrentOp').style.display = 'none';
// Show summary
const details = data.details || {};
document.getElementById('aiOpsSummarySuccess').textContent = details.success || details.scraped || 0;
document.getElementById('aiOpsSummaryFailed').textContent = details.failed || 0;
if (details.skipped !== undefined) {
document.getElementById('aiOpsSummarySkippedRow').style.display = 'flex';
document.getElementById('aiOpsSummarySkipped').textContent = details.skipped;
}
if (details.processing_time) {
document.getElementById('aiOpsSummaryTime').textContent = `${details.processing_time}s`;
}
document.getElementById('aiOpsSummary').style.display = 'grid';
document.getElementById('aiOpsActions').style.display = 'flex';
document.getElementById('aiOpsCloseBtn').style.display = 'block';
}
function startSSEOperation(endpoint, title, icon, limit) {
openAiOpsModal(title, icon);
const url = `${endpoint}?limit=${limit}`;
aiOpsEventSource = new EventSource(url);
aiOpsEventSource.onmessage = function(event) {
const data = JSON.parse(event.data);
if (data.status === 'complete' || data.status === 'error') {
aiOpsEventSource.close();
aiOpsEventSource = null;
completeAiOpsModal(data);
} else {
updateAiOpsProgress(data);
}
};
aiOpsEventSource.onerror = function(event) {
console.error('SSE error:', event);
aiOpsEventSource.close();
aiOpsEventSource = null;
completeAiOpsModal({ status: 'error', message: 'Błąd połączenia', details: {} });
};
}
// ===========================================
// AI Knowledge Base Functions (with SSE)
// ===========================================
async function scrapeContent() {
const confirmed = await showConfirm(
'Czy chcesz rozpocząć scrapowanie treści artykułów?<br><br>' +
'<small>Proces pobierze pełną treść z zatwierdzonych newsów które jeszcze nie mają treści.<br>' +
'Postęp będzie wyświetlany na żywo.</small>',
{ icon: '📄', title: 'Scraping treści', okText: 'Rozpocznij', okClass: 'btn-primary' }
);
if (!confirmed) return;
startSSEOperation('/admin/zopk/news/scrape-content/stream', 'Scraping treści artykułów', '📄', 30);
}
async function extractKnowledge() {
const confirmed = await showConfirm(
'Czy chcesz uruchomić ekstrakcję wiedzy przez AI?<br><br>' +
'<small>Gemini AI przeanalizuje zescrapowane artykuły i wyekstrahuje:<br>' +
'• Chunks (fragmenty tekstu)<br>' +
'• Fakty (daty, liczby, decyzje)<br>' +
'• Encje (firmy, osoby, projekty)<br><br>' +
'Postęp będzie wyświetlany na żywo.</small>',
{ icon: '🤖', title: 'Ekstrakcja wiedzy', okText: 'Uruchom AI', okClass: 'btn-primary' }
);
if (!confirmed) return;
startSSEOperation('/admin/zopk/knowledge/extract/stream', 'Ekstrakcja wiedzy (Gemini AI)', '🤖', 10);
}
async function generateEmbeddings() {
const confirmed = await showConfirm(
'Czy chcesz wygenerować embeddingi dla semantic search?<br><br>' +
'<small>Google Text Embedding API przekształci tekst w wektory 768-wymiarowe.<br>' +
'Embeddingi umożliwiają inteligentne wyszukiwanie w bazie wiedzy.<br><br>' +
'Postęp będzie wyświetlany na żywo.</small>',
{ icon: '🔍', title: 'Generowanie embeddingów', okText: 'Generuj', okClass: 'btn-primary' }
);
if (!confirmed) return;
startSSEOperation('/admin/zopk/knowledge/embeddings/stream', 'Generowanie embeddingów', '🔍', 50);
}
// Keep old code for backward compatibility (non-SSE version - can be removed later)
async function scrapeContentOld() {
const btn = document.getElementById('scrapeBtn');
const originalContent = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-small"></span>';
try {
const response = await fetch('/admin/zopk/news/scrape-content', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({ limit: 30 })
});
const data = await response.json();
if (data.success) {
showToast(data.message || `Zescrapowano ${data.scraped || 0} artykułów`, 'success');
loadKnowledgeStats();
} else {
showToast(data.error || 'Błąd scrapowania', 'error');
}
} catch (error) {
showToast(`Błąd: ${error.message}`, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = originalContent;
}
}
async function extractKnowledgeOld() {
const btn = document.getElementById('extractBtn');
const originalContent = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-small"></span>';
try {
const response = await fetch('/admin/zopk/knowledge/extract', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({ limit: 10 })
});
const data = await response.json();
if (data.success) {
showToast(data.message || 'Ekstrakcja zakończona', 'success');
loadKnowledgeStats();
} else {
showToast(data.error || 'Błąd ekstrakcji', 'error');
}
} catch (error) {
showToast(`Błąd: ${error.message}`, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = originalContent;
}
}
async function generateEmbeddingsOld() {
const btn = document.getElementById('embeddingsBtn');
const originalContent = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-small"></span>';
try {
const response = await fetch('/admin/zopk/knowledge/embeddings', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({ limit: 50 })
});
const data = await response.json();
if (data.success) {
showToast(data.message || 'Embeddingi wygenerowane', 'success');
loadKnowledgeStats();
} else {
showToast(data.error || 'Błąd generowania', 'error');
}
} catch (error) {
showToast(`Błąd: ${error.message}`, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = originalContent;
}
}
// Load knowledge stats on page load
document.addEventListener('DOMContentLoaded', loadKnowledgeStats);
{% endblock %}