updated main page
This commit is contained in:
1 parent
ca18ee8cc7
commit
6e609cc9fa
7 files changed
+276
-176
No files matched your search
+114
-48
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user