deck display
This commit is contained in:
1 parent
6e609cc9fa
commit
37b3b61f79
9 files changed
+226
-58
No files matched your search
@@ -166,6 +166,17 @@ export async function getWordGroups(): Promise<WordGroup[]> {
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getDecks(): Promise<Deck[]> {
|
||||
const rows = await query("SELECT id, name, forward, backward, filter FROM decks ORDER BY id");
|
||||
return (rows as Record<string, unknown>[]).map((r) => ({
|
||||
id: Number(r["id"]),
|
||||
name: String(r["name"] ?? ""),
|
||||
forward: String(r["forward"] ?? ""),
|
||||
backward: String(r["backward"] ?? ""),
|
||||
filter: String(r["filter"] ?? ""),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getCustomFields(): Promise<CustomField[]> {
|
||||
const rows = await query("SELECT id, name, identifier FROM custom_fields ORDER BY id");
|
||||
return (rows as Record<string, unknown>[]).map((r) => ({
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// Страница «Колоды»: плоский список строк из таблицы 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)}`);
|
||||
}
|
||||
})();
|
||||
}
|
||||
+7
-3
@@ -1,14 +1,16 @@
|
||||
import './style.css';
|
||||
import { renderHomePage } from './home-page.ts';
|
||||
import { renderDecksPage } from './decks-page.ts';
|
||||
import { renderSyncPage } from './sync-page.ts';
|
||||
import { initWorker } from './sqlite-api.ts';
|
||||
|
||||
// Каркас приложения: топ-бар + бургер-меню + две страницы (Главная / Синхронизация).
|
||||
// Каркас приложения: топ-бар + бургер-меню + страницы (Главная / Колоды / Синхронизация).
|
||||
|
||||
type Page = 'home' | 'sync';
|
||||
type Page = 'home' | 'decks' | 'sync';
|
||||
|
||||
const PAGE_TITLES: Record<Page, string> = {
|
||||
home: 'Главная',
|
||||
decks: 'Колоды',
|
||||
sync: 'Синхронизация',
|
||||
};
|
||||
|
||||
@@ -46,6 +48,8 @@ function renderPage(page: Page): void {
|
||||
setActiveButton(page);
|
||||
if (page === 'home') {
|
||||
renderHomePage(pageRoot, {});
|
||||
} else if (page === 'decks') {
|
||||
renderDecksPage(pageRoot);
|
||||
} else {
|
||||
renderSyncPage(pageRoot);
|
||||
}
|
||||
@@ -66,7 +70,7 @@ document.addEventListener('keydown', (e) => {
|
||||
drawer.querySelectorAll<HTMLButtonElement>('.drawer-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const page = btn.dataset.page as Page | undefined;
|
||||
if (page === 'home' || page === 'sync') renderPage(page);
|
||||
if (page === 'home' || page === 'decks' || page === 'sync') renderPage(page);
|
||||
setDrawer(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -472,3 +472,8 @@ body.drawer-open {
|
||||
margin: 2px 0 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Пустые значения в раскрытии колоды — приглушённое тире */
|
||||
.empty-val {
|
||||
opacity: 0.5;
|
||||
}
|
||||
Reference in new issue
Block a user