updated main page

This commit is contained in:
micialware committed 2026-09-15 10:51:04 +03:00
1 parent ca18ee8cc7
commit 6e609cc9fa
7 files changed
+276 -176

No files matched your search

+114 -48
View File
@@ -7,7 +7,7 @@ import { hasDatabaseFile, readDatabaseFile } from './opfs.ts';
import { looksLikeSQLite } from './zstd.ts';
export type HomeNav = {
goSync: () => void;
goSync?: () => void;
};
const PAGE_SIZE = 30;
@@ -21,38 +21,43 @@ function escapeHtml(s: string): string {
.replace(/'/g, ''');
}
function wordCardHtml(w: Word, gname: string | undefined, fmap: Map<number, CustomField>): string {
const tags = w.tagsList.map((t) => `<span class="tag-chip">${escapeHtml(t)}</span>`).join('');
const mrows = [...w.more.entries()]
.map(([fid, val]) => {
const f = fmap.get(fid);
const label = f ? `${escapeHtml(f.name)} <span class="word-more-id">#${fid}</span>` : `Поле #${fid}`;
return `<div class="word-more-row"><dt>${label}</dt><dd>${escapeHtml(val)}</dd></div>`;
})
.join('');
const more = mrows
? `<details class="word-more"><summary>Дополнительно (${w.more.size})</summary><dl class="word-more-list">${mrows}</dl></details>`
function wordRowHtml(w: Word, fmap: Map<number, CustomField>, expanded: boolean): string {
const firstTag = w.tagsList[0] ?? '';
const extraCount = w.tagsList.length > 1 ? ` +${w.tagsList.length - 1}` : '';
const tagTitle = escapeHtml(w.tagsList.join(', '));
const tag = firstTag
? `<span class="word-tag" title="${tagTitle}">${escapeHtml(firstTag)}${extraCount}</span>`
: '';
return `<article class="word-card" data-word-id="${w.id}">
<div class="word-card-top"><div class="word-key">${escapeHtml(w.key)}</div>`
+ (gname ? `<span class="word-group">${escapeHtml(gname)}</span>` : '')
+ `</div><div class="word-value">${escapeHtml(w.value)}</div>`
+ (tags ? `<div class="tag-row">${tags}</div>` : '') + more + `</article>`;
// Маркер наличия доп. полей (имена берём из custom_fields при раскрытии)
const dot = w.more.size > 0 ? '<span class="word-dot" aria-hidden="true"></span>' : '';
return `<div class="word-row-wrap" data-word-id="${w.id}">`
+ `<button class="word-row" type="button" data-word-id="${w.id}"`
+ ` aria-expanded="${expanded}" aria-controls="extra-${w.id}"${w.more.size === 0 ? ' disabled' : ''}>`
+ `<span class="word-main"><span class="word-key">${escapeHtml(w.key)}</span>`
+ (w.value ? `<span class="word-sep"> — </span><span class="word-value">${escapeHtml(w.value)}</span>` : '')
+ `</span>${dot}${tag}</button>`
+ `<div class="word-extra" id="extra-${w.id}"${expanded ? '' : ' hidden'}>`
+ (expanded ? wordExtraHtml(w, fmap) : '')
+ `</div></div>`;
}
export function renderHomePage(root: HTMLElement, nav: HomeNav): void {
function wordExtraHtml(w: Word, fmap: Map<number, CustomField>): string {
const rows = [...w.more.entries()]
.map(([fid, val]) => {
const f = fmap.get(fid);
const label = f ? escapeHtml(f.name) : `Поле #${fid}`;
return `<div class="word-extra-row"><dt>${label}</dt><dd>${escapeHtml(val)}</dd></div>`;
})
.join('');
return rows ? `<dl class="word-extra-list">${rows}</dl>` : '';
}
export function renderHomePage(root: HTMLElement, _nav: HomeNav): void {
root.innerHTML = `
<main class="card" aria-labelledby="home-title">
<h2 id="home-title" class="card-title">🏠 Главная</h2>
<p class="card-hint">Локальное сохранение и повторения — всё работает офлайн.</p>
<div id="home-db-status" class="status-bar" role="status">Проверка локальной базы…</div>
<div class="btn-row">
<button id="btn-go-sync" class="btn btn-primary" type="button">📡 К синхронизации</button>
</div>
<div id="home-actions" class="btn-row" hidden>
<button id="btn-start" class="btn" type="button">▶ Начать повторение</button>
</div>
</main>
<div id="home-db-banner" class="db-banner" role="status" hidden>
<span id="home-db-text" class="db-banner-text">Проверка локальной базы…</span>
<button id="home-db-go" class="db-banner-link" type="button">Синхронизация →</button>
</div>
<section class="card" aria-labelledby="words-title">
<h2 id="words-title" class="card-title">📚 Слова</h2>
<div class="words-toolbar">
@@ -69,10 +74,9 @@ export function renderHomePage(root: HTMLElement, nav: HomeNav): void {
</section>
`;
const statusEl = root.querySelector<HTMLElement>('#home-db-status')!;
const goSync = root.querySelector<HTMLButtonElement>('#btn-go-sync')!;
const actions = root.querySelector<HTMLElement>('#home-actions')!;
const btnStart = root.querySelector<HTMLButtonElement>('#btn-start')!;
const banner = root.querySelector<HTMLElement>('#home-db-banner')!;
const bannerText = root.querySelector<HTMLElement>('#home-db-text')!;
const bannerGo = root.querySelector<HTMLButtonElement>('#home-db-go')!;
const searchInput = root.querySelector<HTMLInputElement>('#words-search')!;
const groupSelect = root.querySelector<HTMLSelectElement>('#words-group')!;
const countEl = root.querySelector<HTMLElement>('#words-count')!;
@@ -80,17 +84,27 @@ export function renderHomePage(root: HTMLElement, nav: HomeNav): void {
const listEl = root.querySelector<HTMLElement>('#words-list')!;
const moreBtn = root.querySelector<HTMLButtonElement>('#words-more')!;
goSync.addEventListener('click', () => nav.goSync());
btnStart.addEventListener('click', () => {
statusEl.textContent = 'Экран повторений — следующий шаг.';
function showDbBanner(msg: string, showLink: boolean): void {
banner.hidden = false;
bannerText.textContent = msg;
bannerGo.hidden = !showLink;
}
function hideDbBanner(): void {
banner.hidden = true;
}
bannerGo.addEventListener('click', () => {
document.querySelector<HTMLButtonElement>('.drawer-btn[data-page="sync"]')?.click();
});
// --- Состояние списка слов ---
let dbReady = false;
let gnames = new Map<number, string>();
let fmap = new Map<number, CustomField>();
let total = 0;
let loaded: Word[] = [];
// id последнего раскрытого слова (аккордеон: открыт максимум один)
let expandedId: number | null = null;
let offset = 0;
let loading = false;
let reqSeq = 0;
@@ -109,11 +123,55 @@ export function renderHomePage(root: HTMLElement, nav: HomeNav): void {
}
function fullRender(): void {
listEl.innerHTML = loaded.map((w) => wordCardHtml(w, gnames.get(w.group_id), fmap)).join('');
listEl.innerHTML = loaded.map((w) => wordRowHtml(w, fmap, w.id === expandedId)).join('');
countEl.textContent = total > 0 ? `Показано ${loaded.length} из ${total}` : '';
refreshMoreBtn();
}
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);
}
function collapseRow(id: number): void {
const wrap = listEl.querySelector(`.word-row-wrap[data-word-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 w = loaded.find((x) => x.id === id);
const wrap = listEl.querySelector(`.word-row-wrap[data-word-id="${id}"]`);
if (!w || !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 = wordExtraHtml(w, fmap);
}
}
function setWStatus(msg: string): void {
wordsStatus.textContent = msg;
wordsStatus.hidden = msg === '';
@@ -135,6 +193,8 @@ export function renderHomePage(root: HTMLElement, nav: HomeNav): void {
total = cnt;
loaded = words;
offset = words.length;
// Раскрытие привязано к id: если слова больше нет в выдаче — сбрасываем
if (expandedId != null && !loaded.some((w) => w.id === expandedId)) expandedId = null;
fullRender();
setWStatus(words.length === 0 ? 'По этому запросу слов нет.' : '');
if (words.length === 0 && total === 0) countEl.textContent = 'Слов в базе пока нет.';
@@ -162,7 +222,7 @@ export function renderHomePage(root: HTMLElement, nav: HomeNav): void {
if (my !== reqSeq) return;
loaded = loaded.concat(words);
offset += words.length;
listEl.insertAdjacentHTML('beforeend', words.map((w) => wordCardHtml(w, gnames.get(w.group_id), fmap)).join(''));
listEl.insertAdjacentHTML('beforeend', words.map((w) => wordRowHtml(w, fmap, w.id === expandedId)).join(''));
countEl.textContent = `Показано ${loaded.length} из ${total}`;
refreshMoreBtn();
} catch (err) {
@@ -190,11 +250,20 @@ export function renderHomePage(root: HTMLElement, nav: HomeNav): void {
groupSelect.addEventListener('change', () => void reloadWords());
moreBtn.addEventListener('click', () => void loadMore());
// Делегирование: тап по строке раскрывает доп. поля (только последнее нажатое).
// Без доп. полей кнопка disabled и не реагирует.
listEl.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.word-row');
if (!btn || btn.disabled) return;
const id = Number(btn.dataset.wordId);
if (!Number.isFinite(id)) return;
setExpanded(expandedId === id ? null : id);
});
void (async () => {
const has = await hasDatabaseFile();
if (!has) {
statusEl.textContent = 'Локальной базы нет. Скачай сохранение на странице «Синхронизация».';
actions.hidden = true;
showDbBanner('Локальной базы нет — скачай сохранение.', true);
setWStatus('Нет локальной базы — скачай сохранение на странице «Синхронизация».');
countEl.textContent = '';
return;
@@ -202,18 +271,16 @@ export function renderHomePage(root: HTMLElement, nav: HomeNav): void {
try {
const raw = await readDatabaseFile();
const ok = looksLikeSQLite(raw);
statusEl.textContent = ok
? `Локальная база на месте: ${(raw.length / 1024).toFixed(1)} КБ, сигнатура SQLite OK.`
: 'Файл в OPFS есть, но это не похоже на SQLite — перекачай сохранение.';
actions.hidden = !ok;
if (!ok) {
showDbBanner('Файл в OPFS не похож на SQLite — перекачай сохранение.', true);
setWStatus('Файл в OPFS не похож на SQLite — перекачай сохранение.');
return;
}
// База в порядке — баннер прячем, слова занимают весь экран
hideDbBanner();
try {
const [gs, fields] = await Promise.all([getWordGroups(), getCustomFields()]);
const groups: WordGroup[] = gs;
gnames = new Map(groups.map((g) => [g.id, g.name]));
fmap = new Map(fields.map((f) => [f.id, f]));
groupSelect.innerHTML = '<option value="">Все группы</option>'
+ groups.map((g) => `<option value="${g.id}">${escapeHtml(g.name)}</option>`).join('');
@@ -224,8 +291,7 @@ export function renderHomePage(root: HTMLElement, nav: HomeNav): void {
setWStatus(`БД есть, но слова не прочитались: ${err instanceof Error ? err.message : String(err)}`);
}
} catch (err) {
statusEl.textContent = `Не удалось прочитать базу: ${err instanceof Error ? err.message : String(err)}`;
actions.hidden = true;
showDbBanner(`Не удалось прочитать базу: ${err instanceof Error ? err.message : String(err)}`, true);
}
})();
}
+1 -1
View File
@@ -45,7 +45,7 @@ function renderPage(page: Page): void {
pageTitle.textContent = PAGE_TITLES[page];
setActiveButton(page);
if (page === 'home') {
renderHomePage(pageRoot, { goSync: () => renderPage('sync') });
renderHomePage(pageRoot, {});
} else {
renderSyncPage(pageRoot);
}
+106 -64
View File
@@ -286,6 +286,44 @@ body.drawer-open {
display: none;
}
/* Компактный баннер статуса БД: виден только при проблеме.
В норме скрыт — слова занимают весь экран. */
.db-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 10px;
background: #fff7e6;
color: #7a5b00;
font-size: 0.85rem;
}
.db-banner[hidden] {
display: none;
}
.db-banner-text {
flex: 1;
min-width: 0;
}
.db-banner-link {
flex: none;
border: none;
background: transparent;
padding: 4px;
font: inherit;
font-weight: 700;
color: #7c3aed;
cursor: pointer;
touch-action: manipulation;
}
.db-banner-link[hidden] {
display: none;
}
/* --- Список слов (главная) --- */
.words-toolbar {
@@ -329,104 +367,108 @@ body.drawer-open {
.words-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.word-card {
border: 1px solid #eee9fb;
border-radius: 12px;
padding: 12px;
background: #fbfaff;
display: flex;
flex-direction: column;
gap: 8px;
/* Плоская строка: ключ + перевод слева, тег справа. Без карточек. */
.word-row-wrap {
border-bottom: 1px solid #eee9fb;
}
.word-card-top {
.word-row-wrap:last-child {
border-bottom: none;
}
.word-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
align-items: baseline;
gap: 8px;
width: 100%;
padding: 11px 4px;
border: none;
background: transparent;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
touch-action: manipulation;
border-radius: 8px;
}
.word-row:disabled {
cursor: default;
}
.word-row:not(:disabled):active {
background: #f4f1fa;
}
.word-row.open {
background: #f4f1fa;
}
.word-main {
flex: 1;
min-width: 0;
overflow-wrap: break-word;
}
.word-key {
font-size: 1.2rem;
font-weight: 700;
line-height: 1.3;
word-break: break-word;
}
.word-sep {
color: #6d6390;
}
.word-value {
font-size: 1rem;
color: #4c446b;
word-break: break-word;
}
.word-group {
/* Тег справа — плоской подписью, без плашки */
.word-tag {
flex: none;
font-size: 0.75rem;
font-weight: 700;
color: #6d28d9;
background: #f1eafe;
border-radius: 999px;
padding: 4px 10px;
max-width: 38%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag-chip {
font-size: 0.8rem;
font-weight: 600;
color: #5b21b6;
background: #ede9fe;
border-radius: 999px;
padding: 3px 10px;
word-break: break-word;
color: #6d6390;
text-align: right;
}
.word-more {
font-size: 0.9rem;
color: #4c446b;
/* Точка — маркер наличия доп. полей (custom_fields) */
.word-dot {
flex: none;
width: 6px;
height: 6px;
border-radius: 50%;
background: #7c3aed;
opacity: 0.55;
align-self: center;
}
.word-more summary {
cursor: pointer;
font-weight: 600;
color: #6d28d9;
touch-action: manipulation;
/* Раскрытые доп. поля — только у последнего нажатого */
.word-extra[hidden] {
display: none;
}
.word-more-list {
margin: 8px 0 0;
.word-extra-list {
margin: 0 0 10px;
padding: 10px 12px;
background: #f4f1fa;
border-radius: 10px;
display: flex;
flex-direction: column;
gap: 6px;
gap: 8px;
}
.word-more-row {
background: #fff;
border: 1px solid #eee9fb;
border-radius: 8px;
padding: 8px 10px;
}
.word-more-row dt {
.word-extra-row dt {
font-size: 0.8rem;
font-weight: 700;
color: #6d6390;
}
.word-more-row dd {
.word-extra-row dd {
margin: 2px 0 0;
word-break: break-word;
}
.word-more-id {
font-weight: 400;
opacity: 0.7;
}