- FastAPI + SQLite backend with REST API - Polymorphic Media model (book + series) - Cover lookup via Open Library - Vanilla HTML/CSS/JS frontend (MAL-inspired) - Dark theme, status tabs, drawer detail, stats modal - 8 seed demo entries
518 lines
21 KiB
JavaScript
518 lines
21 KiB
JavaScript
/* ========================================================================
|
||
WatchStack – Client App
|
||
======================================================================== */
|
||
(() => {
|
||
'use strict';
|
||
|
||
const $ = (sel, root = document) => root.querySelector(sel);
|
||
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
|
||
|
||
const state = {
|
||
items: [],
|
||
genres: [],
|
||
tags: [],
|
||
filter: {
|
||
kind: '',
|
||
status: '',
|
||
search: '',
|
||
genre: '',
|
||
tag: '',
|
||
sort: 'updated_desc',
|
||
},
|
||
editing: null, // media id when editing, null when creating
|
||
};
|
||
|
||
// ---------- Helpers --------------------------------------------------------
|
||
|
||
const api = {
|
||
async get(url) {
|
||
const r = await fetch(url);
|
||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
|
||
return r.json();
|
||
},
|
||
async send(method, url, body) {
|
||
const r = await fetch(url, {
|
||
method,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: body ? JSON.stringify(body) : undefined,
|
||
});
|
||
if (!r.ok && r.status !== 204) {
|
||
const err = await r.json().catch(() => ({}));
|
||
throw new Error(err.detail || r.statusText);
|
||
}
|
||
return r.status === 204 ? null : r.json();
|
||
},
|
||
};
|
||
|
||
function toast(msg, kind = 'info') {
|
||
const el = $('#toast');
|
||
el.textContent = msg;
|
||
el.hidden = false;
|
||
el.style.borderColor =
|
||
kind === 'error' ? 'rgba(239,68,68,.5)' :
|
||
kind === 'success' ? 'rgba(34,197,94,.5)' :
|
||
'var(--border-strong)';
|
||
clearTimeout(toast._t);
|
||
toast._t = setTimeout(() => { el.hidden = true; }, 2400);
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
if (s == null) return '';
|
||
return String(s)
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
function stars(rating) {
|
||
if (rating == null) return '';
|
||
return `<span class="rating-display" title="${rating}/10">★ ${Number(rating).toFixed(1)}</span>`;
|
||
}
|
||
|
||
function statusLabel(s) {
|
||
return ({
|
||
plan: 'Geplant', reading: 'Laufend', done: 'Abgeschlossen',
|
||
hold: 'Pausiert', dropped: 'Abgebrochen',
|
||
})[s] || s;
|
||
}
|
||
|
||
function kindLabel(k) {
|
||
return k === 'book' ? '📚 Buch' : '📺 Serie';
|
||
}
|
||
|
||
function kindIcon(k) {
|
||
return k === 'book' ? '📚' : '📺';
|
||
}
|
||
|
||
function progressText(m) {
|
||
if (m.kind === 'book') {
|
||
const total = m.total_chapters || m.total_pages || m.total_volumes || 0;
|
||
const done = m.chapters_read || m.pages_read || m.volumes_read || 0;
|
||
const unit = m.total_chapters ? 'Kap.' : m.total_pages ? 'S.' : 'Bd.';
|
||
return total ? `${done} / ${total} ${unit}` : '—';
|
||
}
|
||
const total = m.total_episodes || 0;
|
||
const done = m.episodes_watched || 0;
|
||
return total ? `${done} / ${total} Ep.` : '—';
|
||
}
|
||
|
||
// ---------- Initial Load ----------------------------------------------------
|
||
|
||
async function loadAll() {
|
||
try {
|
||
const params = new URLSearchParams();
|
||
if (state.filter.kind) params.set('kind', state.filter.kind);
|
||
if (state.filter.status) params.set('status', state.filter.status);
|
||
if (state.filter.search) params.set('search', state.filter.search);
|
||
if (state.filter.genre) params.set('genre', state.filter.genre);
|
||
if (state.filter.tag) params.set('tag', state.filter.tag);
|
||
params.set('sort', state.filter.sort);
|
||
|
||
const [items, genres, tags] = await Promise.all([
|
||
api.get(`/api/media?${params}`),
|
||
api.get('/api/genres'),
|
||
api.get('/api/tags'),
|
||
]);
|
||
state.items = items;
|
||
state.genres = genres;
|
||
state.tags = tags;
|
||
renderAll();
|
||
} catch (e) {
|
||
toast('Fehler beim Laden: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
// ---------- Render ----------------------------------------------------------
|
||
|
||
function renderAll() {
|
||
renderChips();
|
||
renderCounts();
|
||
renderGrid();
|
||
}
|
||
|
||
function renderChips() {
|
||
const g = $('#genre-chips');
|
||
const t = $('#tag-chips');
|
||
g.innerHTML = '<span class="chip-label">Genres:</span>';
|
||
t.innerHTML = '<span class="chip-label">Tags:</span>';
|
||
state.genres.forEach(x => {
|
||
const el = document.createElement('button');
|
||
el.className = 'chip' + (state.filter.genre === x.name ? ' active' : '');
|
||
el.textContent = x.name;
|
||
el.onclick = () => { state.filter.genre = state.filter.genre === x.name ? '' : x.name; loadAll(); };
|
||
g.appendChild(el);
|
||
});
|
||
state.tags.forEach(x => {
|
||
const el = document.createElement('button');
|
||
el.className = 'chip' + (state.filter.tag === x.name ? ' active' : '');
|
||
el.textContent = x.name;
|
||
el.onclick = () => { state.filter.tag = state.filter.tag === x.name ? '' : x.name; loadAll(); };
|
||
t.appendChild(el);
|
||
});
|
||
}
|
||
|
||
function renderCounts() {
|
||
const counts = { plan: 0, reading: 0, done: 0, hold: 0, dropped: 0 };
|
||
let filtered = state.items;
|
||
if (state.filter.kind) filtered = filtered.filter(m => m.kind === state.filter.kind);
|
||
if (state.filter.search) {
|
||
const s = state.filter.search.toLowerCase();
|
||
filtered = filtered.filter(m =>
|
||
(m.title || '').toLowerCase().includes(s) ||
|
||
(m.author || '').toLowerCase().includes(s) ||
|
||
(m.original_title || '').toLowerCase().includes(s)
|
||
);
|
||
}
|
||
filtered.forEach(m => { counts[m.status] = (counts[m.status] || 0) + 1; });
|
||
Object.entries(counts).forEach(([k, v]) => {
|
||
const el = $('#cnt-' + k);
|
||
if (el) el.textContent = v;
|
||
});
|
||
}
|
||
|
||
function renderGrid() {
|
||
const grid = $('#grid');
|
||
const empty = $('#empty-state');
|
||
|
||
if (state.items.length === 0) {
|
||
grid.innerHTML = '';
|
||
empty.classList.remove('hidden');
|
||
return;
|
||
}
|
||
empty.classList.add('hidden');
|
||
|
||
grid.innerHTML = state.items.map(m => `
|
||
<article class="card" data-id="${m.id}">
|
||
<div class="cover">
|
||
<span class="kind-badge">${kindIcon(m.kind)} ${m.kind === 'book' ? 'Buch' : 'Serie'}</span>
|
||
${m.cover_url
|
||
? `<img src="${escapeHtml(m.cover_url)}" alt="${escapeHtml(m.title)}" loading="lazy" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'cover-fallback',textContent:'${escapeHtml((m.title||'?').slice(0,20))}'}))">`
|
||
: `<div class="cover-fallback">${escapeHtml((m.title || '?').slice(0, 28))}</div>`}
|
||
<div class="progress-bar"><div style="width:${m.progress_percent}%"></div></div>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="card-title">${escapeHtml(m.title)}</div>
|
||
<div class="card-sub">${escapeHtml(m.kind === 'book' ? (m.author || '—') : (m.network || '—'))}${m.release_year ? ' · ' + m.release_year : ''}</div>
|
||
<div class="card-meta">
|
||
<span class="status-badge status-${m.status}">${statusLabel(m.status)}</span>
|
||
${stars(m.rating)}
|
||
</div>
|
||
<div class="card-sub">${progressText(m)}</div>
|
||
</div>
|
||
</article>
|
||
`).join('');
|
||
|
||
$$('.card', grid).forEach(el => {
|
||
el.addEventListener('click', () => openDetail(parseInt(el.dataset.id, 10)));
|
||
});
|
||
}
|
||
|
||
// ---------- Filter / Suche --------------------------------------------------
|
||
|
||
function wireFilters() {
|
||
const search = $('#search-input');
|
||
let debounce;
|
||
search.addEventListener('input', () => {
|
||
clearTimeout(debounce);
|
||
debounce = setTimeout(() => { state.filter.search = search.value.trim(); loadAll(); }, 220);
|
||
});
|
||
$('#filter-kind').addEventListener('change', e => { state.filter.kind = e.target.value; loadAll(); });
|
||
$('#filter-status').addEventListener('change', e => { state.filter.status = e.target.value; loadAll(); });
|
||
$('#filter-sort').addEventListener('change', e => { state.filter.sort = e.target.value; loadAll(); });
|
||
|
||
$$('#status-tabs .tab').forEach(tab => {
|
||
tab.addEventListener('click', () => {
|
||
$$('#status-tabs .tab').forEach(t => t.classList.remove('active'));
|
||
tab.classList.add('active');
|
||
state.filter.status = tab.dataset.status;
|
||
$('#filter-status').value = state.filter.status;
|
||
loadAll();
|
||
});
|
||
});
|
||
}
|
||
|
||
// ---------- Modal: Add / Edit ---------------------------------------------
|
||
|
||
function openAdd() {
|
||
state.editing = null;
|
||
$('#modal-title').textContent = 'Neuer Eintrag';
|
||
const form = $('#form-add');
|
||
form.reset();
|
||
form.id.value = '';
|
||
form.kind.value = 'book';
|
||
document.body.classList.remove('kind-series');
|
||
document.body.classList.add('kind-book');
|
||
$('#modal-add').hidden = false;
|
||
}
|
||
|
||
function openEdit(m) {
|
||
state.editing = m.id;
|
||
$('#modal-title').textContent = 'Bearbeiten: ' + m.title;
|
||
const form = $('#form-add');
|
||
form.reset();
|
||
form.id.value = m.id;
|
||
form.kind.value = m.kind;
|
||
document.body.classList.toggle('kind-book', m.kind === 'book');
|
||
document.body.classList.toggle('kind-series', m.kind === 'series');
|
||
// Felder setzen
|
||
const setVal = (name, val) => { if (form[name]) form[name].value = val ?? ''; };
|
||
setVal('title', m.title);
|
||
setVal('status', m.status);
|
||
setVal('release_year', m.release_year);
|
||
setVal('author', m.author);
|
||
setVal('isbn', m.isbn);
|
||
setVal('total_volumes', m.total_volumes);
|
||
setVal('total_chapters', m.total_chapters);
|
||
setVal('total_pages', m.total_pages);
|
||
setVal('network', m.network);
|
||
setVal('total_seasons', m.total_seasons);
|
||
setVal('total_episodes', m.total_episodes);
|
||
setVal('season_watching', m.season_watching);
|
||
setVal('start_date', m.start_date);
|
||
setVal('end_date', m.end_date);
|
||
setVal('cover_url', m.cover_url);
|
||
setVal('rating', m.rating);
|
||
setVal('description', m.description);
|
||
setVal('notes', m.notes);
|
||
form.genres.value = (m.genres || []).map(g => g.name).join(', ');
|
||
form.tags.value = (m.tags || []).map(t => t.name).join(', ');
|
||
$('#modal-add').hidden = false;
|
||
}
|
||
|
||
function wireAddModal() {
|
||
const form = $('#form-add');
|
||
const modal = $('#modal-add');
|
||
|
||
$$('[data-close]', modal).forEach(b => b.addEventListener('click', () => { modal.hidden = true; }));
|
||
|
||
form.kind.addEventListener('change', () => {
|
||
document.body.classList.toggle('kind-book', form.kind.value === 'book');
|
||
document.body.classList.toggle('kind-series', form.kind.value === 'series');
|
||
});
|
||
|
||
// Online-Lookup (Open Library per ISBN, sonst Titel)
|
||
$('#btn-lookup').addEventListener('click', async () => {
|
||
const isbn = form.isbn.value.trim();
|
||
const title = form.title.value.trim();
|
||
if (!isbn && !title) { toast('ISBN oder Titel eingeben', 'error'); return; }
|
||
try {
|
||
const params = isbn ? `?isbn=${encodeURIComponent(isbn)}` : `?title=${encodeURIComponent(title)}`;
|
||
const data = await api.get('/api/lookup/book' + params);
|
||
if (data.title && !form.title.value) form.title.value = data.title;
|
||
if (data.author && !form.author.value) form.author.value = data.author;
|
||
if (data.release_year && !form.release_year.value) form.release_year.value = data.release_year;
|
||
if (data.total_pages && !form.total_pages.value) form.total_pages.value = data.total_pages;
|
||
if (data.cover_url && !form.cover_url.value) form.cover_url.value = data.cover_url;
|
||
toast('Daten geladen ✓', 'success');
|
||
} catch (e) {
|
||
toast('Lookup fehlgeschlagen: ' + e.message, 'error');
|
||
}
|
||
});
|
||
|
||
form.addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
const fd = new FormData(form);
|
||
const body = {};
|
||
for (const [k, v] of fd.entries()) {
|
||
if (k === 'id') continue;
|
||
body[k] = v;
|
||
}
|
||
// Numerische Felder
|
||
const intFields = ['release_year','total_volumes','total_chapters','total_pages',
|
||
'total_seasons','total_episodes','season_watching'];
|
||
intFields.forEach(f => { if (body[f] === '') body[f] = null; else if (body[f] != null) body[f] = parseInt(body[f], 10); });
|
||
['rating'].forEach(f => { if (body[f] === '') body[f] = null; else if (body[f] != null) body[f] = parseFloat(body[f]); });
|
||
|
||
body.genres = (body.genres || '').split(',').map(s => s.trim()).filter(Boolean);
|
||
body.tags = (body.tags || '').split(',').map(s => s.trim()).filter(Boolean);
|
||
|
||
try {
|
||
if (state.editing) {
|
||
await api.send('PATCH', `/api/media/${state.editing}`, body);
|
||
toast('Aktualisiert ✓', 'success');
|
||
} else {
|
||
await api.send('POST', '/api/media', body);
|
||
toast('Hinzugefügt ✓', 'success');
|
||
}
|
||
modal.hidden = true;
|
||
await loadAll();
|
||
if (state.editing) openDetail(state.editing);
|
||
} catch (err) {
|
||
toast('Speichern fehlgeschlagen: ' + err.message, 'error');
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---------- Drawer: Detail -------------------------------------------------
|
||
|
||
async function openDetail(id) {
|
||
const m = await api.get('/api/media/' + id).catch(() => null);
|
||
if (!m) { toast('Eintrag nicht gefunden', 'error'); return; }
|
||
|
||
const c = $('#drawer-content');
|
||
c.innerHTML = `
|
||
<div class="detail-hero">
|
||
<div class="row-top">
|
||
<div class="cover-mini">
|
||
${m.cover_url
|
||
? `<img src="${escapeHtml(m.cover_url)}" alt="" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'cover-fallback',textContent:'${escapeHtml((m.title||'?').slice(0,20))}'}))">`
|
||
: `<div class="cover-fallback">${escapeHtml((m.title || '?').slice(0, 20))}</div>`}
|
||
</div>
|
||
<div class="meta-title">
|
||
<h2>${escapeHtml(m.title)}</h2>
|
||
<div class="sub">${kindLabel(m.kind)} · ${m.release_year || '—'}${m.author ? ' · ' + escapeHtml(m.author) : ''}${m.network ? ' · ' + escapeHtml(m.network) : ''}</div>
|
||
<div class="badges">
|
||
<span class="status-badge status-${m.status}">${statusLabel(m.status)}</span>
|
||
${stars(m.rating)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
${m.description ? `<section class="detail-section"><h3>Beschreibung</h3><p>${escapeHtml(m.description)}</p></section>` : ''}
|
||
|
||
<section class="detail-section">
|
||
<h3>Status & Fortschritt</h3>
|
||
<div class="progress-bar" style="height:8px;border-radius:8px;overflow:hidden;background:var(--bg-elev-2);">
|
||
<div style="width:${m.progress_percent}%;height:100%;background:var(--accent-grad);"></div>
|
||
</div>
|
||
<div style="margin-top:6px;color:var(--text-dim);font-size:13px;">${progressText(m)} (${m.progress_percent}%)</div>
|
||
<div class="progress-controls">
|
||
<button class="btn small" id="prog-minus">−1</button>
|
||
<button class="btn small" id="prog-plus">+1</button>
|
||
<input type="number" id="prog-set" min="0" placeholder="absolut">
|
||
<button class="btn small btn-primary" id="prog-save">Setzen</button>
|
||
</div>
|
||
<div class="progress-controls" style="margin-top:14px;">
|
||
${['plan','reading','done','hold','dropped'].map(s =>
|
||
`<button class="btn small status-btn" data-st="${s}">${statusLabel(s)}</button>`
|
||
).join('')}
|
||
</div>
|
||
</section>
|
||
|
||
<section class="detail-section">
|
||
<h3>Eckdaten</h3>
|
||
<div class="stat-grid">
|
||
${m.kind === 'book' ? `
|
||
<div class="stat"><div class="label">Autor</div><div class="value">${escapeHtml(m.author || '—')}</div></div>
|
||
<div class="stat"><div class="label">ISBN</div><div class="value" style="font-size:14px">${escapeHtml(m.isbn || '—')}</div></div>
|
||
<div class="stat"><div class="label">Bände</div><div class="value">${m.total_volumes ?? '—'}</div></div>
|
||
<div class="stat"><div class="label">Kapitel</div><div class="value">${m.total_chapters ?? '—'}</div></div>
|
||
<div class="stat"><div class="label">Seiten</div><div class="value">${m.total_pages ?? '—'}</div></div>
|
||
` : `
|
||
<div class="stat"><div class="label">Sender</div><div class="value" style="font-size:14px">${escapeHtml(m.network || '—')}</div></div>
|
||
<div class="stat"><div class="label">Staffeln</div><div class="value">${m.total_seasons ?? '—'}</div></div>
|
||
<div class="stat"><div class="label">Episoden</div><div class="value">${m.total_episodes ?? '—'}</div></div>
|
||
<div class="stat"><div class="label">Staffel aktuell</div><div class="value">${m.season_watching ?? '—'}</div></div>
|
||
<div class="stat"><div class="label">Start</div><div class="value" style="font-size:14px">${m.start_date || '—'}</div></div>
|
||
<div class="stat"><div class="label">Ende</div><div class="value" style="font-size:14px">${m.end_date || '—'}</div></div>
|
||
`}
|
||
</div>
|
||
</section>
|
||
|
||
${(m.genres && m.genres.length) || (m.tags && m.tags.length) ? `
|
||
<section class="detail-section">
|
||
<h3>Genres & Tags</h3>
|
||
<div class="tag-list">
|
||
${(m.genres||[]).map(g => `<span class="chip">${escapeHtml(g.name)}</span>`).join('')}
|
||
${(m.tags||[]).map(t => `<span class="chip" style="border-style:dashed">#${escapeHtml(t.name)}</span>`).join('')}
|
||
</div>
|
||
</section>
|
||
` : ''}
|
||
|
||
${m.notes ? `<section class="detail-section"><h3>Notizen</h3><p style="white-space:pre-wrap">${escapeHtml(m.notes)}</p></section>` : ''}
|
||
|
||
<div class="actions-row">
|
||
<button class="btn btn-primary" id="detail-edit">✎ Bearbeiten</button>
|
||
<button class="btn btn-danger" id="detail-delete">🗑 Löschen</button>
|
||
</div>
|
||
`;
|
||
|
||
// wire drawer actions
|
||
$('#detail-edit').onclick = () => { closeDrawer(); openEdit(m); };
|
||
$('#detail-delete').onclick = async () => {
|
||
if (!confirm(`"${m.title}" wirklich löschen?`)) return;
|
||
try {
|
||
await api.send('DELETE', `/api/media/${m.id}`);
|
||
toast('Gelöscht', 'success');
|
||
closeDrawer();
|
||
loadAll();
|
||
} catch (e) { toast('Löschen fehlgeschlagen: ' + e.message, 'error'); }
|
||
};
|
||
$('#prog-plus').onclick = async () => { await api.send('POST', `/api/media/${m.id}/progress`, { delta: 1 }); openDetail(m.id); loadAll(); };
|
||
$('#prog-minus').onclick = async () => { await api.send('POST', `/api/media/${m.id}/progress`, { delta: -1 }); openDetail(m.id); loadAll(); };
|
||
$('#prog-save').onclick = async () => {
|
||
const v = parseInt($('#prog-set').value, 10);
|
||
if (Number.isNaN(v)) return;
|
||
await api.send('POST', `/api/media/${m.id}/progress`, { set_to: v });
|
||
openDetail(m.id); loadAll();
|
||
};
|
||
$$('.status-btn', c).forEach(btn => btn.onclick = async () => {
|
||
await api.send('PATCH', `/api/media/${m.id}`, { status: btn.dataset.st });
|
||
openDetail(m.id); loadAll();
|
||
});
|
||
|
||
$('#drawer').hidden = false;
|
||
}
|
||
|
||
function closeDrawer() { $('#drawer').hidden = true; }
|
||
|
||
function wireDrawer() {
|
||
$$('[data-close-drawer]').forEach(b => b.addEventListener('click', closeDrawer));
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape') {
|
||
if (!$('#modal-add').hidden) $('#modal-add').hidden = true;
|
||
else if (!$('#modal-stats').hidden) $('#modal-stats').hidden = true;
|
||
else if (!$('#drawer').hidden) closeDrawer();
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---------- Stats -----------------------------------------------------------
|
||
|
||
async function openStats() {
|
||
try {
|
||
const s = await api.get('/api/stats');
|
||
const block = (kind, data, total) => {
|
||
const max = Math.max(1, ...data.map(b => b.count));
|
||
return `
|
||
<div class="stat-block">
|
||
<h3>${kind === 'book' ? '📚 Bücher' : '📺 Serien'} · ${total} Einträge · ⌀ ${data.avg_rating ?? '—'}</h3>
|
||
${data.map(b => `
|
||
<div class="bar-row">
|
||
<div class="label status-badge status-${b.status}">${statusLabel(b.status)}</div>
|
||
<div class="bar"><div style="width:${(b.count / max) * 100}%"></div></div>
|
||
<div class="count">${b.count}</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
};
|
||
$('#stats-body').innerHTML = `
|
||
<div class="stat-block">
|
||
<div class="big-number">${s.total_entries}</div>
|
||
<div style="color:var(--text-dim)">Einträge insgesamt</div>
|
||
</div>
|
||
${block('book', s.books.by_status, s.books.total)}
|
||
${block('series', s.series.by_status, s.series.total)}
|
||
`;
|
||
$('#modal-stats').hidden = false;
|
||
} catch (e) { toast('Stats laden fehlgeschlagen', 'error'); }
|
||
}
|
||
|
||
// ---------- Bootstrap -------------------------------------------------------
|
||
|
||
function wire() {
|
||
wireFilters();
|
||
wireAddModal();
|
||
wireDrawer();
|
||
$('#open-add').addEventListener('click', openAdd);
|
||
$('#open-stats').addEventListener('click', openStats);
|
||
$$('[data-close-stats]').forEach(b => b.addEventListener('click', () => { $('#modal-stats').hidden = true; }));
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
wire();
|
||
loadAll();
|
||
});
|
||
})();
|