139 lines
5.1 KiB
TypeScript
139 lines
5.1 KiB
TypeScript
// Страница «Колоды»: плоский список строк из таблицы decks 1:1.
|
|||
|
|
// Никаких связей (states/history/words) здесь не додумываем — только колонки таблицы.
|
||
|
|
|
||
|
|
import { getDecks } from './db-types.ts';
|
||
|
|
import type { Deck } from './db-types.ts';
|
||
|
|
import { hasDatabaseFile } from './opfs.ts';
|
||
|
|
|
||
|
|
function escapeHtml(s: string): string {
|
||
|
|
return s
|
||
|
|
.replace(/&/g, '&')
|
||
|
|
.replace(/</g, '<')
|
||
|
|
.replace(/>/g, '>')
|
||
|
|
.replace(/"/g, '"')
|
||
|
|
.replace(/'/g, ''');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Одна колода — плоская строка: имя слева, направление справа.
|
||
|
|
function deckRowHtml(d: Deck, expanded: boolean): string {
|
||
|
|
const dir = [d.forward, d.backward].filter((s) => s.trim().length > 0).join(' → ');
|
||
|
|
return `<div class="word-row-wrap" data-deck-id="${d.id}">`
|
||
|
|
+ `<button class="word-row" type="button" data-deck-id="${d.id}"`
|
||
|
|
+ ` aria-expanded="${expanded}" aria-controls="deck-extra-${d.id}">`
|
||
|
|
+ `<span class="word-main"><span class="word-key">${escapeHtml(d.name)}</span>`
|
||
|
|
+ (dir ? `<span class="word-sep"> · </span><span class="word-value">${escapeHtml(dir)}</span>` : '')
|
||
|
|
+ `</span></button>`
|
||
|
|
+ `<div class="word-extra" id="deck-extra-${d.id}"${expanded ? '' : ' hidden'}>`
|
||
|
|
+ (expanded ? deckExtraHtml(d) : '')
|
||
|
|
+ `</div></div>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Раскрытие — сырые поля строки decks (имена колонок как в dbschema.sql).
|
||
|
|
function deckExtraHtml(d: Deck): string {
|
||
|
|
const rows: Array<[string, string]> = [
|
||
|
|
['forward', d.forward],
|
||
|
|
['backward', d.backward],
|
||
|
|
['filter', d.filter],
|
||
|
|
];
|
||
|
|
const html = rows
|
||
|
|
.map(([k, v]) => `<div class="word-extra-row"><dt>${escapeHtml(k)}</dt><dd>${v ? escapeHtml(v) : '<span class="empty-val">—</span>'}</dd></div>`)
|
||
|
|
.join('');
|
||
|
|
return `<dl class="word-extra-list">${html}</dl>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function renderDecksPage(root: HTMLElement): void {
|
||
|
|
root.innerHTML = `
|
||
|
|
<section class="card" aria-labelledby="decks-title">
|
||
|
|
<h2 id="decks-title" class="card-title">🗂️ Колоды</h2>
|
||
|
|
<p id="decks-count" class="card-hint" role="status" aria-live="polite"></p>
|
||
|
|
<div id="decks-status" class="status-bar" role="status">Проверка локальной базы…</div>
|
||
|
|
<div id="decks-list" class="words-list" aria-live="polite"></div>
|
||
|
|
</section>
|
||
|
|
`;
|
||
|
|
|
||
|
|
const countEl = root.querySelector<HTMLElement>('#decks-count')!;
|
||
|
|
const statusEl = root.querySelector<HTMLElement>('#decks-status')!;
|
||
|
|
const listEl = root.querySelector<HTMLElement>('#decks-list')!;
|
||
|
|
|
||
|
|
let decks: Deck[] = [];
|
||
|
|
let expandedId: number | null = null;
|
||
|
|
|
||
|
|
function setStatus(msg: string): void {
|
||
|
|
statusEl.textContent = msg;
|
||
|
|
statusEl.hidden = msg === '';
|
||
|
|
}
|
||
|
|
|
||
|
|
function collapseRow(id: number): void {
|
||
|
|
const wrap = listEl.querySelector(`.word-row-wrap[data-deck-id="${id}"]`);
|
||
|
|
if (!wrap) return;
|
||
|
|
const btn = wrap.querySelector<HTMLButtonElement>('.word-row');
|
||
|
|
const extra = wrap.querySelector<HTMLElement>('.word-extra');
|
||
|
|
if (btn) {
|
||
|
|
btn.classList.remove('open');
|
||
|
|
btn.setAttribute('aria-expanded', 'false');
|
||
|
|
}
|
||
|
|
if (extra) {
|
||
|
|
extra.hidden = true;
|
||
|
|
extra.innerHTML = '';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function expandRow(id: number): void {
|
||
|
|
const d = decks.find((x) => x.id === id);
|
||
|
|
const wrap = listEl.querySelector(`.word-row-wrap[data-deck-id="${id}"]`);
|
||
|
|
if (!d || !wrap) return;
|
||
|
|
const btn = wrap.querySelector<HTMLButtonElement>('.word-row');
|
||
|
|
const extra = wrap.querySelector<HTMLElement>('.word-extra');
|
||
|
|
if (btn) {
|
||
|
|
btn.classList.add('open');
|
||
|
|
btn.setAttribute('aria-expanded', 'true');
|
||
|
|
}
|
||
|
|
if (extra) {
|
||
|
|
extra.hidden = false;
|
||
|
|
extra.innerHTML = deckExtraHtml(d);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function setExpanded(id: number | null): void {
|
||
|
|
if (expandedId === id) {
|
||
|
|
if (id != null) collapseRow(id);
|
||
|
|
expandedId = null;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (expandedId != null) collapseRow(expandedId);
|
||
|
|
expandedId = id;
|
||
|
|
if (id != null) expandRow(id);
|
||
|
|
}
|
||
|
|
|
||
|
|
listEl.addEventListener('click', (e) => {
|
||
|
|
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.word-row');
|
||
|
|
if (!btn) return;
|
||
|
|
const id = Number(btn.dataset.deckId);
|
||
|
|
if (!Number.isFinite(id)) return;
|
||
|
|
setExpanded(expandedId === id ? null : id);
|
||
|
|
});
|
||
|
|
|
||
|
|
void (async () => {
|
||
|
|
const has = await hasDatabaseFile();
|
||
|
|
if (!has) {
|
||
|
|
setStatus('Нет локальной базы — скачай сохранение на странице «Синхронизация».');
|
||
|
|
countEl.textContent = '';
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setStatus('Загрузка колод…');
|
||
|
|
try {
|
||
|
|
decks = await getDecks();
|
||
|
|
if (decks.length === 0) {
|
||
|
|
countEl.textContent = 'Колоды пока отсутствуют.';
|
||
|
|
setStatus('');
|
||
|
|
listEl.innerHTML = '';
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
countEl.textContent = `Всего: ${decks.length}`;
|
||
|
|
setStatus('');
|
||
|
|
listEl.innerHTML = decks.map((d) => deckRowHtml(d, d.id === expandedId)).join('');
|
||
|
|
} catch (err) {
|
||
|
|
setStatus(`Не удалось загрузить колоды: ${err instanceof Error ? err.message : String(err)}`);
|
||
|
|
}
|
||
|
|
})();
|
||
|
|
}
|