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
After refactoring to blueprints, templates still used bare endpoint names
(e.g., url_for('admin_zopk')) instead of prefixed names (e.g.,
url_for('admin.admin_zopk')). While most worked via backward-compat aliases,
api_zopk_search_news was missing from the alias list causing 500 on /admin/zopk.
Fixed 19 template files and added missing alias for api_zopk_search_news.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
702 lines
21 KiB
HTML
702 lines
21 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title %}Duplikaty Encji - ZOPK Baza Wiedzy{% endblock %}
|
||
|
||
{% block extra_css %}
|
||
<style>
|
||
.page-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: var(--spacing-xl);
|
||
}
|
||
|
||
.page-header h1 {
|
||
font-size: var(--font-size-2xl);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.breadcrumb {
|
||
display: flex;
|
||
gap: var(--spacing-xs);
|
||
color: var(--text-secondary);
|
||
font-size: var(--font-size-sm);
|
||
margin-bottom: var(--spacing-lg);
|
||
}
|
||
|
||
.breadcrumb a {
|
||
color: var(--primary);
|
||
text-decoration: none;
|
||
}
|
||
|
||
.breadcrumb a:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.filters-bar {
|
||
display: flex;
|
||
gap: var(--spacing-md);
|
||
align-items: center;
|
||
margin-bottom: var(--spacing-xl);
|
||
padding: var(--spacing-md);
|
||
background: var(--surface);
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow);
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.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-group select,
|
||
.filter-group input {
|
||
padding: 6px 12px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
font-size: var(--font-size-sm);
|
||
}
|
||
|
||
.duplicates-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-lg);
|
||
}
|
||
|
||
.duplicate-card {
|
||
background: var(--surface);
|
||
border-radius: var(--radius-lg);
|
||
box-shadow: var(--shadow);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.duplicate-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: var(--spacing-md) var(--spacing-lg);
|
||
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
||
border-bottom: 1px solid #fbbf24;
|
||
}
|
||
|
||
.duplicate-type {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
font-weight: 600;
|
||
color: #92400e;
|
||
}
|
||
|
||
.similarity-badge {
|
||
padding: 4px 10px;
|
||
border-radius: var(--radius);
|
||
font-size: var(--font-size-sm);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.similarity-high {
|
||
background: #dcfce7;
|
||
color: #166534;
|
||
}
|
||
|
||
.similarity-medium {
|
||
background: #fef3c7;
|
||
color: #92400e;
|
||
}
|
||
|
||
.similarity-low {
|
||
background: #fee2e2;
|
||
color: #991b1b;
|
||
}
|
||
|
||
.duplicate-body {
|
||
display: grid;
|
||
grid-template-columns: 1fr auto 1fr;
|
||
gap: var(--spacing-lg);
|
||
padding: var(--spacing-lg);
|
||
}
|
||
|
||
.entity-card {
|
||
padding: var(--spacing-md);
|
||
background: var(--background);
|
||
border-radius: var(--radius);
|
||
border: 2px solid transparent;
|
||
cursor: pointer;
|
||
transition: var(--transition);
|
||
}
|
||
|
||
.entity-card:hover {
|
||
border-color: var(--primary);
|
||
}
|
||
|
||
.entity-card.selected {
|
||
border-color: var(--primary);
|
||
background: #f0fdf4;
|
||
}
|
||
|
||
.entity-card.selected-duplicate {
|
||
border-color: #ef4444;
|
||
background: #fee2e2;
|
||
}
|
||
|
||
.entity-name {
|
||
font-size: var(--font-size-lg);
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
margin-bottom: var(--spacing-xs);
|
||
}
|
||
|
||
.entity-meta {
|
||
display: flex;
|
||
gap: var(--spacing-md);
|
||
font-size: var(--font-size-sm);
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.entity-meta span {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.merge-arrow {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: var(--spacing-sm);
|
||
}
|
||
|
||
.merge-arrow svg {
|
||
width: 32px;
|
||
height: 32px;
|
||
color: var(--primary);
|
||
}
|
||
|
||
.merge-arrow span {
|
||
font-size: var(--font-size-xs);
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.duplicate-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: var(--spacing-md);
|
||
padding: var(--spacing-md) var(--spacing-lg);
|
||
border-top: 1px solid var(--border);
|
||
background: var(--background);
|
||
}
|
||
|
||
.btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
padding: 8px 16px;
|
||
border-radius: var(--radius);
|
||
font-size: var(--font-size-sm);
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: var(--transition);
|
||
text-decoration: none;
|
||
border: none;
|
||
}
|
||
|
||
.btn-primary {
|
||
background: var(--primary);
|
||
color: white;
|
||
}
|
||
|
||
.btn-primary:hover {
|
||
background: var(--primary-dark);
|
||
}
|
||
|
||
.btn-danger {
|
||
background: #ef4444;
|
||
color: white;
|
||
}
|
||
|
||
.btn-danger:hover {
|
||
background: #dc2626;
|
||
}
|
||
|
||
.btn-secondary {
|
||
background: var(--background);
|
||
color: var(--text-primary);
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
.btn-secondary:hover {
|
||
background: var(--surface);
|
||
}
|
||
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: var(--spacing-2xl);
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.empty-state svg {
|
||
width: 64px;
|
||
height: 64px;
|
||
color: var(--text-muted);
|
||
margin-bottom: var(--spacing-md);
|
||
}
|
||
|
||
/* 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;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
|
||
.modal-overlay.active {
|
||
display: flex;
|
||
}
|
||
|
||
.modal {
|
||
background: var(--surface);
|
||
border-radius: var(--radius-lg);
|
||
max-width: 600px;
|
||
width: 90%;
|
||
max-height: 80vh;
|
||
overflow-y: auto;
|
||
box-shadow: var(--shadow-lg);
|
||
}
|
||
|
||
.modal-header {
|
||
padding: var(--spacing-lg);
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.modal-header h2 {
|
||
font-size: var(--font-size-xl);
|
||
}
|
||
|
||
.modal-body {
|
||
padding: var(--spacing-lg);
|
||
}
|
||
|
||
.modal-footer {
|
||
padding: var(--spacing-lg);
|
||
border-top: 1px solid var(--border);
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: var(--spacing-md);
|
||
}
|
||
|
||
.preview-section {
|
||
margin-bottom: var(--spacing-lg);
|
||
}
|
||
|
||
.preview-section h4 {
|
||
font-size: var(--font-size-sm);
|
||
color: var(--text-secondary);
|
||
margin-bottom: var(--spacing-sm);
|
||
}
|
||
|
||
.preview-entities {
|
||
display: grid;
|
||
grid-template-columns: 1fr auto 1fr;
|
||
gap: var(--spacing-md);
|
||
align-items: center;
|
||
margin-bottom: var(--spacing-lg);
|
||
}
|
||
|
||
.preview-entity {
|
||
padding: var(--spacing-md);
|
||
background: var(--background);
|
||
border-radius: var(--radius);
|
||
}
|
||
|
||
.preview-entity.keep {
|
||
border: 2px solid var(--primary);
|
||
}
|
||
|
||
.preview-entity.delete {
|
||
border: 2px solid #ef4444;
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.preview-stats {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||
gap: var(--spacing-sm);
|
||
}
|
||
|
||
.preview-stat {
|
||
padding: var(--spacing-sm);
|
||
background: var(--background);
|
||
border-radius: var(--radius-sm);
|
||
text-align: center;
|
||
}
|
||
|
||
.preview-stat-value {
|
||
font-size: var(--font-size-xl);
|
||
font-weight: 700;
|
||
color: var(--primary);
|
||
}
|
||
|
||
.preview-stat-label {
|
||
font-size: var(--font-size-xs);
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.name-input {
|
||
width: 100%;
|
||
padding: var(--spacing-sm);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
font-size: var(--font-size-base);
|
||
margin-top: var(--spacing-xs);
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.duplicate-body {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.merge-arrow {
|
||
transform: rotate(90deg);
|
||
}
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="container">
|
||
<div class="breadcrumb">
|
||
<a href="{{ url_for('admin.admin_zopk') }}">Panel ZOPK</a>
|
||
<span>›</span>
|
||
<a href="{{ url_for('admin.admin_zopk_knowledge_dashboard') }}">Baza Wiedzy</a>
|
||
<span>›</span>
|
||
<span>Duplikaty Encji</span>
|
||
</div>
|
||
|
||
<div class="page-header">
|
||
<h1>🔀 Duplikaty Encji</h1>
|
||
</div>
|
||
|
||
<div class="filters-bar">
|
||
<form method="get" style="display: contents;">
|
||
<div class="filter-group">
|
||
<label for="entity_type">Typ encji:</label>
|
||
<select name="entity_type" id="entity_type" onchange="this.form.submit()">
|
||
<option value="">Wszystkie</option>
|
||
{% for etype in entity_types %}
|
||
<option value="{{ etype }}" {% if etype == selected_type %}selected{% endif %}>{{ etype }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="filter-group">
|
||
<label for="min_similarity">Min. podobieństwo:</label>
|
||
<input type="range" name="min_similarity" id="min_similarity"
|
||
min="0.3" max="0.9" step="0.1"
|
||
value="{{ min_similarity }}"
|
||
onchange="document.getElementById('sim_value').textContent = this.value; this.form.submit()">
|
||
<span id="sim_value">{{ min_similarity }}</span>
|
||
</div>
|
||
</form>
|
||
<div style="margin-left: auto;">
|
||
Znaleziono: <strong>{{ duplicates|length }}</strong> par
|
||
</div>
|
||
</div>
|
||
|
||
{% if duplicates %}
|
||
<div class="duplicates-list">
|
||
{% for dup in duplicates %}
|
||
<div class="duplicate-card" data-pair-id="{{ loop.index }}">
|
||
<div class="duplicate-header">
|
||
<div class="duplicate-type">
|
||
<span>{{ dup.entity1.entity_type }}</span>
|
||
</div>
|
||
<span class="similarity-badge {% if dup.similarity > 0.8 %}similarity-high{% elif dup.similarity > 0.6 %}similarity-medium{% else %}similarity-low{% endif %}">
|
||
{{ (dup.similarity * 100)|round|int }}% podobieństwo
|
||
{% if dup.match_type == 'substring' %}(substring){% endif %}
|
||
</span>
|
||
</div>
|
||
<div class="duplicate-body">
|
||
<div class="entity-card"
|
||
onclick="selectEntity(this, {{ dup.entity1.id }}, 'primary')"
|
||
data-id="{{ dup.entity1.id }}"
|
||
data-name="{{ dup.entity1.name }}">
|
||
<div class="entity-name">{{ dup.entity1.name }}</div>
|
||
<div class="entity-meta">
|
||
<span>📊 {{ dup.entity1.mentions_count }} wzmianek</span>
|
||
{% if dup.entity1.is_verified %}
|
||
<span>✅ Zweryfikowano</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="merge-arrow">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||
<polyline points="12 5 19 12 12 19"></polyline>
|
||
</svg>
|
||
<span>połącz</span>
|
||
</div>
|
||
|
||
<div class="entity-card"
|
||
onclick="selectEntity(this, {{ dup.entity2.id }}, 'duplicate')"
|
||
data-id="{{ dup.entity2.id }}"
|
||
data-name="{{ dup.entity2.name }}">
|
||
<div class="entity-name">{{ dup.entity2.name }}</div>
|
||
<div class="entity-meta">
|
||
<span>📊 {{ dup.entity2.mentions_count }} wzmianek</span>
|
||
{% if dup.entity2.is_verified %}
|
||
<span>✅ Zweryfikowano</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="duplicate-actions">
|
||
<button class="btn btn-secondary" onclick="skipPair({{ loop.index }})">
|
||
⏭️ Pomiń
|
||
</button>
|
||
<button class="btn btn-primary" onclick="openMergeModal({{ loop.index }}, {{ dup.entity1.id }}, {{ dup.entity2.id }})">
|
||
🔀 Połącz encje
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% else %}
|
||
<div class="empty-state">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<circle cx="12" cy="12" r="10"></circle>
|
||
<path d="M16 16s-1.5-2-4-2-4 2-4 2"></path>
|
||
<line x1="9" y1="9" x2="9.01" y2="9"></line>
|
||
<line x1="15" y1="9" x2="15.01" y2="9"></line>
|
||
</svg>
|
||
<h3>Brak duplikatów do wyświetlenia</h3>
|
||
<p>Spróbuj zmniejszyć próg podobieństwa lub wybierz inny typ encji.</p>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<!-- Merge Preview Modal -->
|
||
<div class="modal-overlay" id="mergeModal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<h2>🔀 Podgląd połączenia encji</h2>
|
||
</div>
|
||
<div class="modal-body" id="mergePreviewContent">
|
||
<p>Ładowanie...</p>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeMergeModal()">Anuluj</button>
|
||
<button class="btn btn-danger" id="confirmMergeBtn" onclick="confirmMerge()">
|
||
🔀 Połącz encje
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
let currentPrimaryId = null;
|
||
let currentDuplicateId = null;
|
||
let currentNewName = null;
|
||
|
||
function selectEntity(element, id, role) {
|
||
const card = element.closest('.duplicate-card');
|
||
const entities = card.querySelectorAll('.entity-card');
|
||
|
||
// Reset selection
|
||
entities.forEach(e => {
|
||
e.classList.remove('selected', 'selected-duplicate');
|
||
});
|
||
|
||
// If primary clicked, mark it and mark other as duplicate
|
||
if (role === 'primary') {
|
||
element.classList.add('selected');
|
||
entities.forEach(e => {
|
||
if (e !== element) e.classList.add('selected-duplicate');
|
||
});
|
||
}
|
||
}
|
||
|
||
function skipPair(pairId) {
|
||
const card = document.querySelector(`[data-pair-id="${pairId}"]`);
|
||
card.style.opacity = '0.3';
|
||
card.style.pointerEvents = 'none';
|
||
}
|
||
|
||
function openMergeModal(pairId, id1, id2) {
|
||
const card = document.querySelector(`[data-pair-id="${pairId}"]`);
|
||
const entities = card.querySelectorAll('.entity-card');
|
||
|
||
// Get selected primary
|
||
let primaryId = id1;
|
||
let duplicateId = id2;
|
||
|
||
entities.forEach(e => {
|
||
if (e.classList.contains('selected')) {
|
||
primaryId = parseInt(e.dataset.id);
|
||
}
|
||
if (e.classList.contains('selected-duplicate')) {
|
||
duplicateId = parseInt(e.dataset.id);
|
||
}
|
||
});
|
||
|
||
// If nothing selected, use the one with more mentions
|
||
if (!card.querySelector('.selected')) {
|
||
const e1 = entities[0];
|
||
const e2 = entities[1];
|
||
e1.classList.add('selected');
|
||
e2.classList.add('selected-duplicate');
|
||
}
|
||
|
||
currentPrimaryId = primaryId;
|
||
currentDuplicateId = duplicateId;
|
||
|
||
// Show modal and fetch preview
|
||
document.getElementById('mergeModal').classList.add('active');
|
||
fetchMergePreview(primaryId, duplicateId);
|
||
}
|
||
|
||
function closeMergeModal() {
|
||
document.getElementById('mergeModal').classList.remove('active');
|
||
currentPrimaryId = null;
|
||
currentDuplicateId = null;
|
||
}
|
||
|
||
async function fetchMergePreview(primaryId, duplicateId) {
|
||
const content = document.getElementById('mergePreviewContent');
|
||
content.innerHTML = '<p>Ładowanie podglądu...</p>';
|
||
|
||
try {
|
||
const response = await fetch('/api/zopk/knowledge/duplicates/preview', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRFToken': '{{ csrf_token() }}'
|
||
},
|
||
body: JSON.stringify({
|
||
primary_id: primaryId,
|
||
duplicate_id: duplicateId
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
const p = data.preview;
|
||
currentNewName = p.primary.name;
|
||
|
||
content.innerHTML = `
|
||
<div class="preview-section">
|
||
<h4>Encje do połączenia</h4>
|
||
<div class="preview-entities">
|
||
<div class="preview-entity keep">
|
||
<strong>✅ Zachowaj</strong>
|
||
<div class="entity-name">${p.primary.name}</div>
|
||
<div class="entity-meta">
|
||
<span>📊 ${p.primary.mentions_count} wzmianek</span>
|
||
</div>
|
||
</div>
|
||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||
<polyline points="12 5 19 12 12 19"></polyline>
|
||
</svg>
|
||
<div class="preview-entity delete">
|
||
<strong>🗑️ Usuń</strong>
|
||
<div class="entity-name">${p.duplicate.name}</div>
|
||
<div class="entity-meta">
|
||
<span>📊 ${p.duplicate.mentions_count} wzmianek</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-section">
|
||
<h4>Co zostanie przeniesione</h4>
|
||
<div class="preview-stats">
|
||
<div class="preview-stat">
|
||
<div class="preview-stat-value">${p.transfers.mentions}</div>
|
||
<div class="preview-stat-label">Wzmianki</div>
|
||
</div>
|
||
<div class="preview-stat">
|
||
<div class="preview-stat-value">${p.transfers.facts || 0}</div>
|
||
<div class="preview-stat-label">Fakty</div>
|
||
</div>
|
||
<div class="preview-stat">
|
||
<div class="preview-stat-value">${p.transfers.relations_source + p.transfers.relations_target}</div>
|
||
<div class="preview-stat-label">Relacje</div>
|
||
</div>
|
||
<div class="preview-stat">
|
||
<div class="preview-stat-value">${p.result.new_mentions_count}</div>
|
||
<div class="preview-stat-label">Wynik wzmianek</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-section">
|
||
<h4>Nowa nazwa encji (opcjonalnie)</h4>
|
||
<input type="text" class="name-input" id="newNameInput"
|
||
value="${p.primary.name}"
|
||
placeholder="Pozostaw pustą aby zachować obecną nazwę">
|
||
</div>
|
||
`;
|
||
} else {
|
||
content.innerHTML = `<p style="color: #ef4444;">Błąd: ${data.error}</p>`;
|
||
}
|
||
} catch (error) {
|
||
content.innerHTML = `<p style="color: #ef4444;">Błąd połączenia: ${error.message}</p>`;
|
||
}
|
||
}
|
||
|
||
async function confirmMerge() {
|
||
const btn = document.getElementById('confirmMergeBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Łączenie...';
|
||
|
||
const newName = document.getElementById('newNameInput')?.value || null;
|
||
|
||
try {
|
||
const response = await fetch('/api/zopk/knowledge/duplicates/merge', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRFToken': '{{ csrf_token() }}'
|
||
},
|
||
body: JSON.stringify({
|
||
primary_id: currentPrimaryId,
|
||
duplicate_id: currentDuplicateId,
|
||
new_name: newName !== currentNewName ? newName : null
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
alert(`✅ Encje połączone!\n\nPrzeniesiono:\n- ${data.transfers.mentions} wzmianek\n- ${data.transfers.facts || 0} faktów\n- ${data.transfers.relations_source + data.transfers.relations_target} relacji`);
|
||
closeMergeModal();
|
||
window.location.reload();
|
||
} else {
|
||
alert(`❌ Błąd: ${data.error}`);
|
||
btn.disabled = false;
|
||
btn.textContent = '🔀 Połącz encje';
|
||
}
|
||
} catch (error) {
|
||
alert(`❌ Błąd połączenia: ${error.message}`);
|
||
btn.disabled = false;
|
||
btn.textContent = '🔀 Połącz encje';
|
||
}
|
||
}
|
||
{% endblock %}
|