mirror of
https://github.com/PRVTPRO/Amnezia-Web-Panel.git
synced 2026-09-18 01:44:10 +02:00
cf25956a9f
"Error loading connections: JSON.parse: unexpected character at line 1 column
1 of the JSON data" is what the panel shows whenever a response is not JSON,
and it says nothing about the actual failure. Two halves:
The app has no handler for unhandled exceptions, so Starlette answers with a
plain-text "Internal Server Error" - unparseable for every caller that does
`await res.json()`. /api/* now answers `{"error": "Internal server error"}`
with the traceback logged; pages keep the plain-text body, since a browser
showing an error page is fine.
apiCall read the body as JSON unconditionally, so a proxy error page, a
restart mid-request or an empty body surfaced as a parser complaint. It now
reads text, parses defensively and throws "HTTP <status>: <first line of the
body>", which points at what happened. The existing err.payload passthrough
is kept, so the wg-easy import log still survives the throw, and data.detail
is consulted for FastAPI's own validation errors.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
372 lines
24 KiB
HTML
372 lines
24 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="{{ lang }}" {% if lang=='fa' %}dir="rtl" {% endif %}>
|
||
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||
<meta name="description" content="{{ _('site_description') }}">
|
||
<meta name="theme-color" content="#0a0a0f" id="metaThemeColor">
|
||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||
<meta name="apple-mobile-web-app-title" content="{{ site_settings.title or 'Amnezia' }}">
|
||
<title>{{ site_settings.title or 'Amnezia Panel' }} {% block title_extra %}{% endblock %}</title>
|
||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon-180.png">
|
||
<link rel="manifest" href="/manifest.webmanifest">
|
||
<link rel="stylesheet" href="/static/css/style.css?v={{ static_v }}">
|
||
<script>!function () { var t = localStorage.getItem('theme') || 'dark'; document.documentElement.setAttribute('data-theme', t); var m = document.querySelector('meta[name=theme-color]'); if (m) m.content = t === 'light' ? '#f5f5f7' : '#0a0a0f'; }()</script>
|
||
<script src="/static/js/qrcode.min.js"></script>
|
||
<script src="/static/js/searchable-select.js" defer></script>
|
||
{% block head_extra %}{% endblock %}
|
||
<style>
|
||
.lang-check {
|
||
color: var(--success);
|
||
font-weight: 700;
|
||
}
|
||
|
||
[dir="rtl"] .lang-check {
|
||
margin-right: auto;
|
||
}
|
||
|
||
[dir="ltr"] .lang-check {
|
||
margin-left: auto;
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body>
|
||
<div class="toast-container" id="toastContainer"></div>
|
||
|
||
<div class="app-container">
|
||
<header class="app-header">
|
||
<a href="/" class="app-logo" style="text-decoration:none">
|
||
<div class="logo-icon">{{ site_settings.logo }}</div>
|
||
<div>
|
||
<div class="logo-text">{{ site_settings.title }}</div>
|
||
<div class="logo-subtitle">{{ site_settings.subtitle }}</div>
|
||
</div>
|
||
</a>
|
||
|
||
{% if current_user %}
|
||
<nav class="header-nav">
|
||
{% if current_user.role in ['admin', 'support'] %}
|
||
<a href="/" class="nav-link">{{ _('nav_servers') }}</a>
|
||
<a href="/users" class="nav-link">{{ _('nav_users') }}</a>
|
||
{% endif %}
|
||
{% if current_user.role == 'admin' %}
|
||
<a href="/settings" class="nav-link">{{ _('nav_settings') }}</a>
|
||
{% endif %}
|
||
<a href="/my" class="nav-link">{{ _('nav_connections') }}</a>
|
||
</nav>
|
||
|
||
<div class="nav-user">
|
||
<button class="theme-toggle" onclick="openModal('langModal')" title="{{ _('select_lang') }}"
|
||
style="margin-right: var(--space-sm);">
|
||
{% if lang == 'ru' %}🇷🇺{% elif lang == 'fr' %}🇫🇷{% elif lang == 'zh' %}🇨🇳{% elif lang == 'fa'
|
||
%}🇮🇷{% else %}🇺🇸{% endif %}
|
||
</button>
|
||
<button class="theme-toggle" onclick="toggleTheme()" title="{{ _('toggle_theme') }}"
|
||
id="themeToggle">🌙</button>
|
||
<span class="nav-username">{{ current_user.username }}</span>
|
||
<span
|
||
class="badge badge-{{ 'success' if current_user.role == 'admin' else ('info' if current_user.role == 'support' else 'warn') }}"
|
||
style="font-size:0.65rem;">
|
||
{{ current_user.role | upper }}
|
||
</span>
|
||
<a href="/logout" class="btn btn-secondary btn-sm nav-logout"
|
||
title="{{ _('nav_logout') }}" aria-label="{{ _('nav_logout') }}">
|
||
<svg class="nav-logout-icon" width="18" height="18" viewBox="0 0 24 24" fill="none"
|
||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||
aria-hidden="true">
|
||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||
<polyline points="16 17 21 12 16 7" />
|
||
<line x1="21" y1="12" x2="9" y2="12" />
|
||
</svg>
|
||
<span class="nav-logout-text">{{ _('nav_logout') }}</span>
|
||
</a>
|
||
</div>
|
||
{% else %}
|
||
<div class="nav-user">
|
||
<button class="theme-toggle" onclick="openModal('langModal')" title="{{ _('select_lang') }}"
|
||
style="margin-right: var(--space-sm);">
|
||
{% if lang == 'ru' %}🇷🇺{% elif lang == 'fr' %}🇫🇷{% elif lang == 'zh' %}🇨🇳{% elif lang == 'fa'
|
||
%}🇮🇷{% else %}🇺🇸{% endif %}
|
||
</button>
|
||
<button class="theme-toggle" onclick="toggleTheme()" title="{{ _('toggle_theme') }}"
|
||
id="themeToggle">🌙</button>
|
||
</div>
|
||
{% endif %}
|
||
</header>
|
||
|
||
<main class="{% block page_class %}{% endblock %}">
|
||
{% block content %}{% endblock %}
|
||
</main>
|
||
</div>
|
||
|
||
{% if current_user %}
|
||
{% set show_ops = current_user.role in ['admin', 'support'] %}
|
||
{% set show_settings = current_user.role == 'admin' %}
|
||
{% set tab_count = 1 + (2 if show_ops else 0) + (1 if show_settings else 0) %}
|
||
{% if tab_count >= 2 %}
|
||
<nav class="mobile-tabbar" aria-label="Primary">
|
||
{% if show_ops %}
|
||
<a href="/" class="tab-item">
|
||
<span class="tab-icon" aria-hidden="true"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="12" rx="2"/><line x1="7" y1="21" x2="17" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></span>
|
||
<span class="tab-label">{{ _('nav_servers') }}</span>
|
||
</a>
|
||
<a href="/users" class="tab-item">
|
||
<span class="tab-icon" aria-hidden="true"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2"/><circle cx="10" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg></span>
|
||
<span class="tab-label">{{ _('nav_users') }}</span>
|
||
</a>
|
||
{% endif %}
|
||
{% if show_settings %}
|
||
<a href="/settings" class="tab-item">
|
||
<span class="tab-icon" aria-hidden="true"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg></span>
|
||
<span class="tab-label">{{ _('nav_settings') }}</span>
|
||
</a>
|
||
{% endif %}
|
||
<a href="/my" class="tab-item">
|
||
<span class="tab-icon" aria-hidden="true"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></span>
|
||
<span class="tab-label">{{ _('nav_connections') }}</span>
|
||
</a>
|
||
</nav>
|
||
{% endif %}
|
||
{% endif %}
|
||
|
||
<!-- ===== Language Modal ===== -->
|
||
<div class="modal-backdrop" id="langModal">
|
||
<div class="modal" style="max-width: 320px;">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title">{{ _('select_lang') }}</h2>
|
||
<button class="modal-close" onclick="closeModal('langModal')">×</button>
|
||
</div>
|
||
<div style="display: flex; flex-direction: column; gap: var(--space-sm);">
|
||
{% set langs = [
|
||
('en', '🇺🇸', 'lang_en'),
|
||
('ru', '🇷🇺', 'lang_ru'),
|
||
('fr', '🇫🇷', 'lang_fr'),
|
||
('zh', '🇨🇳', 'lang_zh'),
|
||
('fa', '🇮🇷', 'lang_fa')
|
||
] %}
|
||
{% for l_code, l_flag, l_key in langs %}
|
||
<a href="/set_lang/{{ l_code }}" class="btn btn-secondary"
|
||
style="justify-content: flex-start; gap: var(--space-md); padding: var(--space-md);">
|
||
<span style="font-size: 1.4rem;">{{ l_flag }}</span>
|
||
<span style="font-weight: 500;">{{ _(l_key) }}</span>
|
||
{% if lang == l_code %}
|
||
<span class="lang-check">✓</span>
|
||
{% endif %}
|
||
</a>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
window.I18N = {{ translations_json | safe }};
|
||
window._ = function (key) { return window.I18N[key] || key; };
|
||
|
||
/* Shared stroke icons for JS-rendered buttons (matches logout SVG style) */
|
||
window.ICON_PENCIL = '<svg class="btn-icon-svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>';
|
||
window.ICON_TRASH = '<svg class="btn-icon-svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>';
|
||
window.ICON_SERVERS = '<svg class="btn-icon-svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="12" rx="2"/><line x1="7" y1="21" x2="17" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>';
|
||
window.ICON_USERS = '<svg class="btn-icon-svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2"/><circle cx="10" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>';
|
||
window.ICON_SETTINGS = '<svg class="btn-icon-svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg>';
|
||
window.ICON_LINK = '<svg class="btn-icon-svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>';
|
||
window.ICON_FILE = '<svg class="btn-icon-svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>';
|
||
window.ICON_PLUS = '<svg class="btn-icon-svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>';
|
||
window.ICON_POWER = '<svg class="btn-icon-svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/></svg>';
|
||
window.ICON_SHARE = '<svg class="btn-icon-svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>';
|
||
window.ICON_LOCK = '<svg class="btn-icon-svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
|
||
window.ICON_GLOBE = '<svg class="btn-icon-svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>';
|
||
window.ICON_KEY = '<svg class="btn-icon-svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>';
|
||
window.ICON_INFO = '<svg class="btn-icon-svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>';
|
||
window.ICON_REFRESH = '<svg class="btn-icon-svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>';
|
||
window.ICON_COPY = '<svg class="btn-icon-svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
||
window.ICON_DOWNLOAD = '<svg class="btn-icon-svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
||
window.ICON_UPLOAD = '<svg class="btn-icon-svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>';
|
||
window.ICON_DOCS = '<svg class="btn-icon-svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>';
|
||
window.ICON_CHECK = '<svg class="btn-icon-svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>';
|
||
window.ICON_X = '<svg class="btn-icon-svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
||
|
||
/* ===== Theme Toggle ===== */
|
||
(function () {
|
||
const saved = localStorage.getItem('theme') || 'dark';
|
||
document.documentElement.setAttribute('data-theme', saved);
|
||
|
||
const updateUI = () => {
|
||
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
|
||
document.querySelectorAll('.theme-toggle').forEach(btn => {
|
||
const isLang = btn.getAttribute('onclick').includes('langModal');
|
||
if (!isLang) {
|
||
btn.textContent = isLight ? '☀️' : '🌙';
|
||
}
|
||
});
|
||
};
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', updateUI);
|
||
} else {
|
||
updateUI();
|
||
}
|
||
|
||
window.toggleTheme = function () {
|
||
const current = document.documentElement.getAttribute('data-theme') || 'dark';
|
||
const next = current === 'dark' ? 'light' : 'dark';
|
||
document.documentElement.setAttribute('data-theme', next);
|
||
localStorage.setItem('theme', next);
|
||
const meta = document.getElementById('metaThemeColor') || document.querySelector('meta[name="theme-color"]');
|
||
if (meta) meta.content = next === 'light' ? '#f5f5f7' : '#0a0a0f';
|
||
updateUI();
|
||
};
|
||
})();
|
||
|
||
/* ===== Active Nav Link ===== */
|
||
(function () {
|
||
const path = window.location.pathname;
|
||
document.querySelectorAll('.nav-link, .tab-item').forEach(link => {
|
||
const href = link.getAttribute('href');
|
||
if (href === '/' && path === '/') link.classList.add('active');
|
||
else if (href !== '/' && path.startsWith(href)) link.classList.add('active');
|
||
});
|
||
})();
|
||
|
||
/* ===== Shared QR size helper (modal width - padding) ===== */
|
||
window.qrSize = (el, max = 280) => Math.min(max, (el.clientWidth || max) - 8);
|
||
|
||
/* ===== Service Worker (static assets only) ===== */
|
||
if ('serviceWorker' in navigator) {
|
||
navigator.serviceWorker.register('/sw.js?v={{ static_v }}').catch(() => {});
|
||
}
|
||
|
||
/* ===== Toast Notifications ===== */
|
||
function showToast(message, type = 'info') {
|
||
const container = document.getElementById('toastContainer');
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast toast-${type}`;
|
||
|
||
const icons = { success: '✓', error: '✕', info: 'ℹ' };
|
||
toast.innerHTML = `<span>${icons[type] || 'ℹ'}</span> <span>${message}</span>`;
|
||
|
||
container.appendChild(toast);
|
||
setTimeout(() => {
|
||
toast.style.opacity = '0';
|
||
toast.style.transform = 'translateX(100%)';
|
||
toast.style.transition = 'all 0.3s ease';
|
||
setTimeout(() => toast.remove(), 300);
|
||
}, 4000);
|
||
}
|
||
|
||
/* ===== Modal Management ===== */
|
||
function openModal(id) {
|
||
const modal = document.getElementById(id);
|
||
if (modal) {
|
||
modal.classList.add('active');
|
||
document.body.style.overflow = 'hidden';
|
||
}
|
||
}
|
||
|
||
function closeModal(id) {
|
||
const modal = document.getElementById(id);
|
||
if (modal) {
|
||
modal.classList.remove('active');
|
||
document.body.style.overflow = '';
|
||
}
|
||
}
|
||
|
||
/* Close modal on backdrop click — but only when BOTH the press and
|
||
the release happen on the backdrop. Pressing inside the modal
|
||
(e.g. selecting input text) and releasing outside must not close
|
||
it, otherwise drag-select closes every modal. */
|
||
let backdropPressTarget = null;
|
||
document.addEventListener('mousedown', (e) => {
|
||
backdropPressTarget = e.target;
|
||
});
|
||
document.addEventListener('click', (e) => {
|
||
if (e.target.classList.contains('modal-backdrop') && e.target.classList.contains('active')
|
||
&& backdropPressTarget === e.target) {
|
||
e.target.classList.remove('active');
|
||
document.body.style.overflow = '';
|
||
}
|
||
backdropPressTarget = null;
|
||
});
|
||
|
||
/* Close modal on Escape */
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
document.querySelectorAll('.modal-backdrop.active').forEach(m => {
|
||
m.classList.remove('active');
|
||
});
|
||
document.body.style.overflow = '';
|
||
}
|
||
});
|
||
|
||
/* ===== API Helper ===== */
|
||
async function apiCall(url, method = 'GET', body = null) {
|
||
const opts = {
|
||
method,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
};
|
||
if (body) opts.body = JSON.stringify(body);
|
||
|
||
const res = await fetch(url, opts);
|
||
|
||
if (res.status === 401 || res.status === 403) {
|
||
window.location.href = '/login';
|
||
throw new Error('Session expired');
|
||
}
|
||
|
||
// Not res.json(): a proxy error page, a restart mid-request or a
|
||
// handler that died before it could answer all arrive as HTML or
|
||
// plain text, and the parse error alone ("unexpected character at
|
||
// line 1 column 1") tells the user nothing about what happened.
|
||
const text = await res.text();
|
||
let data;
|
||
try {
|
||
data = text ? JSON.parse(text) : {};
|
||
} catch (parseError) {
|
||
const detail = (text || res.statusText || '').replace(/<[^>]*>/g, ' ').trim().slice(0, 120);
|
||
throw new Error(`HTTP ${res.status}${detail ? ': ' + detail : ''}`);
|
||
}
|
||
|
||
if (!res.ok) {
|
||
const err = new Error(data.error || data.detail || `HTTP ${res.status}`);
|
||
err.payload = data; // keep extra fields (e.g. import log)
|
||
throw err;
|
||
}
|
||
return data;
|
||
}
|
||
|
||
/* ===== Copy to clipboard ===== */
|
||
async function copyToClipboard(text) {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
showToast(_('copied_to_clipboard'), 'success');
|
||
} catch {
|
||
const ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
document.execCommand('copy');
|
||
ta.remove();
|
||
showToast(_('copied_to_clipboard'), 'success');
|
||
}
|
||
}
|
||
|
||
/* ===== Download file ===== */
|
||
function downloadFile(content, filename) {
|
||
const blob = new Blob([content], { type: 'text/plain' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
</script>
|
||
|
||
{% block scripts %}{% endblock %}
|
||
</body>
|
||
|
||
</html>
|