displaing words
This commit is contained in:
1 parent
8a0834abac
commit
ca18ee8cc7
14 files changed
+540
-90
No files matched your search
+133
-20
@@ -7,7 +7,7 @@
|
||||
// т.е. количество дней с 1970-01-01, без времени;
|
||||
// - states.fsrs_memory: сырое INTEGER-значение FSRS (не дата, не трогаем).
|
||||
|
||||
import { query } from "./sqlite-api";
|
||||
import { query } from "./sqlite-api.ts";
|
||||
|
||||
export interface CustomField {
|
||||
id: number;
|
||||
@@ -37,32 +37,145 @@ export interface Word {
|
||||
id: number;
|
||||
key: string;
|
||||
value: string;
|
||||
/** Сырое значение колонки tags (как лежит в БД). */
|
||||
tags: string;
|
||||
/** Распарсенные теги для UI (split по запятой/пробелам/точке с запятой). */
|
||||
tagsList: string[];
|
||||
/**
|
||||
* Доп. поля: more хранится как чередование пар через \u001f:
|
||||
* "fieldId\u001fvalue\u001ffieldId\u001fvalue...".
|
||||
* Ключ — id из custom_fields, значение — текст.
|
||||
*/
|
||||
more: Map<number, string>;
|
||||
group_id: number;
|
||||
}
|
||||
|
||||
export async function wordList() {
|
||||
let rows = await query("SELECT id, key, value, tags, more, group_id FROM words");
|
||||
|
||||
return rows.map((row: any) => {
|
||||
let more : string = row.more.split('');
|
||||
let dict = new Map();
|
||||
for (let i = 0; i < more.length / 2; i++) {
|
||||
dict.set(more[i * 2], more[i * 2 + 1]);
|
||||
}
|
||||
let w = {
|
||||
id: row.id,
|
||||
key: row.key,
|
||||
value: row.value,
|
||||
tags: row.tags,
|
||||
more: dict,
|
||||
group_id: row.group_id,
|
||||
}
|
||||
return w;
|
||||
}) as Word[];
|
||||
export interface WordListFilter {
|
||||
/** Подстрока для поиска по key/value/tags (LIKE %q%, без учёта регистра). */
|
||||
search?: string;
|
||||
/** Фильтр по группе. undefined/null = все группы. */
|
||||
groupId?: number | null;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
/** Разбирает сырое поле tags в список чипсов. */
|
||||
export function parseTags(raw: unknown): string[] {
|
||||
if (raw == null) return [];
|
||||
const s = String(raw).trim();
|
||||
if (!s) return [];
|
||||
return s
|
||||
.split(/[,\s;]+/)
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Разбирает сырое поле more в Map<fieldId, value>.
|
||||
* Формат десктопа: пары через \u001f (Unit Separator):
|
||||
* "3\u001fчтение\u001f5\u001fпример". Нечисловые ключи игнорируем,
|
||||
* висячий хвост без пары отбрасываем, пустые значения пропускаем.
|
||||
*/
|
||||
export function parseMore(raw: unknown): Map<number, string> {
|
||||
const dict = new Map<number, string>();
|
||||
if (raw == null) return dict;
|
||||
const s = String(raw);
|
||||
if (!s) return dict;
|
||||
const parts = s.split("");
|
||||
for (let i = 0; i + 1 < parts.length; i += 2) {
|
||||
const id = Number(parts[i]);
|
||||
const val = parts[i + 1] ?? "";
|
||||
if (!Number.isFinite(id) || !Number.isInteger(id)) continue;
|
||||
if (!val) continue;
|
||||
dict.set(id, val);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
function mapRowToWord(row: Record<string, unknown>): Word {
|
||||
const id = Number(row["id"] ?? 0);
|
||||
const key = String(row["key"] ?? "");
|
||||
const value = String(row["value"] ?? "");
|
||||
const tagsRaw = row["tags"] == null ? "" : String(row["tags"]);
|
||||
const group_id = Number(row["group_id"] ?? 1);
|
||||
return {
|
||||
id,
|
||||
key,
|
||||
value,
|
||||
tags: tagsRaw,
|
||||
tagsList: parseTags(tagsRaw),
|
||||
more: parseMore(row["more"]),
|
||||
group_id: Number.isFinite(group_id) ? group_id : 1,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWhere(filter: WordListFilter): { clause: string; params: unknown[] } {
|
||||
const conds: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
const search = (filter.search ?? "").trim();
|
||||
if (search) {
|
||||
// Простой LIKE-поиск по трём колонкам. more не трогаем (там id+значения вперемешку).
|
||||
conds.push("(key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\')");
|
||||
const like = `%${search.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_")}%`;
|
||||
params.push(like, like, like);
|
||||
}
|
||||
if (filter.groupId != null) {
|
||||
conds.push("group_id = ?");
|
||||
params.push(filter.groupId);
|
||||
}
|
||||
return { clause: conds.length > 0 ? `WHERE ${conds.join(" AND ")}` : "", params };
|
||||
}
|
||||
|
||||
/** Сколько всего слов подходит под фильтр (для пагинации/счётчика). */
|
||||
export async function countWords(filter: WordListFilter = {}): Promise<number> {
|
||||
const { clause, params } = buildWhere(filter);
|
||||
const rows = await query(`SELECT COUNT(*) as cnt FROM words ${clause}`, params as never[]);
|
||||
const cnt = (rows[0] as Record<string, unknown> | undefined)?.["cnt"];
|
||||
return typeof cnt === "number" ? cnt : Number(cnt ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Визуализация слов для главной страницы: поиск + фильтр по группе + пагинация.
|
||||
* Сортировка — по id (порядок добавления), чтобы список был стабильным.
|
||||
*/
|
||||
export async function getWords(filter: WordListFilter = {}): Promise<Word[]> {
|
||||
const { clause, params } = buildWhere(filter);
|
||||
const limit = Math.max(1, Math.min(filter.limit ?? 50, 500));
|
||||
const offset = Math.max(0, filter.offset ?? 0);
|
||||
const rows = await query(
|
||||
`SELECT id, key, value, tags, more, group_id FROM words ${clause} ORDER BY id LIMIT ? OFFSET ?`,
|
||||
[...(params as unknown[]), limit, offset] as never[],
|
||||
);
|
||||
return (rows as Record<string, unknown>[]).map(mapRowToWord);
|
||||
}
|
||||
|
||||
/**
|
||||
* Легаси-обёртка: все слова без пагинации (для десктоп-совместимости / отладки).
|
||||
* Для UI используй getWords() + countWords().
|
||||
*/
|
||||
export async function wordList(): Promise<Word[]> {
|
||||
const rows = await query("SELECT id, key, value, tags, more, group_id FROM words ORDER BY id");
|
||||
return (rows as Record<string, unknown>[]).map(mapRowToWord);
|
||||
}
|
||||
|
||||
export async function getWordGroups(): Promise<WordGroup[]> {
|
||||
const rows = await query("SELECT id, name FROM word_groups ORDER BY id");
|
||||
return (rows as Record<string, unknown>[]).map((r) => ({
|
||||
id: Number(r["id"]),
|
||||
name: String(r["name"] ?? ""),
|
||||
}));
|
||||
}
|
||||
|
||||
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) => ({
|
||||
id: Number(r["id"]),
|
||||
name: String(r["name"] ?? ""),
|
||||
identifier: String(r["identifier"] ?? ""),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
export interface HistoryEntry {
|
||||
id: number;
|
||||
deck_id: number;
|
||||
|
||||
+5
-5
@@ -1,5 +1,5 @@
|
||||
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
|
||||
import { DB_FILENAME } from './opfs';
|
||||
import { DB_FILENAME } from './opfs.ts';
|
||||
|
||||
let db: any | null = null;
|
||||
|
||||
@@ -7,7 +7,6 @@ self.onmessage = async (e) => {
|
||||
const { type, id, sql, params } = e.data;
|
||||
|
||||
try {
|
||||
console.log(e.data.type)
|
||||
switch (type) {
|
||||
case 'start': {
|
||||
if (db != null) {
|
||||
@@ -30,14 +29,14 @@ self.onmessage = async (e) => {
|
||||
}
|
||||
case 'query': {
|
||||
if (db === null) throw new Error('БД не инициализирована');
|
||||
const result = db.selectObjects(sql, params);
|
||||
const result = db.selectObjects(sql, params ?? []);
|
||||
self.postMessage({ type: 'success', id, data: result });
|
||||
|
||||
break;
|
||||
}
|
||||
case 'command': {
|
||||
if (db === null) throw new Error('БД не инициализирована');
|
||||
db.exec(sql, params);
|
||||
db.exec(sql, params ?? []);
|
||||
self.postMessage({ type: 'success', id, data: null });
|
||||
break;
|
||||
}
|
||||
@@ -45,11 +44,12 @@ self.onmessage = async (e) => {
|
||||
if (db === null) throw new Error('БД не инициализирована');
|
||||
db.close();
|
||||
db = null;
|
||||
self.postMessage({ type: 'success', id, data: null });
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
self.postMessage({ type: 'error', message: err.message , id});
|
||||
self.postMessage({ type: 'error', error: err?.message ?? String(err), id });
|
||||
}
|
||||
};
|
||||
+177
-7
@@ -1,8 +1,8 @@
|
||||
// Главная страница: статус локальной базы + быстрый переход к синхронизации.
|
||||
// Никакой БД-логики кроме проверки наличия файла в OPFS.
|
||||
// Главная страница: статус локальной базы + визуализация слов (getWords).
|
||||
// Поиск по key/value/tags, фильтр по группе, пагинация «Показать ещё».
|
||||
|
||||
import { wordList } from './db-types.ts';
|
||||
import { worker } from './main.ts';
|
||||
import { countWords, getCustomFields, getWordGroups, getWords } from './db-types.ts';
|
||||
import type { CustomField, Word, WordGroup } from './db-types.ts';
|
||||
import { hasDatabaseFile, readDatabaseFile } from './opfs.ts';
|
||||
import { looksLikeSQLite } from './zstd.ts';
|
||||
|
||||
@@ -10,6 +10,36 @@ export type HomeNav = {
|
||||
goSync: () => void;
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.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>`
|
||||
: '';
|
||||
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>`;
|
||||
}
|
||||
|
||||
export function renderHomePage(root: HTMLElement, nav: HomeNav): void {
|
||||
root.innerHTML = `
|
||||
<main class="card" aria-labelledby="home-title">
|
||||
@@ -23,23 +53,150 @@ export function renderHomePage(root: HTMLElement, nav: HomeNav): void {
|
||||
<button id="btn-start" class="btn" type="button">▶ Начать повторение</button>
|
||||
</div>
|
||||
</main>
|
||||
<section class="card" aria-labelledby="words-title">
|
||||
<h2 id="words-title" class="card-title">📚 Слова</h2>
|
||||
<div class="words-toolbar">
|
||||
<input id="words-search" class="words-search" type="search" placeholder="Поиск: ключ, перевод, тег…"
|
||||
autocomplete="off" aria-label="Поиск по словам" />
|
||||
<select id="words-group" class="words-group" aria-label="Фильтр по группе">
|
||||
<option value="">Все группы</option>
|
||||
</select>
|
||||
</div>
|
||||
<p id="words-count" class="card-hint" role="status" aria-live="polite"></p>
|
||||
<div id="words-status" class="status-bar" role="status">База ещё не проверена.</div>
|
||||
<div id="words-list" class="words-list" aria-live="polite"></div>
|
||||
<div class="btn-row"><button id="words-more" class="btn" type="button" hidden>Показать ещё</button></div>
|
||||
</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 searchInput = root.querySelector<HTMLInputElement>('#words-search')!;
|
||||
const groupSelect = root.querySelector<HTMLSelectElement>('#words-group')!;
|
||||
const countEl = root.querySelector<HTMLElement>('#words-count')!;
|
||||
const wordsStatus = root.querySelector<HTMLElement>('#words-status')!;
|
||||
const listEl = root.querySelector<HTMLElement>('#words-list')!;
|
||||
const moreBtn = root.querySelector<HTMLButtonElement>('#words-more')!;
|
||||
|
||||
goSync.addEventListener('click', () => nav.goSync());
|
||||
btnStart.addEventListener('click', () => {
|
||||
statusEl.textContent = 'Экран повторений — следующий шаг.';
|
||||
});
|
||||
|
||||
// --- Состояние списка слов ---
|
||||
let dbReady = false;
|
||||
let gnames = new Map<number, string>();
|
||||
let fmap = new Map<number, CustomField>();
|
||||
let total = 0;
|
||||
let loaded: Word[] = [];
|
||||
let offset = 0;
|
||||
let loading = false;
|
||||
let reqSeq = 0;
|
||||
let debounce = 0;
|
||||
|
||||
function wordsFilter(): { search: string; groupId: number | null } {
|
||||
const v = groupSelect.value;
|
||||
if (v === '') return { search: searchInput.value.trim(), groupId: null };
|
||||
const n = Number(v);
|
||||
return { search: searchInput.value.trim(), groupId: Number.isFinite(n) ? n : null };
|
||||
}
|
||||
|
||||
function refreshMoreBtn(): void {
|
||||
moreBtn.hidden = !(dbReady && loaded.length < total);
|
||||
if (!moreBtn.hidden) moreBtn.textContent = `Показать ещё (${total - loaded.length} осталось)`;
|
||||
}
|
||||
|
||||
function fullRender(): void {
|
||||
listEl.innerHTML = loaded.map((w) => wordCardHtml(w, gnames.get(w.group_id), fmap)).join('');
|
||||
countEl.textContent = total > 0 ? `Показано ${loaded.length} из ${total}` : '';
|
||||
refreshMoreBtn();
|
||||
}
|
||||
|
||||
function setWStatus(msg: string): void {
|
||||
wordsStatus.textContent = msg;
|
||||
wordsStatus.hidden = msg === '';
|
||||
}
|
||||
|
||||
async function reloadWords(): Promise<void> {
|
||||
if (!dbReady || loading) return;
|
||||
loading = true;
|
||||
const my = ++reqSeq;
|
||||
const f = wordsFilter();
|
||||
moreBtn.disabled = true;
|
||||
if (loaded.length === 0) setWStatus('Загрузка слов…');
|
||||
try {
|
||||
const [cnt, words] = await Promise.all([
|
||||
countWords({ search: f.search, groupId: f.groupId ?? undefined }),
|
||||
getWords({ search: f.search, groupId: f.groupId ?? undefined, limit: PAGE_SIZE, offset: 0 }),
|
||||
]);
|
||||
if (my !== reqSeq) return;
|
||||
total = cnt;
|
||||
loaded = words;
|
||||
offset = words.length;
|
||||
fullRender();
|
||||
setWStatus(words.length === 0 ? 'По этому запросу слов нет.' : '');
|
||||
if (words.length === 0 && total === 0) countEl.textContent = 'Слов в базе пока нет.';
|
||||
} catch (err) {
|
||||
if (my !== reqSeq) return;
|
||||
setWStatus(`Не удалось загрузить слова: ${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
if (my === reqSeq) {
|
||||
loading = false;
|
||||
moreBtn.disabled = false;
|
||||
refreshMoreBtn();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore(): Promise<void> {
|
||||
if (!dbReady || loading || loaded.length >= total) return;
|
||||
loading = true;
|
||||
const my = ++reqSeq;
|
||||
const f = wordsFilter();
|
||||
moreBtn.disabled = true;
|
||||
moreBtn.textContent = 'Загрузка…';
|
||||
try {
|
||||
const words = await getWords({ search: f.search, groupId: f.groupId ?? undefined, limit: PAGE_SIZE, offset });
|
||||
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(''));
|
||||
countEl.textContent = `Показано ${loaded.length} из ${total}`;
|
||||
refreshMoreBtn();
|
||||
} catch (err) {
|
||||
if (my !== reqSeq) return;
|
||||
setWStatus(`Не удалось догрузить: ${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
if (my === reqSeq) {
|
||||
loading = false;
|
||||
moreBtn.disabled = false;
|
||||
refreshMoreBtn();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
searchInput.addEventListener('input', () => {
|
||||
window.clearTimeout(debounce);
|
||||
debounce = window.setTimeout(() => void reloadWords(), 300);
|
||||
});
|
||||
searchInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
window.clearTimeout(debounce);
|
||||
void reloadWords();
|
||||
}
|
||||
});
|
||||
groupSelect.addEventListener('change', () => void reloadWords());
|
||||
moreBtn.addEventListener('click', () => void loadMore());
|
||||
|
||||
void (async () => {
|
||||
const has = await hasDatabaseFile();
|
||||
if (!has) {
|
||||
statusEl.textContent = 'Локальной базы нет. Скачай сохранение на странице «Синхронизация».';
|
||||
actions.hidden = true;
|
||||
setWStatus('Нет локальной базы — скачай сохранение на странице «Синхронизация».');
|
||||
countEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -49,9 +206,22 @@ export function renderHomePage(root: HTMLElement, nav: HomeNav): void {
|
||||
? `Локальная база на месте: ${(raw.length / 1024).toFixed(1)} КБ, сигнатура SQLite OK.`
|
||||
: 'Файл в OPFS есть, но это не похоже на SQLite — перекачай сохранение.';
|
||||
actions.hidden = !ok;
|
||||
if (ok) {
|
||||
let list = await wordList();
|
||||
console.log(list);
|
||||
if (!ok) {
|
||||
setWStatus('Файл в OPFS не похож на SQLite — перекачай сохранение.');
|
||||
return;
|
||||
}
|
||||
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('');
|
||||
dbReady = true;
|
||||
await reloadWords();
|
||||
groupSelect.options[0].textContent = `Все группы (${total})`;
|
||||
} catch (err) {
|
||||
setWStatus(`БД есть, но слова не прочитались: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
} catch (err) {
|
||||
statusEl.textContent = `Не удалось прочитать базу: ${err instanceof Error ? err.message : String(err)}`;
|
||||
|
||||
@@ -3,10 +3,6 @@ import { renderHomePage } from './home-page.ts';
|
||||
import { renderSyncPage } from './sync-page.ts';
|
||||
import { initWorker } from './sqlite-api.ts';
|
||||
|
||||
export const worker = new Worker(new URL('./db-worker.ts', import.meta.url), {
|
||||
type: 'module',
|
||||
});
|
||||
|
||||
// Каркас приложения: топ-бар + бургер-меню + две страницы (Главная / Синхронизация).
|
||||
|
||||
type Page = 'home' | 'sync';
|
||||
|
||||
+6
-6
@@ -1,4 +1,6 @@
|
||||
import { worker } from "./main";
|
||||
const worker = new Worker(new URL('./db-worker.ts', import.meta.url), {
|
||||
type: 'module',
|
||||
});
|
||||
|
||||
let requestId = 0;
|
||||
|
||||
@@ -14,7 +16,6 @@ export function initWorker(): Promise<void> {
|
||||
|
||||
initPromise = new Promise((resolve, reject) => {
|
||||
worker.onmessage = (e: MessageEvent) => {
|
||||
console.log("Handling worker output");
|
||||
const msg = e.data;
|
||||
|
||||
if (msg.type === 'ready') {
|
||||
@@ -29,8 +30,7 @@ export function initWorker(): Promise<void> {
|
||||
if (msg.type == "success") {
|
||||
pendingReq.resolve(msg.data);
|
||||
} else {
|
||||
console.log(msg)
|
||||
pendingReq.reject(new Error(msg.error || 'Неизвестная ошибка Worker'));
|
||||
pendingReq.reject(new Error(msg.error ?? msg.message ?? 'Неизвестная ошибка Worker'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -63,12 +63,12 @@ function sendToWorker(type: string, payload?: any): Promise<any> {
|
||||
}
|
||||
|
||||
export async function query(sql: string, params?: any[]): Promise<any[]> {
|
||||
const result = await sendToWorker('query', { sql, params });
|
||||
const result = await sendToWorker('query', { sql, params: params ?? [] });
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function command(sql: string, params?: any[]): Promise<void> {
|
||||
await sendToWorker('command', { sql, params });
|
||||
await sendToWorker('command', { sql, params: params ?? [] });
|
||||
}
|
||||
|
||||
export async function closeDb(): Promise<void> {
|
||||
|
||||
+149
@@ -281,3 +281,152 @@ body.drawer-open {
|
||||
font-size: 0.95rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.status-bar[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* --- Список слов (главная) --- */
|
||||
|
||||
.words-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.words-search {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
font-size: 1rem;
|
||||
border: 2px solid #d9d2f0;
|
||||
border-radius: 12px;
|
||||
outline: none;
|
||||
background: #fff;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.words-search:focus {
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
|
||||
.words-group {
|
||||
flex: none;
|
||||
max-width: 42%;
|
||||
padding: 12px 8px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #4c446b;
|
||||
border: 2px solid #d9d2f0;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.words-group:focus {
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
|
||||
.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-card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.word-key {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.word-value {
|
||||
font-size: 1rem;
|
||||
color: #4c446b;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.word-group {
|
||||
flex: none;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: #6d28d9;
|
||||
background: #f1eafe;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
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;
|
||||
}
|
||||
|
||||
.word-more {
|
||||
font-size: 0.9rem;
|
||||
color: #4c446b;
|
||||
}
|
||||
|
||||
.word-more summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: #6d28d9;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.word-more-list {
|
||||
margin: 8px 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.word-more-row {
|
||||
background: #fff;
|
||||
border: 1px solid #eee9fb;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.word-more-row dt {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: #6d6390;
|
||||
}
|
||||
|
||||
.word-more-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