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

-60
View File
@@ -1,60 +0,0 @@
(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const o of document.querySelectorAll('link[rel="modulepreload"]'))c(o);new MutationObserver(o=>{for(const l of o)if(l.type==="childList")for(const h of l.addedNodes)h.tagName==="LINK"&&h.rel==="modulepreload"&&c(h)}).observe(document,{childList:!0,subtree:!0});function i(o){const l={};return o.integrity&&(l.integrity=o.integrity),o.referrerPolicy&&(l.referrerPolicy=o.referrerPolicy),o.crossOrigin==="use-credentials"?l.credentials="include":o.crossOrigin==="anonymous"?l.credentials="omit":l.credentials="same-origin",l}function c(o){if(o.ep)return;o.ep=!0;const l=i(o);fetch(o.href,l)}})();const _e=new Worker(new URL("/assets/db-worker-AHwpujSZ.js",import.meta.url),{type:"module"});let ir=0,Be=null;const De=new Map;function kn(){return Be||(Be=new Promise((r,n)=>{_e.onmessage=c=>{const o=c.data;if(o.type==="ready")return;const l=De.get(o.id);l&&(De.delete(o.id),o.type=="success"?l.resolve(o.data):l.reject(new Error(o.error??o.message??"Неизвестная ошибка Worker")))},_e.onerror=c=>{console.error("[SQLite Worker] Ошибка:",c),n(new Error("Ошибка Worker"))};const i=++ir;De.set(i,{resolve:r,reject:n}),_e.postMessage({type:"start",id:i})}),Be)}function $n(r,n){return new Promise((i,c)=>{if(!_e){c(new Error("Worker не создан"));return}const o=++ir;De.set(o,{resolve:i,reject:c}),_e.postMessage({type:r,id:o,...n})})}async function Ae(r,n){return await $n("query",{sql:r,params:n??[]})}function xn(r){if(r==null)return[];const n=String(r).trim();return n?n.split(/[,\s;]+/).map(i=>i.trim()).filter(i=>i.length>0):[]}function Qn(r){const n=new Map;if(r==null)return n;const i=String(r);if(!i)return n;const c=i.split("");for(let o=0;o+1<c.length;o+=2){const l=Number(c[o]),h=c[o+1]??"";!Number.isFinite(l)||!Number.isInteger(l)||h&&n.set(l,h)}return n}function Fn(r){const n=Number(r.id??0),i=String(r.key??""),c=String(r.value??""),o=r.tags==null?"":String(r.tags),l=Number(r.group_id??1);return{id:n,key:i,value:c,tags:o,tagsList:xn(o),more:Qn(r.more),group_id:Number.isFinite(l)?l:1}}function or(r){const n=[],i=[],c=(r.search??"").trim();if(c){n.push("(key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\')");const o=`%${c.replace(/\\/g,"\\\\").replace(/%/g,"\\%").replace(/_/g,"\\_")}%`;i.push(o,o,o)}return r.groupId!=null&&(n.push("group_id = ?"),i.push(r.groupId)),{clause:n.length>0?`WHERE ${n.join(" AND ")}`:"",params:i}}async function Nn(r={}){const{clause:n,params:i}=or(r),o=(await Ae(`SELECT COUNT(*) as cnt FROM words ${n}`,i))[0]?.cnt;return typeof o=="number"?o:Number(o??0)}async function Wt(r={}){const{clause:n,params:i}=or(r),c=Math.max(1,Math.min(r.limit??50,500)),o=Math.max(0,r.offset??0);return(await Ae(`SELECT id, key, value, tags, more, group_id FROM words ${n} ORDER BY id LIMIT ? OFFSET ?`,[...i,c,o])).map(Fn)}async function Tn(){return(await Ae("SELECT id, name FROM word_groups ORDER BY id")).map(n=>({id:Number(n.id),name:String(n.name??"")}))}async function Pn(){return(await Ae("SELECT id, name, identifier FROM custom_fields ORDER BY id")).map(n=>({id:Number(n.id),name:String(n.name??""),identifier:String(n.identifier??"")}))}const Ut="japanese_app.db";async function Ct(){return navigator.storage.getDirectory()}async function _n(r){r[18]=1,r[19]=1;const i=await(await Ct()).getFileHandle(Ut,{create:!0}),c=i.createSyncAccessHandle;if(typeof c=="function")try{const l=await c.call(i);try{l.truncate(0),l.write(r,{at:0}),l.flush()}finally{l.close()}return}catch{}const o=await i.createWritable();try{await o.truncate(0);const l=new Uint8Array(r.length);l.set(r),await o.write(l)}finally{await o.close()}}async function Hn(){const r=await Ct();let n;try{n=await r.getFileHandle(Ut)}catch{throw new Error("Локальная база не найдена — сначала скачай сохранение")}const i=await n.getFile();if(i.size===0)throw new Error("Локальная база пуста");return new Uint8Array(await i.arrayBuffer())}async function Ge(){try{return(await(await(await Ct()).getFileHandle(Ut)).getFile()).size>0}catch{return!1}}var qn=ArrayBuffer,z=Uint8Array,je=Uint16Array,Ln=Int16Array,et=Int32Array,ft=function(r,n,i){if(z.prototype.slice)return z.prototype.slice.call(r,n,i);(n==null||n<0)&&(n=0),(i==null||i>r.length)&&(i=r.length);var c=new z(i-n);return c.set(r.subarray(n,i)),c},He=function(r,n,i,c){if(z.prototype.fill)return z.prototype.fill.call(r,n,i,c);for((i==null||i<0)&&(i=0),(c==null||c>r.length)&&(c=r.length);i<c;++i)r[i]=n;return r},Rn=function(r,n,i,c){if(z.prototype.copyWithin)return z.prototype.copyWithin.call(r,n,i,c);for((i==null||i<0)&&(i=0),(c==null||c>r.length)&&(c=r.length);i<c;)r[n++]=r[i++]},In=["invalid zstd data","window size too large (>2046MB)","invalid block type","FSE accuracy too high","match distance too far back","unexpected EOF"],Y=function(r,n,i){var c=new Error(n||In[r]);if(c.code=r,Error.caLine truncated
<div class="word-card-top"><div class="word-key">${ye(r.key)}</div>`+(n?`<span class="word-group">${ye(n)}</span>`:"")+`</div><div class="word-value">${ye(r.value)}</div>`+(c?`<div class="tag-row">${c}</div>`:"")+l+"</article>"}function ua(r,n){r.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>
<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 i=r.querySelector("#home-db-status"),c=r.querySelector("#btn-go-sync"),o=r.querySelector("#home-actions"),l=r.querySelector("#btn-start"),h=r.querySelector("#words-search"),g=r.querySelector("#words-group"),v=r.querySelector("#words-count"),k=r.querySelector("#words-status"),C=r.querySelector("#words-list"),M=r.querySelector("#words-more");c.addEventListener("click",()=>n.goSync()),l.addEventListener("click",()=>{i.textContent="Экран повторений — следующий шаг."});let F=!1,y=new Map,N=new Map,w=0,$=[],D=0,J=!1,H=0,te=0;function K(){const T=g.value;if(T==="")return{search:h.value.trim(),groupId:null};const _=Number(T);return{search:h.value.trim(),groupId:Number.isFinite(_)?_:null}}function b(){M.hidden=!(F&&$.length<w),M.hidden||(M.textContent=`Показать ещё (${w-$.length} осталось)`)}function B(){C.innerHTML=$.map(T=>tr(T,y.get(T.group_id),N)).join(""),v.textContent=w>0?`Показано ${$.length} из ${w}`:"",b()}function S(T){k.textContent=T,k.hidden=T===""}async function G(){if(!F||J)return;J=!0;const T=++H,_=K();M.disabled=!0,$.length===0&&S("Загрузка слов…");try{const[P,U]=await Promise.all([Nn({search:_.search,groupId:_.groupId??void 0}),Wt({search:_.search,groupId:_.groupId??void 0,limit:er,offset:0})]);if(T!==H)return;w=P,$=U,D=U.length,B(),S(U.length===0?"По этому запросу слов нет.":""),U.length===0&&w===0&&(v.textContent="Слов в базе пока нет.")}catch(P){if(T!==H)return;S(`Не удалось загрузить слова: ${P instanceof Error?P.message:String(P)}`)}finally{T===H&&(J=!1,M.disabled=!1,b())}}async function j(){if(!F||J||$.length>=w)return;J=!0;const T=++H,_=K();M.disabled=!0,M.textContent="Загрузка…";try{const P=await Wt({search:_.search,groupId:_.groupId??void 0,limit:er,offset:D});if(T!==H)return;$=$.concat(P),D+=P.length,C.insertAdjacentHTML("beforeend",P.map(U=>tr(U,y.get(U.group_id),N)).join("")),v.textContent=`Показано ${$.length} из ${w}`,b()}catch(P){if(T!==H)return;S(`Не удалось догрузить: ${P instanceof Error?P.message:String(P)}`)}finally{T===H&&(J=!1,M.disabled=!1,b())}}h.addEventListener("input",()=>{window.clearTimeout(te),te=window.setTimeout(()=>{G()},300)}),h.addEventListener("keydown",T=>{T.key==="Enter"&&(window.clearTimeout(te),G())}),g.addEventListener("change",()=>{G()}),M.addEventListener("click",()=>{j()}),(async()=>{if(!await Ge()){i.textContent="Локальной базы нет. Скачай сохранение на странице «Синхронизация».",o.hidden=!0,S("Нет локальной базы — скачай сохранение на странице «Синхронизация»."),v.textContent="";return}try{const _=await Hn(),P=bt(_);if(i.textContent=P?`Локальная база на месте: ${(_.length/1024).toFixed(1)} КБ, сигнатура SQLite OK.`:"Файл в OPFS есть, но это не похоже на SQLite — перекачай сохранение.",o.hidden=!P,!P){S("Файл в OPFS не похож на SQLite — перекачай сохранение.");return}try{const[U,E]=await Promise.all([Tn(),Pn()]),V=U;y=new Map(V.map(O=>[O.id,O.name])),N=new Map(E.map(O=>[O.id,O])),g.innerHTML='<option value="">Все группы</option>'+V.map(O=>`<option value="${O.id}">${ye(O.name)}</option>`).join(""),F=!0,await G(),g.options[0].textContent=`Все группы (${w})`}catch(U){S(`БД есть, но слова не прочитались: ${U instanceof Error?U.message:String(U)}`)}}catch(_){i.textContent=`Не удалось прочитать базу: ${_ instanceof Error?_.message:String(_)}`,o.hidden=!0}})()}const rr=24,da=/^[A-Za-z0-9]{24}$/,lr="japlearn_sync_token";function nr(r){return r.trim().replace(/\s+/g,"")}function ve(r){return da.test(r)}function pa(){try{return localStorage.getItem(lr)??""}catch{return""}}function wt(r){try{localStorage.setItem(lr,r)}catch{}}const ur="https://learning.micialware.ru";function fa(r,n=ur){return`${n.replace(/\/+$/,"")}/download/${encodeURIComponent(r)}`}async function ha(r,n,i=ur){const c=fa(r,i);let o;try{o=await fetch(c,{method:"GET",...n})}catch(h){throw new Error(`Нет связи с сервером: ${h instanceof Error?h.message:String(h)}`)}if(!o.ok)throw o.status===404?new Error("Сохранение не найдено (404). Проверь токен."):new Error(`Сервер ответил ${o.status} ${o.statusText}`);const l=await o.arrayBuffer();if(l.byteLength===0)throw new Error("Сервер вернул пустой файл");return new Uint8Array(l)}function ga(r){r.innerHTML=`
<main class="card" aria-labelledby="sync-title">
<h2 id="sync-title" class="card-title">📡 Синхронизация</h2>
<p class="card-hint">Токен сохранения — 24 символа: латинские буквы и цифры.</p>
<label class="field-label" for="token-input">Токен сохранения</label>
<input
id="token-input"
class="token-input"
type="text"
inputmode="latin"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
maxlength="24"
minlength="24"
placeholder="Например: aB3dEf7hJk9mN2pQr5tUvWx1z"
aria-describedby="token-error token-counter"
/>
<div class="token-meta">
<span id="token-error" class="token-error" role="alert"></span>
<span id="token-counter" class="token-counter" aria-live="polite">0 / 24</span>
</div>
<div class="btn-row">
<button id="btn-download" class="btn" type="button" disabled>📥 Скачать</button>
<button id="btn-upload" class="btn btn-primary" type="button" disabled>📤 Выгрузить</button>
</div>
<p id="status" class="status-bar" role="status">Введите токен из 24 символов</p>
</main>
`;const n=r.querySelector("#token-input"),i=r.querySelector("#token-error"),c=r.querySelector("#token-counter"),o=r.querySelector("#btn-download"),l=r.querySelector("#btn-upload"),h=r.querySelector("#status");function g(y){h.textContent=y,console.log(`[JapLearn] ${y}`)}function v(){return nr(n.value)}function k(y){o.disabled=y||!ve(v()),l.disabled=!0,n.disabled=y,y||Ge().then(N=>{!n.disabled&&ve(v())&&(l.disabled=!N)})}function C(){const y=v(),N=y.length;c.textContent=`${N} / ${rr}`;const w=ve(y),$=N===0;n.classList.toggle("invalid",!$&&!w),n.setAttribute("aria-invalid",String(!$&&!w)),$?i.textContent="":N!==rr?i.textContent=`Нужно 24 символа, сейчас ${N}`:i.textContent=w?"":"Только латинские буквы и цифры",o.disabled=!w,w?Ge().then(D=>{n.disabled||(l.disabled=!D)}):l.disabled=!0}async function M(){const y=v();if(ve(y)){wt(y),k(!0),g("Скачивание сохранения...");try{const N=await ha(y);g(`Скачано ${(N.length/1024).toFixed(1)} КБ, распаковка...`);const w=await la(N);g(`Распаковано ${(w.length/1024).toFixed(1)} КБ, сохранение в OPFS...`),await _n(w),g(`Готово: база ${(w.length/1024).toFixed(1)} КБ сохранена локально (OPFS).`)}catch(N){g(`Ошибка скачивания: ${N instanceof Error?N.message:String(N)}`)}finally{k(!1),C()}}}n.addEventListener("input",()=>{C()}),n.addEventListener("change",()=>{const y=v();n.value=y,ve(y)&&(wt(y),g("Токен сохранён локально. Можно скачивать или выгружать.")),C()}),o.addEventListener("click",()=>{M()}),l.addEventListener("click",()=>{const y=v();ve(y)&&(wt(y),g("Выгрузка — следующий шаг (POST /upload/stream/{id})."))});const F=pa();F&&(n.value=F),C(),ve(nr(F))&&Ge().then(y=>{g(y?"Токен восстановлен, локальная база на месте.":"Токен восстановлен. Нажми «Скачать», чтобы получить базу."),C()})}const wa={home:"Главная",sync:"Синхронизация"};function Le(r){const n=document.getElementById(r);if(!n)throw new Error(`[JapLearn] Не найден элемент #${r}`);return n}const dr=Le("burger-btn"),We=Le("drawer"),pr=Le("drawer-overlay"),ma=Le("page-title"),ar=Le("page");let Et=!1;function Re(r){Et=r,We.classList.toggle("open",r),pr.hidden=!r,dr.setAttribute("aria-expanded",String(r)),We.setAttribute("aria-hidden",String(!r)),document.body.classList.toggle("drawer-open",r)}function va(r){We.querySelectorAll(".drawer-btn").forEach(n=>{n.classList.toggle("active",n.dataset.page===r)})}function Mt(r){ma.textContent=wa[r],va(r),r==="home"?ua(ar,{goSync:()=>Mt("sync")}):ga(ar)}dr.addEventListener("click",()=>{Re(!Et)});pr.addEventListener("click",()=>{Re(!1)});document.addEventListener("keydown",r=>{r.key==="Escape"&&Et&&Re(!1)});We.querySelectorAll(".drawer-btn").forEach(r=>{r.addEventListener("click",()=>{const n=r.dataset.page;(n==="home"||n==="sync")&&Mt(n),Re(!1)})});await kn();Re(!1);Mt("home");console.log("[JapLearn] crossOriginIsolated =",window.crossOriginIsolated);
@@ -1 +1 @@
:root{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;line-height:1.5;color:#1e1b2e;background:#f4f1fa}*{box-sizing:border-box}body{margin:0;min-height:100dvh;display:flex;justify-content:center;padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)}#app{width:100%;max-width:480px;padding:24px 16px 32px;display:flex;flex-direction:column;gap:16px}.app-header h1{margin:0;font-size:1.75rem;letter-spacing:.02em}.app-subtitle{margin:4px 0 0;color:#6d6390}.top-bar{display:flex;align-items:center;gap:12px}.burger-btn{width:44px;height:44px;border:2px solid #d9d2f0;border-radius:12px;background:#fff;cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;touch-action:manipulation;flex:none}.burger-btn span{display:block;width:20px;height:2px;border-radius:2px;background:#4c446b}.burger-btn[aria-expanded=true]{border-color:#7c3aed}.top-bar-title h1{margin:0;font-size:1.4rem;line-height:1.2}.top-bar-title p{margin:0;font-size:.9rem;color:#6d6390}.top-bar-spacer{flex:1}.app-content{display:flex;flex-direction:column;gap:16px}.drawer-overlay{position:fixed;inset:0;background:#1e1b2e73;z-index:40}.drawer-overlay[hidden]{display:none}.drawer{position:fixed;top:0;left:0;bottom:0;width:min(300px,85vw);background:#fff;z-index:50;transform:translate(-105%);transition:transform .22s ease;display:flex;flex-direction:column;gap:8px;padding:20px 14px;box-shadow:8px 0 32px #1e1b2e2e}.drawer.open{transform:translate(0)}.drawer-header{padding:4px 8px 14px;border-bottom:1px solid #eee9fb;margin-bottom:8px}.drawer-logo{font-size:1.3rem;font-weight:800;color:#7c3aed}.drawer-subtitle{font-size:.9rem;color:#6d6390}.drawer-btn{border:none;background:transparent;text-align:left;font-size:1.05rem;font-weight:600;color:#1e1b2e;padding:13px 12px;border-radius:12px;cursor:pointer;touch-action:manipulation}.drawer-btn.active{background:#f1eafe;color:#6d28d9}.drawer-btn:active{background:#e9defc}body.drawer-open{overflow:hidden}.card{background:#fff;border-radius:16px;padding:20px 16px;box-shadow:0 8px 24px #7c3aed1f;display:flex;flex-direction:column;gap:12px}.card-title{margin:0;font-size:1.25rem}.card-hint{margin:0;color:#6d6390;font-size:.95rem}.field-label{font-weight:600;font-size:.95rem}.token-input{width:100%;padding:14px 12px;font-size:1.05rem;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.04em;border:2px solid #d9d2f0;border-radius:12px;outline:none}.token-input:focus{border-color:#7c3aed}.token-input.invalid{border-color:#dc2626;background:#fef2f2}.token-meta{display:flex;justify-content:space-between;align-items:baseline;gap:8px;min-height:1.4em}.token-error{color:#dc2626;font-size:.9rem}.token-counter{color:#6d6390;font-variant-numeric:tabular-nums;white-space:nowrap}.btn-row{display:flex;gap:12px}.btn{flex:1;padding:14px 12px;font-size:1rem;font-weight:600;border-radius:12px;border:2px solid #7c3aed;background:#fff;color:#7c3aed;cursor:pointer;touch-action:manipulation}.btn-primary{background:#7c3aed;color:#fff}.btn:disabled{opacity:.4;cursor:not-allowed}.btn:not(:disabled):active{transform:scale(.98)}.status-bar{margin:0;padding:10px 12px;border-radius:10px;background:#f4f1fa;color:#4c446b;font-size:.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:.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:.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:.8rem;font-weight:600;color:#5b21b6;background:#ede9fe;border-radius:999px;padding:3px 10px;word-break:break-word}.word-more{font-size:.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:.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:.7} :root{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;line-height:1.5;color:#1e1b2e;background:#f4f1fa}*{box-sizing:border-box}body{margin:0;min-height:100dvh;display:flex;justify-content:center;padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)}#app{width:100%;max-width:480px;padding:24px 16px 32px;display:flex;flex-direction:column;gap:16px}.app-header h1{margin:0;font-size:1.75rem;letter-spacing:.02em}.app-subtitle{margin:4px 0 0;color:#6d6390}.top-bar{display:flex;align-items:center;gap:12px}.burger-btn{width:44px;height:44px;border:2px solid #d9d2f0;border-radius:12px;background:#fff;cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;touch-action:manipulation;flex:none}.burger-btn span{display:block;width:20px;height:2px;border-radius:2px;background:#4c446b}.burger-btn[aria-expanded=true]{border-color:#7c3aed}.top-bar-title h1{margin:0;font-size:1.4rem;line-height:1.2}.top-bar-title p{margin:0;font-size:.9rem;color:#6d6390}.top-bar-spacer{flex:1}.app-content{display:flex;flex-direction:column;gap:16px}.drawer-overlay{position:fixed;inset:0;background:#1e1b2e73;z-index:40}.drawer-overlay[hidden]{display:none}.drawer{position:fixed;top:0;left:0;bottom:0;width:min(300px,85vw);background:#fff;z-index:50;transform:translate(-105%);transition:transform .22s ease;display:flex;flex-direction:column;gap:8px;padding:20px 14px;box-shadow:8px 0 32px #1e1b2e2e}.drawer.open{transform:translate(0)}.drawer-header{padding:4px 8px 14px;border-bottom:1px solid #eee9fb;margin-bottom:8px}.drawer-logo{font-size:1.3rem;font-weight:800;color:#7c3aed}.drawer-subtitle{font-size:.9rem;color:#6d6390}.drawer-btn{border:none;background:transparent;text-align:left;font-size:1.05rem;font-weight:600;color:#1e1b2e;padding:13px 12px;border-radius:12px;cursor:pointer;touch-action:manipulation}.drawer-btn.active{background:#f1eafe;color:#6d28d9}.drawer-btn:active{background:#e9defc}body.drawer-open{overflow:hidden}.card{background:#fff;border-radius:16px;padding:20px 16px;box-shadow:0 8px 24px #7c3aed1f;display:flex;flex-direction:column;gap:12px}.card-title{margin:0;font-size:1.25rem}.card-hint{margin:0;color:#6d6390;font-size:.95rem}.field-label{font-weight:600;font-size:.95rem}.token-input{width:100%;padding:14px 12px;font-size:1.05rem;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.04em;border:2px solid #d9d2f0;border-radius:12px;outline:none}.token-input:focus{border-color:#7c3aed}.token-input.invalid{border-color:#dc2626;background:#fef2f2}.token-meta{display:flex;justify-content:space-between;align-items:baseline;gap:8px;min-height:1.4em}.token-error{color:#dc2626;font-size:.9rem}.token-counter{color:#6d6390;font-variant-numeric:tabular-nums;white-space:nowrap}.btn-row{display:flex;gap:12px}.btn{flex:1;padding:14px 12px;font-size:1rem;font-weight:600;border-radius:12px;border:2px solid #7c3aed;background:#fff;color:#7c3aed;cursor:pointer;touch-action:manipulation}.btn-primary{background:#7c3aed;color:#fff}.btn:disabled{opacity:.4;cursor:not-allowed}.btn:not(:disabled):active{transform:scale(.98)}.status-bar{margin:0;padding:10px 12px;border-radius:10px;background:#f4f1fa;color:#4c446b;font-size:.95rem;word-break:break-word}.status-bar[hidden]{display:none}.db-banner{display:flex;align-items:center;gap:8px;padding:8px 12px;border-radius:10px;background:#fff7e6;color:#7a5b00;font-size:.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{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:.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}.word-row-wrap{border-bottom:1px solid #eee9fb}.word-row-wrap:last-child{border-bottom:none}.word-row{display:flex;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-weight:700}.word-sep{color:#6d6390}.word-value{color:#4c446b}.word-tag{flex:none;max-width:38%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:.8rem;color:#6d6390;text-align:right}.word-dot{flex:none;width:6px;height:6px;border-radius:50%;background:#7c3aedLine truncated
+52
View File
@@ -0,0 +1,52 @@
(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))s(i);new MutationObserver(i=>{for(const u of i)if(u.type==="childList")for(const h of u.addedNodes)h.tagName==="LINK"&&h.rel==="modulepreload"&&s(h)}).observe(document,{childList:!0,subtree:!0});function o(i){const u={};return i.integrity&&(u.integrity=i.integrity),i.referrerPolicy&&(u.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?u.credentials="include":i.crossOrigin==="anonymous"?u.credentials="omit":u.credentials="same-origin",u}function s(i){if(i.ep)return;i.ep=!0;const u=o(i);fetch(i.href,u)}})();const _e=new Worker(new URL("/assets/db-worker-AHwpujSZ.js",import.meta.url),{type:"module"});let ar=0,Ye=null;const De=new Map;function kn(){return Ye||(Ye=new Promise((r,n)=>{_e.onmessage=s=>{const i=s.data;if(i.type==="ready")return;const u=De.get(i.id);u&&(De.delete(i.id),i.type=="success"?u.resolve(i.data):u.reject(new Error(i.error??i.message??"Неизвестная ошибка Worker")))},_e.onerror=s=>{console.error("[SQLite Worker] Ошибка:",s),n(new Error("Ошибка Worker"))};const o=++ar;De.set(o,{resolve:r,reject:n}),_e.postMessage({type:"start",id:o})}),Ye)}function $n(r,n){return new Promise((o,s)=>{if(!_e){s(new Error("Worker не создан"));return}const i=++ar;De.set(i,{resolve:o,reject:s}),_e.postMessage({type:r,id:i,...n})})}async function Ae(r,n){return await $n("query",{sql:r,params:n??[]})}function Qn(r){if(r==null)return[];const n=String(r).trim();return n?n.split(/[,\s;]+/).map(o=>o.trim()).filter(o=>o.length>0):[]}function Tn(r){const n=new Map;if(r==null)return n;const o=String(r);if(!o)return n;const s=o.split("");for(let i=0;i+1<s.length;i+=2){const u=Number(s[i]),h=s[i+1]??"";!Number.isFinite(u)||!Number.isInteger(u)||h&&n.set(u,h)}return n}function Fn(r){const n=Number(r.id??0),o=String(r.key??""),s=String(r.value??""),i=r.tags==null?"":String(r.tags),u=Number(r.group_id??1);return{id:n,key:o,value:s,tags:i,tagsList:Qn(i),more:Tn(r.more),group_id:Number.isFinite(u)?u:1}}function or(r){const n=[],o=[],s=(r.search??"").trim();if(s){n.push("(key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\')");const i=`%${s.replace(/\\/g,"\\\\").replace(/%/g,"\\%").replace(/_/g,"\\_")}%`;o.push(i,i,i)}return r.groupId!=null&&(n.push("group_id = ?"),o.push(r.groupId)),{clause:n.length>0?`WHERE ${n.join(" AND ")}`:"",params:o}}async function Nn(r={}){const{clause:n,params:o}=or(r),i=(await Ae(`SELECT COUNT(*) as cnt FROM words ${n}`,o))[0]?.cnt;return typeof i=="number"?i:Number(i??0)}async function jt(r={}){const{clause:n,params:o}=or(r),s=Math.max(1,Math.min(r.limit??50,500)),i=Math.max(0,r.offset??0);return(await Ae(`SELECT id, key, value, tags, more, group_id FROM words ${n} ORDER BY id LIMIT ? OFFSET ?`,[...o,s,i])).map(Fn)}async function Hn(){return(await Ae("SELECT id, name FROM word_groups ORDER BY id")).map(n=>({id:Number(n.id),name:String(n.name??"")}))}async function Pn(){return(await Ae("SELECT id, name, identifier FROM custom_fields ORDER BY id")).map(n=>({id:Number(n.id),name:String(n.name??""),identifier:String(n.identifier??"")}))}const Ut="japanese_app.db";async function Ct(){return navigator.storage.getDirectory()}async function _n(r){r[18]=1,r[19]=1;const o=await(await Ct()).getFileHandle(Ut,{create:!0}),s=o.createSyncAccessHandle;if(typeof s=="function")try{const u=await s.call(o);try{u.truncate(0),u.write(r,{at:0}),u.flush()}finally{u.close()}return}catch{}const i=await o.createWritable();try{await i.truncate(0);const u=new Uint8Array(r.length);u.set(r),await i.write(u)}finally{await i.close()}}async function qn(){const r=await Ct();let n;try{n=await r.getFileHandle(Ut)}catch{throw new Error("Локальная база не найдена — сначала скачай сохранение")}const o=await n.getFile();if(o.size===0)throw new Error("Локальная база пуста");return new Uint8Array(await o.arrayBuffer())}async function Ge(){try{return(await(await(await Ct()).getFileHandle(Ut)).getFile()).size>0}catch{return!1}}var Ln=ArrayBuffer,z=Uint8Array,je=Uint16Array,Rn=Int16Array,et=Int32Array,ft=function(r,n,o){if(z.prototype.slice)return z.prototype.slice.call(r,n,o);(n==null||n<0)&&(n=0),(o==null||o>r.length)&&(o=r.length);var s=new z(o-n);return s.set(r.subarray(n,o)),s},qe=function(r,n,o,s){if(z.prototype.fill)return z.prototype.fill.call(r,n,o,s);for((o==null||o<0)&&(o=0),(s==null||s>r.length)&&(s=r.length);o<s;++o)r[o]=n;return r},In=function(r,n,o,s){if(z.prototype.copyWithin)return z.prototype.copyWithin.call(r,n,o,s);for((o==null||o<0)&&(o=0),(s==null||s>r.length)&&(s=r.length);o<s;)r[n++]=r[o++]},Jn=["invalid zstd data","window size too large (>2046MB)","invalid block type","FSE accuracy too high","match distance too far back","unexpected EOF"],B=function(r,n,o){var s=new Error(n||Jn[r]);if(s.code=r,Error.caLine truncated
<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">
<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 o=r.querySelector("#home-db-banner"),s=r.querySelector("#home-db-text"),i=r.querySelector("#home-db-go"),u=r.querySelector("#words-search"),h=r.querySelector("#words-group"),w=r.querySelector("#words-count"),U=r.querySelector("#words-status"),M=r.querySelector("#words-list"),b=r.querySelector("#words-more");function F(g,C){o.hidden=!1,s.textContent=g,i.hidden=!1}function N(){o.hidden=!0}i.addEventListener("click",()=>{document.querySelector('.drawer-btn[data-page="sync"]')?.click()});let y=!1,Q=new Map,m=0,x=[],I=null,X=0,R=!1,Y=0,K=0;function E(){const g=h.value;if(g==="")return{search:u.value.trim(),groupId:null};const C=Number(g);return{search:u.value.trim(),groupId:Number.isFinite(C)?C:null}}function O(){b.hidden=!(y&&x.length<m),b.hidden||(b.textContent=`Показать ещё (${m-x.length} осталось)`)}function D(){M.innerHTML=x.map(g=>er(g,Q,g.id===I)).join(""),w.textContent=m>0?`Показано ${x.length} из ${m}`:"",O()}function ee(g){if(I===g){g!=null&&G(g),I=null;return}I!=null&&G(I),I=g,g!=null&&re(g)}function G(g){const C=M.querySelector(`.word-row-wrap[data-word-id="${g}"]`);if(!C)return;const k=C.querySelector(".word-row"),P=C.querySelector(".word-extra");k&&(k.classList.remove("open"),k.setAttribute("aria-expanded","false")),P&&(P.hidden=!0,P.innerHTML="")}function re(g){const C=x.find(fe=>fe.id===g),k=M.querySelector(`.word-row-wrap[data-word-id="${g}"]`);if(!C||!k)return;const P=k.querySelector(".word-row"),_=k.querySelector(".word-extra");P&&(P.classList.add("open"),P.setAttribute("aria-expanded","true")),_&&(_.hidden=!1,_.innerHTML=lr(C,Q))}function V(g){U.textContent=g,U.hidden=g===""}async function j(){if(!y||R)return;R=!0;const g=++Y,C=E();b.disabled=!0,x.length===0&&V("Загрузка слов…");try{const[k,P]=await Promise.all([Nn({search:C.search,groupId:C.groupId??void 0}),jt({search:C.search,groupId:C.groupId??void 0,limit:At,offset:0})]);if(g!==Y)return;m=k,x=P,X=P.length,I!=null&&!x.some(_=>_.id===I)&&(I=null),D(),V(P.length===0?"По этому запросу слов нет.":""),P.length===0&&m===0&&(w.textContent="Слов в базе пока нет.")}catch(k){if(g!==Y)return;V(`Не удалось загрузить слова: ${k instanceof Error?k.message:String(k)}`)}finally{g===Y&&(R=!1,b.disabled=!1,O())}}async function H(){if(!y||R||x.length>=m)return;R=!0;const g=++Y,C=E();b.disabled=!0,b.textContent="Загрузка…";try{const k=await jt({search:C.search,groupId:C.groupId??void 0,limit:At,offset:X});if(g!==Y)return;x=x.concat(k),X+=k.length,M.insertAdjacentHTML("beforeend",k.map(P=>er(P,Q,P.id===I)).join("")),w.textContent=`Показано ${x.length} из ${m}`,O()}catch(k){if(g!==Y)return;V(`Не удалось догрузить: ${k instanceof Error?k.message:String(k)}`)}finally{g===Y&&(R=!1,b.disabled=!1,O())}}u.addEventListener("input",()=>{window.clearTimeout(K),K=window.setTimeout(()=>{j()},300)}),u.addEventListener("keydown",g=>{g.key==="Enter"&&(window.clearTimeout(K),j())}),h.addEventListener("change",()=>{j()}),b.addEventListener("click",()=>{H()}),M.addEventListener("click",g=>{const C=g.target.closest(".word-row");if(!C||C.disabled)return;const k=Number(C.dataset.wordId);Number.isFinite(k)&&ee(I===k?null:k)}),(async()=>{if(!await Ge()){F("Локальной базы нет — скачай сохранение."),V("Нет локальной базы — скачай сохранение на странице «Синхронизация»."),w.textContent="";return}try{const C=await qn();if(!bt(C)){F("Файл в OPFS не похож на SQLite — перекачай сохранение.",!0),V("Файл в OPFS не похож на SQLite — перекачай сохранение.");return}N();try{const[P,_]=await Promise.all([Hn(),Pn()]),fe=P;Q=new Map(_.map(ue=>[ue.id,ue])),h.innerHTML='<option value="">Все группы</option>'+fe.map(ue=>`<option value="${ue.id}">${Ue(ue.name)}</option>`).join(""),y=!0,await j(),h.options[0].textContent=`Все группы (${m})`}catch(P){V(`БД есть, но слова не прочитались: ${P instanceof Error?P.message:String(P)}`)}}catch(C){F(`Не удалось прочитать базу: ${C instanceof Error?C.message:String(C)}`)}})()}const tr=24,pa=/^[A-Za-z0-9]{24}$/,ur="japlearn_sync_token";function rr(r){return r.trim().replace(/\s+/g,"")}function be(r){return pa.test(r)}function fa(){try{return localStorage.getItem(ur)??""}catch{return""}}function wt(r){try{localStorage.setItem(ur,r)}catch{}}const cr="https://learning.micialware.ru";function ha(r,n=cr){return`${n.replace(/\/+$/,"")}/download/${encodeURIComponent(r)}`}async function ga(r,n,o=cr){const s=ha(r,o);let i;try{i=await fetch(s,{method:"GET",...n})}catch(h){throw new Error(`Нет связи с сервером: ${h instanceof Error?h.message:String(h)}`)}if(!i.ok)throw i.status===404?new Error("Сохранение не найдено (404). Проверь токен."):new Error(`С Line truncated
<main class="card" aria-labelledby="sync-title">
<h2 id="sync-title" class="card-title">📡 Синхронизация</h2>
<p class="card-hint">Токен сохранения — 24 символа: латинские буквы и цифры.</p>
<label class="field-label" for="token-input">Токен сохранения</label>
<input
id="token-input"
class="token-input"
type="text"
inputmode="latin"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
maxlength="24"
minlength="24"
placeholder="Например: aB3dEf7hJk9mN2pQr5tUvWx1z"
aria-describedby="token-error token-counter"
/>
<div class="token-meta">
<span id="token-error" class="token-error" role="alert"></span>
<span id="token-counter" class="token-counter" aria-live="polite">0 / 24</span>
</div>
<div class="btn-row">
<button id="btn-download" class="btn" type="button" disabled>📥 Скачать</button>
<button id="btn-upload" class="btn btn-primary" type="button" disabled>📤 Выгрузить</button>
</div>
<p id="status" class="status-bar" role="status">Введите токен из 24 символов</p>
</main>
`;const n=r.querySelector("#token-input"),o=r.querySelector("#token-error"),s=r.querySelector("#token-counter"),i=r.querySelector("#btn-download"),u=r.querySelector("#btn-upload"),h=r.querySelector("#status");function w(y){h.textContent=y,console.log(`[JapLearn] ${y}`)}function U(){return rr(n.value)}function M(y){i.disabled=y||!be(U()),u.disabled=!0,n.disabled=y,y||Ge().then(Q=>{!n.disabled&&be(U())&&(u.disabled=!Q)})}function b(){const y=U(),Q=y.length;s.textContent=`${Q} / ${tr}`;const m=be(y),x=Q===0;n.classList.toggle("invalid",!x&&!m),n.setAttribute("aria-invalid",String(!x&&!m)),x?o.textContent="":Q!==tr?o.textContent=`Нужно 24 символа, сейчас ${Q}`:o.textContent=m?"":"Только латинские буквы и цифры",i.disabled=!m,m?Ge().then(I=>{n.disabled||(u.disabled=!I)}):u.disabled=!0}async function F(){const y=U();if(be(y)){wt(y),M(!0),w("Скачивание сохранения...");try{const Q=await ga(y);w(`Скачано ${(Q.length/1024).toFixed(1)} КБ, распаковка...`);const m=await ca(Q);w(`Распаковано ${(m.length/1024).toFixed(1)} КБ, сохранение в OPFS...`),await _n(m),w(`Готово: база ${(m.length/1024).toFixed(1)} КБ сохранена локально (OPFS).`)}catch(Q){w(`Ошибка скачивания: ${Q instanceof Error?Q.message:String(Q)}`)}finally{M(!1),b()}}}n.addEventListener("input",()=>{b()}),n.addEventListener("change",()=>{const y=U();n.value=y,be(y)&&(wt(y),w("Токен сохранён локально. Можно скачивать или выгружать.")),b()}),i.addEventListener("click",()=>{F()}),u.addEventListener("click",()=>{const y=U();be(y)&&(wt(y),w("Выгрузка — следующий шаг (POST /upload/stream/{id})."))});const N=fa();N&&(n.value=N),b(),be(rr(N))&&Ge().then(y=>{w(y?"Токен восстановлен, локальная база на месте.":"Токен восстановлен. Нажми «Скачать», чтобы получить базу."),b()})}const ma={home:"Главная",sync:"Синхронизация"};function Re(r){const n=document.getElementById(r);if(!n)throw new Error(`[JapLearn] Не найден элемент #${r}`);return n}const dr=Re("burger-btn"),We=Re("drawer"),pr=Re("drawer-overlay"),va=Re("page-title"),nr=Re("page");let Et=!1;function Ie(r){Et=r,We.classList.toggle("open",r),pr.hidden=!r,dr.setAttribute("aria-expanded",String(r)),We.setAttribute("aria-hidden",String(!r)),document.body.classList.toggle("drawer-open",r)}function ya(r){We.querySelectorAll(".drawer-btn").forEach(n=>{n.classList.toggle("active",n.dataset.page===r)})}function fr(r){va.textContent=ma[r],ya(r),r==="home"?da(nr):wa(nr)}dr.addEventListener("click",()=>{Ie(!Et)});pr.addEventListener("click",()=>{Ie(!1)});document.addEventListener("keydown",r=>{r.key==="Escape"&&Et&&Ie(!1)});We.querySelectorAll(".drawer-btn").forEach(r=>{r.addEventListener("click",()=>{const n=r.dataset.page;(n==="home"||n==="sync")&&fr(n),Ie(!1)})});await kn();Ie(!1);fr("home");console.log("[JapLearn] crossOriginIsolated =",window.crossOriginIsolated);
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#7c3aed" /> <meta name="theme-color" content="#7c3aed" />
<title>JapLearn — интервальные повторения</title> <title>JapLearn — интервальные повторения</title>
<script type="module" crossorigin src="/assets/index-BdLQfp0Z.js"></script> <script type="module" crossorigin src="/assets/index-CiCJJhCu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CbQBYl48.css"> <link rel="stylesheet" crossorigin href="/assets/index-CXPiR0gG.css">
</head> </head>
<body> <body>
<div id="app"> <div id="app">
+114 -48
View File
@@ -7,7 +7,7 @@ import { hasDatabaseFile, readDatabaseFile } from './opfs.ts';
import { looksLikeSQLite } from './zstd.ts'; import { looksLikeSQLite } from './zstd.ts';
export type HomeNav = { export type HomeNav = {
goSync: () => void; goSync?: () => void;
}; };
const PAGE_SIZE = 30; const PAGE_SIZE = 30;
@@ -21,38 +21,43 @@ function escapeHtml(s: string): string {
.replace(/'/g, '&#39;'); .replace(/'/g, '&#39;');
} }
function wordCardHtml(w: Word, gname: string | undefined, fmap: Map<number, CustomField>): string { function wordRowHtml(w: Word, fmap: Map<number, CustomField>, expanded: boolean): string {
const tags = w.tagsList.map((t) => `<span class="tag-chip">${escapeHtml(t)}</span>`).join(''); const firstTag = w.tagsList[0] ?? '';
const mrows = [...w.more.entries()] const extraCount = w.tagsList.length > 1 ? ` +${w.tagsList.length - 1}` : '';
.map(([fid, val]) => { const tagTitle = escapeHtml(w.tagsList.join(', '));
const f = fmap.get(fid); const tag = firstTag
const label = f ? `${escapeHtml(f.name)} <span class="word-more-id">#${fid}</span>` : `Поле #${fid}`; ? `<span class="word-tag" title="${tagTitle}">${escapeHtml(firstTag)}${extraCount}</span>`
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}"> // Маркер наличия доп. полей (имена берём из custom_fields при раскрытии)
<div class="word-card-top"><div class="word-key">${escapeHtml(w.key)}</div>` const dot = w.more.size > 0 ? '<span class="word-dot" aria-hidden="true"></span>' : '';
+ (gname ? `<span class="word-group">${escapeHtml(gname)}</span>` : '') return `<div class="word-row-wrap" data-word-id="${w.id}">`
+ `</div><div class="word-value">${escapeHtml(w.value)}</div>` + `<button class="word-row" type="button" data-word-id="${w.id}"`
+ (tags ? `<div class="tag-row">${tags}</div>` : '') + more + `</article>`; + ` 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 = ` root.innerHTML = `
<main class="card" aria-labelledby="home-title"> <div id="home-db-banner" class="db-banner" role="status" hidden>
<h2 id="home-title" class="card-title">🏠 Главная</h2> <span id="home-db-text" class="db-banner-text">Проверка локальной базы…</span>
<p class="card-hint">Локальное сохранение и повторения — всё работает офлайн.</p> <button id="home-db-go" class="db-banner-link" type="button">Синхронизация →</button>
<div id="home-db-status" class="status-bar" role="status">Проверка локальной базы…</div> </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>
<section class="card" aria-labelledby="words-title"> <section class="card" aria-labelledby="words-title">
<h2 id="words-title" class="card-title">📚 Слова</h2> <h2 id="words-title" class="card-title">📚 Слова</h2>
<div class="words-toolbar"> <div class="words-toolbar">
@@ -69,10 +74,9 @@ export function renderHomePage(root: HTMLElement, nav: HomeNav): void {
</section> </section>
`; `;
const statusEl = root.querySelector<HTMLElement>('#home-db-status')!; const banner = root.querySelector<HTMLElement>('#home-db-banner')!;
const goSync = root.querySelector<HTMLButtonElement>('#btn-go-sync')!; const bannerText = root.querySelector<HTMLElement>('#home-db-text')!;
const actions = root.querySelector<HTMLElement>('#home-actions')!; const bannerGo = root.querySelector<HTMLButtonElement>('#home-db-go')!;
const btnStart = root.querySelector<HTMLButtonElement>('#btn-start')!;
const searchInput = root.querySelector<HTMLInputElement>('#words-search')!; const searchInput = root.querySelector<HTMLInputElement>('#words-search')!;
const groupSelect = root.querySelector<HTMLSelectElement>('#words-group')!; const groupSelect = root.querySelector<HTMLSelectElement>('#words-group')!;
const countEl = root.querySelector<HTMLElement>('#words-count')!; 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 listEl = root.querySelector<HTMLElement>('#words-list')!;
const moreBtn = root.querySelector<HTMLButtonElement>('#words-more')!; const moreBtn = root.querySelector<HTMLButtonElement>('#words-more')!;
goSync.addEventListener('click', () => nav.goSync()); function showDbBanner(msg: string, showLink: boolean): void {
btnStart.addEventListener('click', () => { banner.hidden = false;
statusEl.textContent = 'Экран повторений — следующий шаг.'; 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 dbReady = false;
let gnames = new Map<number, string>();
let fmap = new Map<number, CustomField>(); let fmap = new Map<number, CustomField>();
let total = 0; let total = 0;
let loaded: Word[] = []; let loaded: Word[] = [];
// id последнего раскрытого слова (аккордеон: открыт максимум один)
let expandedId: number | null = null;
let offset = 0; let offset = 0;
let loading = false; let loading = false;
let reqSeq = 0; let reqSeq = 0;
@@ -109,11 +123,55 @@ export function renderHomePage(root: HTMLElement, nav: HomeNav): void {
} }
function fullRender(): 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}` : ''; countEl.textContent = total > 0 ? `Показано ${loaded.length} из ${total}` : '';
refreshMoreBtn(); 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 { function setWStatus(msg: string): void {
wordsStatus.textContent = msg; wordsStatus.textContent = msg;
wordsStatus.hidden = msg === ''; wordsStatus.hidden = msg === '';
@@ -135,6 +193,8 @@ export function renderHomePage(root: HTMLElement, nav: HomeNav): void {
total = cnt; total = cnt;
loaded = words; loaded = words;
offset = words.length; offset = words.length;
// Раскрытие привязано к id: если слова больше нет в выдаче — сбрасываем
if (expandedId != null && !loaded.some((w) => w.id === expandedId)) expandedId = null;
fullRender(); fullRender();
setWStatus(words.length === 0 ? 'По этому запросу слов нет.' : ''); setWStatus(words.length === 0 ? 'По этому запросу слов нет.' : '');
if (words.length === 0 && total === 0) countEl.textContent = 'Слов в базе пока нет.'; if (words.length === 0 && total === 0) countEl.textContent = 'Слов в базе пока нет.';
@@ -162,7 +222,7 @@ export function renderHomePage(root: HTMLElement, nav: HomeNav): void {
if (my !== reqSeq) return; if (my !== reqSeq) return;
loaded = loaded.concat(words); loaded = loaded.concat(words);
offset += words.length; 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}`; countEl.textContent = `Показано ${loaded.length} из ${total}`;
refreshMoreBtn(); refreshMoreBtn();
} catch (err) { } catch (err) {
@@ -190,11 +250,20 @@ export function renderHomePage(root: HTMLElement, nav: HomeNav): void {
groupSelect.addEventListener('change', () => void reloadWords()); groupSelect.addEventListener('change', () => void reloadWords());
moreBtn.addEventListener('click', () => void loadMore()); 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 () => { void (async () => {
const has = await hasDatabaseFile(); const has = await hasDatabaseFile();
if (!has) { if (!has) {
statusEl.textContent = 'Локальной базы нет. Скачай сохранение на странице «Синхронизация».'; showDbBanner('Локальной базы нет — скачай сохранение.', true);
actions.hidden = true;
setWStatus('Нет локальной базы — скачай сохранение на странице «Синхронизация».'); setWStatus('Нет локальной базы — скачай сохранение на странице «Синхронизация».');
countEl.textContent = ''; countEl.textContent = '';
return; return;
@@ -202,18 +271,16 @@ export function renderHomePage(root: HTMLElement, nav: HomeNav): void {
try { try {
const raw = await readDatabaseFile(); const raw = await readDatabaseFile();
const ok = looksLikeSQLite(raw); const ok = looksLikeSQLite(raw);
statusEl.textContent = ok
? `Локальная база на месте: ${(raw.length / 1024).toFixed(1)} КБ, сигнатура SQLite OK.`
: 'Файл в OPFS есть, но это не похоже на SQLite — перекачай сохранение.';
actions.hidden = !ok;
if (!ok) { if (!ok) {
showDbBanner('Файл в OPFS не похож на SQLite — перекачай сохранение.', true);
setWStatus('Файл в OPFS не похож на SQLite — перекачай сохранение.'); setWStatus('Файл в OPFS не похож на SQLite — перекачай сохранение.');
return; return;
} }
// База в порядке — баннер прячем, слова занимают весь экран
hideDbBanner();
try { try {
const [gs, fields] = await Promise.all([getWordGroups(), getCustomFields()]); const [gs, fields] = await Promise.all([getWordGroups(), getCustomFields()]);
const groups: WordGroup[] = gs; const groups: WordGroup[] = gs;
gnames = new Map(groups.map((g) => [g.id, g.name]));
fmap = new Map(fields.map((f) => [f.id, f])); fmap = new Map(fields.map((f) => [f.id, f]));
groupSelect.innerHTML = '<option value="">Все группы</option>' groupSelect.innerHTML = '<option value="">Все группы</option>'
+ groups.map((g) => `<option value="${g.id}">${escapeHtml(g.name)}</option>`).join(''); + 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)}`); setWStatus(`БД есть, но слова не прочитались: ${err instanceof Error ? err.message : String(err)}`);
} }
} catch (err) { } catch (err) {
statusEl.textContent = `Не удалось прочитать базу: ${err instanceof Error ? err.message : String(err)}`; showDbBanner(`Не удалось прочитать базу: ${err instanceof Error ? err.message : String(err)}`, true);
actions.hidden = true;
} }
})(); })();
} }
+1 -1
View File
@@ -45,7 +45,7 @@ function renderPage(page: Page): void {
pageTitle.textContent = PAGE_TITLES[page]; pageTitle.textContent = PAGE_TITLES[page];
setActiveButton(page); setActiveButton(page);
if (page === 'home') { if (page === 'home') {
renderHomePage(pageRoot, { goSync: () => renderPage('sync') }); renderHomePage(pageRoot, {});
} else { } else {
renderSyncPage(pageRoot); renderSyncPage(pageRoot);
} }
+106 -64
View File
@@ -286,6 +286,44 @@ body.drawer-open {
display: none; 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 { .words-toolbar {
@@ -329,104 +367,108 @@ body.drawer-open {
.words-list { .words-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px;
} }
.word-card { /* Плоская строка: ключ + перевод слева, тег справа. Без карточек. */
border: 1px solid #eee9fb; .word-row-wrap {
border-radius: 12px; border-bottom: 1px solid #eee9fb;
padding: 12px;
background: #fbfaff;
display: flex;
flex-direction: column;
gap: 8px;
} }
.word-card-top { .word-row-wrap:last-child {
border-bottom: none;
}
.word-row {
display: flex; display: flex;
align-items: flex-start; align-items: baseline;
justify-content: space-between;
gap: 8px; 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 { .word-key {
font-size: 1.2rem;
font-weight: 700; font-weight: 700;
line-height: 1.3; }
word-break: break-word;
.word-sep {
color: #6d6390;
} }
.word-value { .word-value {
font-size: 1rem;
color: #4c446b; color: #4c446b;
word-break: break-word;
} }
.word-group { /* Тег справа — плоской подписью, без плашки */
.word-tag {
flex: none; flex: none;
font-size: 0.75rem; max-width: 38%;
font-weight: 700; overflow: hidden;
color: #6d28d9; text-overflow: ellipsis;
background: #f1eafe;
border-radius: 999px;
padding: 4px 10px;
white-space: nowrap; white-space: nowrap;
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag-chip {
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 600; color: #6d6390;
color: #5b21b6; text-align: right;
background: #ede9fe;
border-radius: 999px;
padding: 3px 10px;
word-break: break-word;
} }
.word-more { /* Точка — маркер наличия доп. полей (custom_fields) */
font-size: 0.9rem; .word-dot {
color: #4c446b; flex: none;
width: 6px;
height: 6px;
border-radius: 50%;
background: #7c3aed;
opacity: 0.55;
align-self: center;
} }
.word-more summary { /* Раскрытые доп. поля — только у последнего нажатого */
cursor: pointer; .word-extra[hidden] {
font-weight: 600; display: none;
color: #6d28d9;
touch-action: manipulation;
} }
.word-more-list { .word-extra-list {
margin: 8px 0 0; margin: 0 0 10px;
padding: 10px 12px;
background: #f4f1fa;
border-radius: 10px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 8px;
} }
.word-more-row { .word-extra-row dt {
background: #fff;
border: 1px solid #eee9fb;
border-radius: 8px;
padding: 8px 10px;
}
.word-more-row dt {
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 700; font-weight: 700;
color: #6d6390; color: #6d6390;
} }
.word-more-row dd { .word-extra-row dd {
margin: 2px 0 0; margin: 2px 0 0;
word-break: break-word; word-break: break-word;
} }
.word-more-id {
font-weight: 400;
opacity: 0.7;
}