history display and repetition bug fix
This commit is contained in:
1 parent
36d2c0b48f
commit
b5b4254d20
11 files changed
+480
-141
No files matched your search
@@ -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}.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
|
: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
|
||||||
Vendored
-111
@@ -1,111 +0,0 @@
|
|||||||
(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))o(s);new MutationObserver(s=>{for(const c of s)if(c.type==="childList")for(const u of c.addedNodes)u.tagName==="LINK"&&u.rel==="modulepreload"&&o(u)}).observe(document,{childList:!0,subtree:!0});function a(s){const c={};return s.integrity&&(c.integrity=s.integrity),s.referrerPolicy&&(c.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?c.credentials="include":s.crossOrigin==="anonymous"?c.credentials="omit":c.credentials="same-origin",c}function o(s){if(s.ep)return;s.ep=!0;const c=a(s);fetch(s.href,c)}})();const Se=new Worker(new URL("/assets/db-worker-DFFhVJkA.js",import.meta.url),{type:"module"});let dr=0,je=null;const We=new Map;function _n(){return je||(je=new Promise((e,n)=>{Se.onmessage=o=>{const s=o.data;if(s.type==="ready")return;const c=We.get(s.id);c&&(We.delete(s.id),s.type=="success"?c.resolve(s.data):c.reject(new Error(s.error??s.message??"Неизвестная ошибка Worker")))},Se.onerror=o=>{console.error("[SQLite Worker] Ошибка:",o),n(new Error("Ошибка Worker"))};const a=++dr;We.set(a,{resolve:e,reject:n}),Se.postMessage({type:"start",id:a})}),je)}function fr(e,n){return new Promise((a,o)=>{if(!Se){o(new Error("Worker не создан"));return}const s=++dr;We.set(s,{resolve:a,reject:o}),Se.postMessage({type:e,id:s,...n})})}async function pe(e,n){return await fr("query",{sql:e,params:n??[]})}async function Pn(e,n){await fr("command",{sql:e,params:n??[]})}function Sn(e){if(e==null)return[];const n=String(e).trim();return n?n.split(/[,\s;]+/).map(a=>a.trim()).filter(a=>a.length>0):[]}function Rn(e){const n=new Map;if(e==null)return n;const a=String(e);if(!a)return n;const o=a.split("");for(let s=0;s+1<o.length;s+=2){const c=Number(o[s]),u=o[s+1]??"";!Number.isFinite(c)||!Number.isInteger(c)||u&&n.set(c,u)}return n}function pr(e){const n=Number(e.id??0),a=String(e.key??""),o=String(e.value??""),s=e.tags==null?"":String(e.tags),c=Number(e.group_id??1);return{id:n,key:a,value:o,tags:s,tagsList:Sn(s),more:Rn(e.more),group_id:Number.isFinite(c)?c:1}}function hr(e){const n=[],a=[],o=(e.search??"").trim();if(o){n.push("(key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\')");const s=`%${o.replace(/\\/g,"\\\\").replace(/%/g,"\\%").replace(/_/g,"\\_")}%`;a.push(s,s,s)}return e.groupId!=null&&(n.push("group_id = ?"),a.push(e.groupId)),{clause:n.length>0?`WHERE ${n.join(" AND ")}`:"",params:a}}async function In(e={}){const{clause:n,params:a}=hr(e),s=(await pe(`SELECT COUNT(*) as cnt FROM words ${n}`,a))[0]?.cnt;return typeof s=="number"?s:Number(s??0)}async function er(e={}){const{clause:n,params:a}=hr(e),o=Math.max(1,Math.min(e.limit??50,500)),s=Math.max(0,e.offset??0);return(await pe(`SELECT id, key, value, tags, more, group_id FROM words ${n} ORDER BY id LIMIT ? OFFSET ?`,[...a,o,s])).map(pr)}async function Jn(){return(await pe("SELECT id, name FROM word_groups ORDER BY id")).map(n=>({id:Number(n.id),name:String(n.name??"")}))}async function On(){return(await pe("SELECT id, name, forward, backward, filter FROM decks ORDER BY id")).map(n=>({id:Number(n.id),name:String(n.name??""),forward:String(n.forward??""),backward:String(n.backward??""),filter:String(n.filter??"")}))}async function Kn(e,n){return await Pn("UPDATE decks SET forward = ?, backward = ?, filter = ? WHERE id = ?",[n.forward,n.backward,n.filter,e]),Et(e)}async function Et(e){const a=(await pe("SELECT id, name, forward, backward, filter FROM decks WHERE id = ? LIMIT 1",[e]))[0];return a?{id:Number(a.id),name:String(a.name??""),forward:String(a.forward??""),backward:String(a.backward??""),filter:String(a.filter??"")}:null}async function Xn(){const e=await pe("SELECT deck_id, COUNT(*) as cnt FROM states WHERE deck_id IS NOT NULL GROUP BY deck_id"),n=new Map;for(const a of e){if(a.deck_id==null)continue;const o=a.cnt;n.set(Number(a.deck_id),typeof o=="number"?o:Number(o??0))}return n}async function zn(e){const a=(await pe("SELECT COUNT(DISTINCT word_id) as cnt FROM states WHERE deck_id = ?",[e]))[0]?.cnt;return typeof a=="number"?a:Number(a??0)}async function tr(e){const n=new Map,a=[...new Set(e.filter(s=>Number.isFinite(s)))];if(a.length===0)return n;const o=400;for(let s=0;s<a.length;s+=o){const c=a.slice(s,s+o),u=c.map(()=>"?").join(","),g=await pe(`SELECT id, key, value, tags, more, group_id FROM words WHERE id IN (${u})`,c);for(const y of g.map(pr))n.set(y.id,y)}return n}function Dn(e,n){const a=new Map;a.set("key",e.key),a.set("value",e.value),a.set("tags",e.tagsList.join(", "));for(const o of n){const s=e.more.get(o.id)??"";if(s)for(const c of[o.identifier,o.name]){const u=c.trim().toLowerCase();!u||a.has(u)||a.set(u,s)}}return a}function rr(e,n){const a=[];for(const o of e.split(/\s+/)){const s=o.trim().toLowerCase();if(!s)continue;const c=(n.get(s)??"").trim();c&&a.push(c)}return a}async funLine 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 a=e.querySelector("#home-db-banner"),o=e.querySelector("#home-db-text"),s=e.querySelector("#home-db-go"),c=e.querySelector("#words-search"),u=e.querySelector("#words-group"),g=e.querySelector("#words-count"),y=e.querySelector("#words-status"),m=e.querySelector("#words-list"),C=e.querySelector("#words-more");function N(w,x){a.hidden=!1,o.textContent=w,s.hidden=!1}function E(){a.hidden=!0}s.addEventListener("click",()=>{document.querySelector('.drawer-btn[data-page="sync"]')?.click()});let $=!1,T=new Map,v=0,M=[],S=null,O=0,Q=!1,L=0,U=0;function b(){const w=u.value;if(w==="")return{search:c.value.trim(),groupId:null};const x=Number(w);return{search:c.value.trim(),groupId:Number.isFinite(x)?x:null}}function z(){C.hidden=!($&&M.length<v),C.hidden||(C.textContent=`Показать ещё (${v-M.length} осталось)`)}function B(){m.innerHTML=M.map(w=>sr(w,T,w.id===S)).join(""),g.textContent=v>0?`Показано ${M.length} из ${v}`:"",z()}function ee(w){if(S===w){w!=null&&D(w),S=null;return}S!=null&&D(S),S=w,w!=null&&re(w)}function D(w){const x=m.querySelector(`.word-row-wrap[data-word-id="${w}"]`);if(!x)return;const q=x.querySelector(".word-row"),P=x.querySelector(".word-extra");q&&(q.classList.remove("open"),q.setAttribute("aria-expanded","false")),P&&(P.hidden=!0,P.innerHTML="")}function re(w){const x=M.find(he=>he.id===w),q=m.querySelector(`.word-row-wrap[data-word-id="${w}"]`);if(!x||!q)return;const P=q.querySelector(".word-row"),R=q.querySelector(".word-extra");P&&(P.classList.add("open"),P.setAttribute("aria-expanded","true")),R&&(R.hidden=!1,R.innerHTML=mr(x,T))}function Z(w){y.textContent=w,y.hidden=w===""}async function j(){if(!$||Q)return;Q=!0;const w=++L,x=b();C.disabled=!0,M.length===0&&Z("Загрузка слов…");try{const[q,P]=await Promise.all([In({search:x.search,groupId:x.groupId??void 0}),er({search:x.search,groupId:x.groupId??void 0,limit:ir,offset:0})]);if(w!==L)return;v=q,M=P,O=P.length,S!=null&&!M.some(R=>R.id===S)&&(S=null),B(),Z(P.length===0?"По этому запросу слов нет.":""),P.length===0&&v===0&&(g.textContent="Слов в базе пока нет.")}catch(q){if(w!==L)return;Z(`Не удалось загрузить слова: ${q instanceof Error?q.message:String(q)}`)}finally{w===L&&(Q=!1,C.disabled=!1,z())}}async function _(){if(!$||Q||M.length>=v)return;Q=!0;const w=++L,x=b();C.disabled=!0,C.textContent="Загрузка…";try{const q=await er({search:x.search,groupId:x.groupId??void 0,limit:ir,offset:O});if(w!==L)return;M=M.concat(q),O+=q.length,m.insertAdjacentHTML("beforeend",q.map(P=>sr(P,T,P.id===S)).join("")),g.textContent=`Показано ${M.length} из ${v}`,z()}catch(q){if(w!==L)return;Z(`Не удалось догрузить: ${q instanceof Error?q.message:String(q)}`)}finally{w===L&&(Q=!1,C.disabled=!1,z())}}c.addEventListener("input",()=>{window.clearTimeout(U),U=window.setTimeout(()=>{j()},300)}),c.addEventListener("keydown",w=>{w.key==="Enter"&&(window.clearTimeout(U),j())}),u.addEventListener("change",()=>{j()}),C.addEventListener("click",()=>{_()}),m.addEventListener("click",w=>{const x=w.target.closest(".word-row");if(!x||x.disabled)return;const q=Number(x.dataset.wordId);Number.isFinite(q)&&ee(S===q?null:q)}),(async()=>{if(!await tt()){N("Локальной базы нет — скачай сохранение."),Z("Нет локальной базы — скачай сохранение на странице «Синхронизация»."),g.textContent="";return}try{const x=await Vn();if(!Ct(x)){N("Файл в OPFS не похож на SQLite — перекачай сохранение.",!0),Z("Файл в OPFS не похож на SQLite — перекачай сохранение.");return}E();try{const[P,R]=await Promise.all([Jn(),gr()]),he=P;T=new Map(R.map(le=>[le.id,le])),u.innerHTML='<option value="">Все группы</option>'+he.map(le=>`<option value="${le.id}">${Ce(le.name)}</option>`).join(""),$=!0,await j(),u.options[0].textContent=`Все группы (${v})`}catch(P){Z(`БД есть, но слова не прочитались: ${P instanceof Error?P.message:String(P)}`)}}catch(x){N(`Не удалось прочитать базу: ${x instanceof Error?x.message:String(x)}`)}})()}function or(e){return e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}function Ua(e){const n=e%10,a=e%100;return n===1&&a!==11?"запись":n>=2&&n<=4&&(a<12||a>14)?"записи":"записей"}function $a(e,n){const a=or(e.name)||`<span class="empty-val">Колода #${e.id}</span>`;return`<div class="word-row-wrap deck-row" data-deck-id="${e.id}"><button class="word-row deck-name" type="button" data-act="open" data-deck-id="${e.id}"><span class="word-main"><span class="word-key">${a}</span></span><span class="word-tag">${n} ${Ua(n)}</span></button><button class="deck-play" type="button" data-act="review" data-deck-id="${e.id}" aria-label="ПовтLine truncated
|
|
||||||
<section class="card" aria-labelledby="decks-title">
|
|
||||||
<h2 id="decks-title" class="card-title">🗂️ Колоды</h2>
|
|
||||||
<p id="decks-count" class="card-hint" role="status" aria-live="polite"></p>
|
|
||||||
<div id="decks-status" class="status-bar" role="status">Проверка локальной базы…</div>
|
|
||||||
<div id="decks-list" class="words-list" aria-live="polite"></div>
|
|
||||||
</section>
|
|
||||||
`;const a=e.querySelector("#decks-count"),o=e.querySelector("#decks-status"),s=e.querySelector("#decks-list");function c(u){o.textContent=u,o.hidden=u===""}s.addEventListener("click",u=>{const g=u.target.closest("[data-act]");if(!g)return;const y=Number(g.dataset.deckId);Number.isFinite(y)&&(g.dataset.act==="review"?n.goReview(y):n.goDeck(y))}),(async()=>{if(!await tt()){c("Нет локальной базы — скачай сохранение на странице «Синхронизация»."),a.textContent="";return}c("Загрузка колод…");try{const[g,y]=await Promise.all([On(),Xn()]);if(g.length===0){a.textContent="Колоды пока отсутствуют.",c(""),s.innerHTML="";return}a.textContent=`Всего: ${g.length}`,c(""),s.innerHTML=g.map(m=>$a(m,y.get(m.id)??0)).join("")}catch(g){c(`Не удалось загрузить колоды: ${g instanceof Error?g.message:String(g)}`)}})()}function Ma(e){return e?Ta(e):'<span class="empty-val">—</span>'}function Ta(e){return e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}function mt(e){return e.replace(/&/g,"&").replace(/"/g,""").replace(/</g,"<").replace(/>/g,">")}function Na(e){const n=e%10,a=e%100;return n===1&&a!==11?"слово":n>=2&&n<=4&&(a<12||a>14)?"слова":"слов"}function qa(e,n,a){e.innerHTML=`
|
|
||||||
<button id="deck-back" class="btn btn-ghost" type="button">← Назад к колодам</button>
|
|
||||||
<section class="card" aria-labelledby="deck-title">
|
|
||||||
<h2 id="deck-title" class="card-title">Колода…</h2>
|
|
||||||
<div id="deck-status" class="status-bar" role="status">Загрузка колоды…</div>
|
|
||||||
<div id="deck-body"></div>
|
|
||||||
</section>
|
|
||||||
`;const o=e.querySelector("#deck-back"),s=e.querySelector("#deck-title"),c=e.querySelector("#deck-status"),u=e.querySelector("#deck-body");o.addEventListener("click",()=>a.goDecks()),(async()=>{if(!await tt()){s.textContent="Колода недоступна",c.textContent="Нет локальной базы — скачай сохранение на странице «Синхронизация».";return}let y=null;try{y=await Et(n)}catch(U){s.textContent="Колода недоступна",c.textContent=`Не удалось загрузить колоду: ${U instanceof Error?U.message:String(U)}`;return}if(!y){s.textContent="Колода не найдена",c.textContent=`Колоды с id=${n} нет в локальной базе. Возможно, база обновилась — вернись к списку.`;return}const m=y;s.textContent=m.name||`Колода #${m.id}`,c.hidden=!0;let C=null;try{C=await zn(m.id)}catch(U){c.hidden=!1,c.textContent=`Данные колоды загружены, а количество слов — нет: ${U instanceof Error?U.message:String(U)}`}u.innerHTML=`
|
|
||||||
<dl class="word-extra-list">
|
|
||||||
<div class="word-extra-row"><dt>id</dt><dd>${m.id}</dd></div>
|
|
||||||
<div class="word-extra-row"><dt>name</dt><dd>${Ma(m.name)}</dd></div>
|
|
||||||
</dl>
|
|
||||||
<form id="deck-form" class="deck-form" autocomplete="off">
|
|
||||||
<label class="field-label" for="deck-forward">forward</label>
|
|
||||||
<input id="deck-forward" class="token-input" type="text" value="${mt(m.forward)}" />
|
|
||||||
<label class="field-label" for="deck-backward">backward</label>
|
|
||||||
<input id="deck-backward" class="token-input" type="text" value="${mt(m.backward)}" />
|
|
||||||
<label class="field-label" for="deck-filter">filter</label>
|
|
||||||
<input id="deck-filter" class="token-input" type="text" value="${mt(m.filter)}" />
|
|
||||||
<div class="btn-row">
|
|
||||||
<button id="deck-save" class="btn btn-primary" type="submit">💾 Сохранить</button>
|
|
||||||
</div>
|
|
||||||
<p id="deck-form-status" class="card-hint" role="status" aria-live="polite"></p>
|
|
||||||
</form>
|
|
||||||
<h3 class="deck-sub">📚 ${C==null?"Количество слов неизвестно":`${C} ${Na(C)}`}</h3>
|
|
||||||
<div class="btn-row">
|
|
||||||
<button id="deck-review" class="btn btn-primary" type="button"${C===0?" disabled":""}>▶ Повторять</button>
|
|
||||||
</div>
|
|
||||||
`;const N=u.querySelector("#deck-form"),E=u.querySelector("#deck-forward"),$=u.querySelector("#deck-backward"),T=u.querySelector("#deck-filter"),v=u.querySelector("#deck-save"),M=u.querySelector("#deck-form-status");u.querySelector("#deck-review").addEventListener("click",()=>a.goReview(m.id));const O=()=>`${E.value}
|
|
||||||
${$.value}
|
|
||||||
${T.value}`;let Q=O();function L(){v.disabled=O()===Q||v.dataset.busy==="1"}for(const U of[E,$,T])U.addEventListener("input",L);L(),N.addEventListener("submit",U=>{U.preventDefault(),!v.disabled&&(v.dataset.busy="1",v.disabled=!0,v.textContent="Сохранение…",M.textContent="",(async()=>{try{const b=await Kn(m.id,{forward:E.value,backward:$.value,filter:T.value});if(!b){M.textContent="Колода пропала из базы — вернись к списку.";return}E.value=b.forward,$.value=b.backward,T.value=b.filter,Q=O(),M.textContent="✅ Сохранено в локальную базу."}catch(b){M.textContent=`Не удалось сохранить: ${b instanceof Error?b.message:String(b)}`}finally{delete v.dataset.busy,v.textContent="💾 Сохранить",L()}})())})})()}function Ha(e){return e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}const Fa=[{label:"😞 Не получилось",cls:"grade-fail"},{label:"😟 Тяжело",cls:"grade-hard"},{label:"🙂 Хорошо",cls:"grade-good"},{label:"🤩 Легко",cls:"grade-easy"}];function La(e,n,a){e.innerHTML=`
|
|
||||||
<button id="review-back" class="btn btn-ghost" type="button">← Назад к колодам</button>
|
|
||||||
<section class="card" aria-labelledby="review-title">
|
|
||||||
<h2 id="review-title" class="card-title">Повторение…</h2>
|
|
||||||
<p id="review-progress" class="card-hint" role="status" aria-live="polite"></p>
|
|
||||||
<div id="review-status" class="status-bar" role="status">Загрузка карточек…</div>
|
|
||||||
<div id="review-body"></div>
|
|
||||||
</section>
|
|
||||||
`;const o=e.querySelector("#review-back"),s=e.querySelector("#review-title"),c=e.querySelector("#review-progress"),u=e.querySelector("#review-status"),g=e.querySelector("#review-body");o.addEventListener("click",()=>a.goDecks());function y(L){u.textContent=L,u.hidden=L===""}let m=[],C=new Map,N=[],E="",$="",T=0,v=!1;function M(L,U){return L.length===0?`<div class="review-side ${U}"><span class="empty-val">—</span></div>`:`<div class="review-side ${U}">`+L.map(b=>`<p class="review-line">${Ha(b)}</p>`).join("")+"</div>"}function S(){const L=C.get(m[T]);if(!L){g.innerHTML="",c.textContent="",y("Слово пропало из базы — пропускаю.");return}const U=Dn(L,N),b=rr(E,U),z=v?rr($,U):null;c.textContent=`Карточка ${T+1} из ${m.length}`,y("");const B=v?M(z??[],"review-back-side"):'<p class="review-tap">Тап — показать ответ</p>',ee=v?`<div class="review-grades" role="group" aria-label="Оценка повторения">
|
|
||||||
${Fa.map(D=>`<button class="btn review-grade ${D.cls}" type="button">${D.label}</button>`).join("")}
|
|
||||||
</div>`:"";g.innerHTML=`
|
|
||||||
<button id="review-card" class="review-card" type="button"
|
|
||||||
aria-expanded="${v}" aria-label="${v?"Карточка открыта":"Показать ответ"}">
|
|
||||||
${M(b,"review-front")}
|
|
||||||
${v?'<hr class="review-hr" />':""}
|
|
||||||
${B}
|
|
||||||
</button>
|
|
||||||
${ee}
|
|
||||||
`,g.querySelector("#review-card").addEventListener("click",()=>{v||(v=!0,S())}),g.querySelectorAll(".review-grade").forEach(D=>{D.addEventListener("click",()=>{T+=1,v=!1,T>=m.length?O():S()})})}function O(){c.textContent=`Готово: ${m.length} из ${m.length}`,y(""),g.innerHTML=`
|
|
||||||
<p class="card-hint">Все карточки сессии просмотрены. Запись оценок — следующим шагом.</p>
|
|
||||||
<div class="btn-row">
|
|
||||||
<button id="review-again" class="btn btn-primary" type="button">🔀 Перемешать заново</button>
|
|
||||||
</div>
|
|
||||||
`,g.querySelector("#review-again").addEventListener("click",()=>{Q()})}async function Q(){if(y("Перемешиваю…"),g.innerHTML="",c.textContent="",m=await nr(n),T=0,v=!1,m.length===0){y("В колоде нет слов для повторения.");return}C=await tr(m),S()}(async()=>{if(!await tt()){s.textContent="Повторение недоступно",y("Нет локальной базы — скачай сохранение на странице «Синхронизация».");return}try{const[U,b]=await Promise.all([Et(n),gr()]);if(!U){s.textContent="Колода не найдена",y(`Колоды с id=${n} нет в локальной базе. Вернись к списку.`);return}if(N=b,E=U.forward,$=U.backward,s.textContent=U.name||`Колода #${U.id}`,m=await nr(U.id),m.length===0){c.textContent="",y("В колоде нет слов для повторения.");return}C=await tr(m),T=0,v=!1,S()}catch(U){s.textContent="Повторение недоступно",y(`Не удалось загрузить карточки: ${U instanceof Error?U.message:String(U)}`)}})()}const cr=24,Qa=/^[A-Za-z0-9]{24}$/,yr="japlearn_sync_token";function lr(e){return e.trim().replace(/\s+/g,"")}function Qe(e){return Qa.test(e)}function _a(){try{return localStorage.getItem(yr)??""}catch{return""}}function ur(e){try{localStorage.setItem(yr,e)}catch{}}const br="https://learning.micialware.ru";function Pa(e,n=br){return`${n.replace(/\/+$/,"")}/download/${encodeURIComponent(e)}`}async function Sa(e,n,a=br){const o=Pa(e,a);let s;try{s=await fetch(o,{method:"GET",...n})}catch(u){throw new Error(`Нет связи с сервером: ${u instanceof Error?u.message:String(u)}`)}if(!s.ok)throw s.status===404?new Error("Сохранение не найдено (404). Проверь токен."):new Error(`Сервер ответил ${s.status} ${s.statusText}`);const c=await s.arrayBuffer();if(c.byteLength===0)throw new Error("Сервер вернул пустой файл");return new Uint8Array(c)}function Ra(e){e.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 btn-primary" type="button" disabled>📥 Скачать</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p id="status" class="status-bar" role="status">Введите токен из 24 символов</p>
|
|
||||||
</main>
|
|
||||||
`;const n=e.querySelector("#token-input"),a=e.querySelector("#token-error"),o=e.querySelector("#token-counter"),s=e.querySelector("#btn-download"),c=e.querySelector("#status");function u(E){c.textContent=E,console.log(`[JapLearn] ${E}`)}function g(){return lr(n.value)}function y(E){s.disabled=E||!Qe(g()),n.disabled=E}function m(){const E=g(),$=E.length;o.textContent=`${$} / ${cr}`;const T=Qe(E),v=$===0;n.classList.toggle("invalid",!v&&!T),n.setAttribute("aria-invalid",String(!v&&!T)),v?a.textContent="":$!==cr?a.textContent=`Нужно 24 символа, сейчас ${$}`:a.textContent=T?"":"Только латинские буквы и цифры",s.disabled=!T}async function C(){const E=g();if(Qe(E)){ur(E),y(!0),u("Скачивание сохранения...");try{const $=await Sa(E);u(`Скачано ${($.length/1024).toFixed(1)} КБ, распаковка...`);const T=await Ca($);u(`Распаковано ${(T.length/1024).toFixed(1)} КБ, сохранение в OPFS...`),await Bn(T),u(`Готово: база ${(T.length/1024).toFixed(1)} КБ сохранена локально (OPFS).`)}catch($){u(`Ошибка скачивания: ${$ instanceof Error?$.message:String($)}`)}finally{y(!1),m()}}}n.addEventListener("input",()=>{m()}),n.addEventListener("change",()=>{const E=g();n.value=E,Qe(E)&&(ur(E),u("Токен сохранён локально. Можно скачивать.")),m()}),s.addEventListener("click",()=>{C()});const N=_a();N&&(n.value=N),m(),Qe(lr(N))&&u("Токен восстановлен. Нажми «Скачать», чтобы получить базу.")}const Ia={home:"Главная",decks:"Колоды",deck:"Колода",review:"Повторение",sync:"Синхронизация"};function Je(e){const n=document.getElementById(e);if(!n)throw new Error(`[JapLearn] Не найден элемент #${e}`);return n}const kr=Je("burger-btn"),et=Je("drawer"),Cr=Je("drawer-overlay"),Ja=Je("page-title"),_e=Je("page");let xt=!1;function Oe(e){xt=e,et.classList.toggle("open",e),Cr.hidden=!e,kr.setAttribute("aria-expanded",String(e)),et.setAttribute("aria-hidden",String(!e)),document.body.classList.toggle("drawer-open",e)}function Oa(e){et.querySelectorAll(".drawer-btn").forEach(n=>{n.classList.toggle("active",n.dataset.page===e)})}function ke(e,n){Ja.textContent=Ia[e],Oa(e==="deck"||e==="review"?"decks":e),e==="home"?Ea(_e):e==="decks"?xa(_e,{goDeck:a=>ke("deck",a),goReview:a=>ke("review",a)}):e==="deck"?qa(_e,n??0,{goDecks:()=>ke("decks"),goReview:a=>ke("review",a)}):e==="review"?La(_e,n??0,{goDecks:()=>ke("decks")}):Ra(_e)}kr.addEventListener("click",()=>{Oe(!xt)});Cr.addEventListener("click",()=>{Oe(!1)});document.addEventListener("keydown",e=>{e.key==="Escape"&&xt&&Oe(!1)});et.querySelectorAll(".drawer-btn").forEach(e=>{e.addEventListener("click",()=>{const n=e.dataset.page;(n==="home"||n==="decks"||n==="sync")&&ke(n),Oe(!1)})});await _n();Oe(!1);ke("home");console.log("[JapLearn] crossOriginIsolated =",window.crossOriginIsolated);
|
|
||||||
Vendored
+126
@@ -0,0 +1,126 @@
|
|||||||
|
(function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))o(s);new MutationObserver(s=>{for(const c of s)if(c.type==="childList")for(const u of c.addedNodes)u.tagName==="LINK"&&u.rel==="modulepreload"&&o(u)}).observe(document,{childList:!0,subtree:!0});function a(s){const c={};return s.integrity&&(c.integrity=s.integrity),s.referrerPolicy&&(c.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?c.credentials="include":s.crossOrigin==="anonymous"?c.credentials="omit":c.credentials="same-origin",c}function o(s){if(s.ep)return;s.ep=!0;const c=a(s);fetch(s.href,c)}})();const Re=new Worker(new URL("/assets/db-worker-DFFhVJkA.js",import.meta.url),{type:"module"});let fr=0,Ae=null;const et=new Map;function Rn(){return Ae||(Ae=new Promise((e,r)=>{Re.onmessage=o=>{const s=o.data;if(s.type==="ready")return;const c=et.get(s.id);c&&(et.delete(s.id),s.type=="success"?c.resolve(s.data):c.reject(new Error(s.error??s.message??"Неизвестная ошибка Worker")))},Re.onerror=o=>{console.error("[SQLite Worker] Ошибка:",o),r(new Error("Ошибка Worker"))};const a=++fr;et.set(a,{resolve:e,reject:r}),Re.postMessage({type:"start",id:a})}),Ae)}function pr(e,r){return new Promise((a,o)=>{if(!Re){o(new Error("Worker не создан"));return}const s=++fr;et.set(s,{resolve:a,reject:o}),Re.postMessage({type:e,id:s,...r})})}async function ie(e,r){return await pr("query",{sql:e,params:r??[]})}async function hr(e,r){await pr("command",{sql:e,params:r??[]})}function wr(e){return new Date(e*1e3)}function In(){return Math.floor(Date.now()/1e3)}function Jn(e){const r=wr(e),a=String(r.getDate()).padStart(2,"0"),o=String(r.getMonth()+1).padStart(2,"0");return`${a}.${o}.${r.getFullYear()}`}function On(e){const r=wr(e),a=String(r.getHours()).padStart(2,"0"),o=String(r.getMinutes()).padStart(2,"0");return`${Jn(e)} ${a}:${o}`}function Kn(e){if(e==null)return[];const r=String(e).trim();return r?r.split(/[,\s;]+/).map(a=>a.trim()).filter(a=>a.length>0):[]}function Dn(e){const r=new Map;if(e==null)return r;const a=String(e);if(!a)return r;const o=a.split("");for(let s=0;s+1<o.length;s+=2){const c=Number(o[s]),u=o[s+1]??"";!Number.isFinite(c)||!Number.isInteger(c)||u&&r.set(c,u)}return r}function gr(e){const r=Number(e.id??0),a=String(e.key??""),o=String(e.value??""),s=e.tags==null?"":String(e.tags),c=Number(e.group_id??1);return{id:r,key:a,value:o,tags:s,tagsList:Kn(s),more:Dn(e.more),group_id:Number.isFinite(c)?c:1}}function mr(e){const r=[],a=[],o=(e.search??"").trim();if(o){r.push("(key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\')");const s=`%${o.replace(/\\/g,"\\\\").replace(/%/g,"\\%").replace(/_/g,"\\_")}%`;a.push(s,s,s)}return e.groupId!=null&&(r.push("group_id = ?"),a.push(e.groupId)),{clause:r.length>0?`WHERE ${r.join(" AND ")}`:"",params:a}}async function Xn(e={}){const{clause:r,params:a}=mr(e),s=(await ie(`SELECT COUNT(*) as cnt FROM words ${r}`,a))[0]?.cnt;return typeof s=="number"?s:Number(s??0)}async function tr(e={}){const{clause:r,params:a}=mr(e),o=Math.max(1,Math.min(e.limit??50,500)),s=Math.max(0,e.offset??0);return(await ie(`SELECT id, key, value, tags, more, group_id FROM words ${r} ORDER BY id LIMIT ? OFFSET ?`,[...a,o,s])).map(gr)}async function zn(){return(await ie("SELECT id, name FROM word_groups ORDER BY id")).map(r=>({id:Number(r.id),name:String(r.name??"")}))}async function Bn(){return(await ie("SELECT id, name, forward, backward, filter FROM decks ORDER BY id")).map(r=>({id:Number(r.id),name:String(r.name??""),forward:String(r.forward??""),backward:String(r.backward??""),filter:String(r.filter??"")}))}async function Vn(e,r){return await hr("UPDATE decks SET forward = ?, backward = ?, filter = ? WHERE id = ?",[r.forward,r.backward,r.filter,e]),nt(e)}async function nt(e){const a=(await ie("SELECT id, name, forward, backward, filter FROM decks WHERE id = ? LIMIT 1",[e]))[0];return a?{id:Number(a.id),name:String(a.name??""),forward:String(a.forward??""),backward:String(a.backward??""),filter:String(a.filter??"")}:null}async function Yn(){const e=await ie("SELECT deck_id, COUNT(*) as cnt FROM states WHERE deck_id IS NOT NULL GROUP BY deck_id"),r=new Map;for(const a of e){if(a.deck_id==null)continue;const o=a.cnt;r.set(Number(a.deck_id),typeof o=="number"?o:Number(o??0))}return r}async function Zn(e){const a=(await ie("SELECT COUNT(DISTINCT word_id) as cnt FROM states WHERE deck_id = ?",[e]))[0]?.cnt;return typeof a=="number"?a:Number(a??0)}async function rr(e){const r=new Map,a=[...new Set(e.filter(s=>Number.isFinite(s)))];if(a.length===0)return r;const o=400;for(let s=0;s<a.length;s+=o){const c=a.slice(s,s+o),u=c.map(()=>"?").join(","),g=await ie(`SELECT id, key, value, tags, more, group_id FROM words WHERE id IN (${u})`,c);for(const y of g.map(gr))r.set(y.id,y)}return r}function Gn(e,r){const a=new Map;a.set("key",e.key),a.set("value"Line truncated
|
||||||
|
w.key as wkey, w.value as wvalue
|
||||||
|
FROM history h LEFT JOIN words w ON w.id = h.word_id
|
||||||
|
WHERE h.deck_id = ? ORDER BY h.timestamp DESC, h.id DESC LIMIT ? OFFSET ?`,[e,a,o])).map(c=>({id:Number(c.id),deck_id:Number(c.deck_id),word_id:Number(c.word_id),timestamp:Number(c.timestamp??0),answer:Number(c.answer??0),before:Number(c.before??0),after:Number(c.after??0),fsrs:Number(c.fsrs??0),wordKey:c.wkey==null?"":String(c.wkey),wordValue:c.wvalue==null?"":String(c.wvalue)}))}const Ut="japanese_app.db";async function xt(){return navigator.storage.getDirectory()}async function ta(e){e[18]=1,e[19]=1;const a=await(await xt()).getFileHandle(Ut,{create:!0}),o=a.createSyncAccessHandle;if(typeof o=="function")try{const c=await o.call(a);try{c.truncate(0),c.write(e,{at:0}),c.flush()}finally{c.close()}return}catch{}const s=await a.createWritable();try{await s.truncate(0);const c=new Uint8Array(e.length);c.set(e),await s.write(c)}finally{await s.close()}}async function ra(){const e=await xt();let r;try{r=await e.getFileHandle(Ut)}catch{throw new Error("Локальная база не найдена — сначала скачай сохранение")}const a=await r.getFile();if(a.size===0)throw new Error("Локальная база пуста");return new Uint8Array(await a.arrayBuffer())}async function Je(){try{return(await(await(await xt()).getFileHandle(Ut)).getFile()).size>0}catch{return!1}}var na=ArrayBuffer,G=Uint8Array,tt=Uint16Array,aa=Int16Array,at=Int32Array,mt=function(e,r,a){if(G.prototype.slice)return G.prototype.slice.call(e,r,a);(r==null||r<0)&&(r=0),(a==null||a>e.length)&&(a=e.length);var o=new G(a-r);return o.set(e.subarray(r,a)),o},Ie=function(e,r,a,o){if(G.prototype.fill)return G.prototype.fill.call(e,r,a,o);for((a==null||a<0)&&(a=0),(o==null||o>e.length)&&(o=e.length);a<o;++a)e[a]=r;return e},sa=function(e,r,a,o){if(G.prototype.copyWithin)return G.prototype.copyWithin.call(e,r,a,o);for((a==null||a<0)&&(a=0),(o==null||o>e.length)&&(o=e.length);a<o;)e[r++]=e[a++]},ia=["invalid zstd data","window size too large (>2046MB)","invalid block type","FSE accuracy too high","match distance too far back","unexpected EOF"],A=function(e,r,a){var o=new Error(r||ia[e]);if(o.code=e,Error.captureStackTrace&&Error.captureStackTrace(o,A),!a)throw o;return o},sr=function(e,r,a){for(var o=0,s=0;o<a;++o)s|=e[r++]<<(o<<3);return s},oa=function(e,r){return(e[r]|e[r+1]<<8|e[r+2]<<16|e[r+3]<<24)>>>0},ca=function(e,r){var a=e[0]|e[1]<<8|e[2]<<16;if(a==3126568&&e[3]==253){var o=e[4],s=o>>5&1,c=o>>2&1,u=o&3,g=o>>6;o&8&&A(0);var y=6-s,m=u==3?4:u,b=sr(e,y,m);y+=m;var T=g?1<<g:s,k=sr(e,y,T)+(g==1&&256),U=k;if(!s){var N=1<<10+(e[5]>>3);U=N+(N>>3)*(e[5]&7)}U>2145386496&&A(1);var w=new G((r==1?k||U:r?0:U)+12);return w[0]=1,w[4]=4,w[8]=8,{b:y+T,y:0,l:0,d:b,w:r&&r!=1?r:w.subarray(12),e:U,o:new at(w.buffer,0,3),u:k,c,m:Math.min(131072,U)}}else if((a>>4|e[3]<<20)==25481893)return oa(e,4)+8;A(0)},Ce=function(e){for(var r=0;1<<r<=e;++r);return r-1},Oe=function(e,r,a){var o=(r<<3)+4,s=(e[r]&15)+5;s>a&&A(3);for(var c=1<<s,u=c,g=-1,y=-1,m=-1,b=c,T=new na(512+(c<<2)),k=new aa(T,0,256),U=new tt(T,0,256),N=new tt(T,512,c),w=512+(c<<1),E=new G(T,w,c),I=new G(T,w+c);g<255&&u>0;){var J=Ce(u+1),L=o>>3,z=(1<<J+1)-1,P=(e[L]|e[L+1]<<8|e[L+2]<<16)>>(o&7)&z,C=(1<<J)-1,_=z-u-1,R=P&C;if(R<_?(o+=J,P=R):(o+=J+1,P>C&&(P-=_)),k[++g]=--P,P==-1?(u+=P,E[--b]=g):u-=P,!P)do{var F=o>>3;y=(e[F]|e[F+1]<<8)>>(o&7)&3,o+=2,g+=y}while(y==3)}(g>255||u)&&A(0);for(var X=0,j=(c>>1)+(c>>3)+3,V=c-1,B=0;B<=g;++B){var H=k[B];if(H<1){U[B]=-H;continue}for(m=0;m<H;++m){E[X]=B;do X=X+j&V;while(X>=b)}}for(X&&A(0),m=0;m<c;++m){var v=U[E[m]]++,x=I[m]=s-Ce(v);N[m]=(v<<x)-c}return[o+7>>3,{b:s,s:E,n:I,t:N}]},la=function(e,r){var a=0,o=-1,s=new G(292),c=e[r],u=s.subarray(0,256),g=s.subarray(256,268),y=new tt(s.buffer,268);if(c<128){var m=Oe(e,r+1,6),b=m[0],T=m[1];r+=c;var k=b<<3,U=e[r];U||A(0);for(var N=0,w=0,E=T.b,I=E,J=(++r<<3)-8+Ce(U);J-=E,!(J<k);){var L=J>>3;if(N+=(e[L]|e[L+1]<<8)>>(J&7)&(1<<E)-1,u[++o]=T.s[N],J-=I,J<k)break;L=J>>3,w+=(e[L]|e[L+1]<<8)>>(J&7)&(1<<I)-1,u[++o]=T.s[w],E=T.n[N],N=T.t[N],I=T.n[w],w=T.t[w]}++o>255&&A(0)}else{for(o=c-127;a<o;a+=2){var z=e[++r];u[a]=z>>4,u[a+1]=z&15}++r}var P=0;for(a=0;a<o;++a){var C=u[a];C>11&&A(0),P+=C&&1<<C-1}var _=Ce(P)+1,R=1<<_,F=R-P;for(F&F-1&&A(0),u[o++]=Ce(F)+1,a=0;a<o;++a){var C=u[a];++g[u[a]=C&&_+1-C]}var X=new G(R<<1),j=X.subarray(0,R),V=X.subarray(R);for(y[_]=0,a=_;a>0;--a){var B=y[a];Ie(V,a,B,y[a-1]=B+g[a]*(1<<_-a))}for(y[0]!=R&&A(0),a=0;a<o;++a){var H=u[a];if(H){var v=y[H];Ie(j,a,v,y[H]=v+(1<<_-H))}}return[r,{n:V,b:_,s:j}]},ua=Oe(new G([81,16,99,140,49,198,24,99,12,33,196,24,99,102,102,134,70,146,4]),0,6)[1],da=Oe(new G([33,20,196,24,99,140,33,132,16,66,8,33,132,16,66,8,33,68,68,68,68,68,68,68,68,36,9]),0,6)[1],fa=Oe(new G([32,132,16,66,102,70,68,68,68,68,36,73,2]),0,5)[1],yr=function(e,r){for(var a=e.length,o=new at(a),s=0;s<a;++s)o[s]=r,r+=1<<e[s];return o},kt=new G(new at([0,0,0,0,16843009,50528770,134678020,202050057,269422093]).buffer,0,36),pa=yr(kt,0),Et=new G(new at([0,0,0,0,0,0,0,0,16843009,50528770,11776
Line 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 a=e.querySelector("#home-db-banner"),o=e.querySelector("#home-db-text"),s=e.querySelector("#home-db-go"),c=e.querySelector("#words-search"),u=e.querySelector("#words-group"),g=e.querySelector("#words-count"),y=e.querySelector("#words-status"),m=e.querySelector("#words-list"),b=e.querySelector("#words-more");function T(v,x){a.hidden=!1,o.textContent=v,s.hidden=!1}function k(){a.hidden=!0}s.addEventListener("click",()=>{document.querySelector('.drawer-btn[data-page="sync"]')?.click()});let U=!1,N=new Map,w=0,E=[],I=null,J=0,L=!1,z=0,P=0;function C(){const v=u.value;if(v==="")return{search:c.value.trim(),groupId:null};const x=Number(v);return{search:c.value.trim(),groupId:Number.isFinite(x)?x:null}}function _(){b.hidden=!(U&&E.length<w),b.hidden||(b.textContent=`Показать ещё (${w-E.length} осталось)`)}function R(){m.innerHTML=E.map(v=>or(v,N,v.id===I)).join(""),g.textContent=w>0?`Показано ${E.length} из ${w}`:"",_()}function F(v){if(I===v){v!=null&&X(v),I=null;return}I!=null&&X(I),I=v,v!=null&&j(v)}function X(v){const x=m.querySelector(`.word-row-wrap[data-word-id="${v}"]`);if(!x)return;const M=x.querySelector(".word-row"),Q=x.querySelector(".word-extra");M&&(M.classList.remove("open"),M.setAttribute("aria-expanded","false")),Q&&(Q.hidden=!0,Q.innerHTML="")}function j(v){const x=E.find(he=>he.id===v),M=m.querySelector(`.word-row-wrap[data-word-id="${v}"]`);if(!x||!M)return;const Q=M.querySelector(".word-row"),O=M.querySelector(".word-extra");Q&&(Q.classList.add("open"),Q.setAttribute("aria-expanded","true")),O&&(O.hidden=!1,O.innerHTML=kr(x,N))}function V(v){y.textContent=v,y.hidden=v===""}async function B(){if(!U||L)return;L=!0;const v=++z,x=C();b.disabled=!0,E.length===0&&V("Загрузка слов…");try{const[M,Q]=await Promise.all([Xn({search:x.search,groupId:x.groupId??void 0}),tr({search:x.search,groupId:x.groupId??void 0,limit:ir,offset:0})]);if(v!==z)return;w=M,E=Q,J=Q.length,I!=null&&!E.some(O=>O.id===I)&&(I=null),R(),V(Q.length===0?"По этому запросу слов нет.":""),Q.length===0&&w===0&&(g.textContent="Слов в базе пока нет.")}catch(M){if(v!==z)return;V(`Не удалось загрузить слова: ${M instanceof Error?M.message:String(M)}`)}finally{v===z&&(L=!1,b.disabled=!1,_())}}async function H(){if(!U||L||E.length>=w)return;L=!0;const v=++z,x=C();b.disabled=!0,b.textContent="Загрузка…";try{const M=await tr({search:x.search,groupId:x.groupId??void 0,limit:ir,offset:J});if(v!==z)return;E=E.concat(M),J+=M.length,m.insertAdjacentHTML("beforeend",M.map(Q=>or(Q,N,Q.id===I)).join("")),g.textContent=`Показано ${E.length} из ${w}`,_()}catch(M){if(v!==z)return;V(`Не удалось догрузить: ${M instanceof Error?M.message:String(M)}`)}finally{v===z&&(L=!1,b.disabled=!1,_())}}c.addEventListener("input",()=>{window.clearTimeout(P),P=window.setTimeout(()=>{B()},300)}),c.addEventListener("keydown",v=>{v.key==="Enter"&&(window.clearTimeout(P),B())}),u.addEventListener("change",()=>{B()}),b.addEventListener("click",()=>{H()}),m.addEventListener("click",v=>{const x=v.target.closest(".word-row");if(!x||x.disabled)return;const M=Number(x.dataset.wordId);Number.isFinite(M)&&F(I===M?null:M)}),(async()=>{if(!await Je()){T("Локальной базы нет — скачай сохранение."),V("Нет локальной базы — скачай сохранение на странице «Синхронизация»."),g.textContent="";return}try{const x=await ra();if(!$t(x)){T("Файл в OPFS не похож на SQLite — перекачай сохранение.",!0),V("Файл в OPFS не похож на SQLite — перекачай сохранение.");return}k();try{const[Q,O]=await Promise.all([zn(),vr()]),he=Q;N=new Map(O.map(ue=>[ue.id,ue])),u.innerHTML='<option value="">Все группы</option>'+he.map(ue=>`<option value="${ue.id}">${Ee(ue.name)}</option>`).join(""),U=!0,await B(),u.options[0].textContent=`Все группы (${w})`}catch(Q){V(`БД есть, но слова не прочитались: ${Q instanceof Error?Q.message:String(Q)}`)}}catch(x){T(`Не удалось прочитать базу: ${x instanceof Error?x.message:String(x)}`)}})()}function cr(e){return e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}function La(e){const r=e%10,a=e%100;return r===1&&a!==11?"запись":r>=2&&r<=4&&(a<12||a>14)?"записи":"записей"}function Fa(e,r){const a=cr(e.name)||`<span class="empty-val">Колода #${e.id}</span>`;return`<div class="word-row-wrap deck-row" data-deck-id="${e.id}"><button class="word-row deck-name" type="button" data-act="open" data-deck-id="${e.id}"><span class="word-main"><span class="word-key">${a}</span></span><span class="word-tag">${r} ${La(r)}</span></button><button class="deck-play" type="button" data-act="review" data-deck-id="${e.id}" aria-label="ПовторLine truncated
|
||||||
|
<section class="card" aria-labelledby="decks-title">
|
||||||
|
<h2 id="decks-title" class="card-title">🗂️ Колоды</h2>
|
||||||
|
<p id="decks-count" class="card-hint" role="status" aria-live="polite"></p>
|
||||||
|
<div id="decks-status" class="status-bar" role="status">Проверка локальной базы…</div>
|
||||||
|
<div id="decks-list" class="words-list" aria-live="polite"></div>
|
||||||
|
</section>
|
||||||
|
`;const a=e.querySelector("#decks-count"),o=e.querySelector("#decks-status"),s=e.querySelector("#decks-list");function c(u){o.textContent=u,o.hidden=u===""}s.addEventListener("click",u=>{const g=u.target.closest("[data-act]");if(!g)return;const y=Number(g.dataset.deckId);Number.isFinite(y)&&(g.dataset.act==="review"?r.goReview(y):r.goDeck(y))}),(async()=>{if(!await Je()){c("Нет локальной базы — скачай сохранение на странице «Синхронизация»."),a.textContent="";return}c("Загрузка колод…");try{const[g,y]=await Promise.all([Bn(),Yn()]);if(g.length===0){a.textContent="Колоды пока отсутствуют.",c(""),s.innerHTML="";return}a.textContent=`Всего: ${g.length}`,c(""),s.innerHTML=g.map(m=>Fa(m,y.get(m.id)??0)).join("")}catch(g){c(`Не удалось загрузить колоды: ${g instanceof Error?g.message:String(g)}`)}})()}function Qa(e){return e?Pa(e):'<span class="empty-val">—</span>'}function Pa(e){return e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}function bt(e){return e.replace(/&/g,"&").replace(/"/g,""").replace(/</g,"<").replace(/>/g,">")}function Ra(e){const r=e%10,a=e%100;return r===1&&a!==11?"слово":r>=2&&r<=4&&(a<12||a>14)?"слова":"слов"}function Ia(e,r,a){e.innerHTML=`
|
||||||
|
<button id="deck-back" class="btn btn-ghost" type="button">← Назад к колодам</button>
|
||||||
|
<section class="card" aria-labelledby="deck-title">
|
||||||
|
<h2 id="deck-title" class="card-title">Колода…</h2>
|
||||||
|
<div id="deck-status" class="status-bar" role="status">Загрузка колоды…</div>
|
||||||
|
<div id="deck-body"></div>
|
||||||
|
</section>
|
||||||
|
`;const o=e.querySelector("#deck-back"),s=e.querySelector("#deck-title"),c=e.querySelector("#deck-status"),u=e.querySelector("#deck-body");o.addEventListener("click",()=>a.goDecks()),(async()=>{if(!await Je()){s.textContent="Колода недоступна",c.textContent="Нет локальной базы — скачай сохранение на странице «Синхронизация».";return}let y=null;try{y=await nt(r)}catch(C){s.textContent="Колода недоступна",c.textContent=`Не удалось загрузить колоду: ${C instanceof Error?C.message:String(C)}`;return}if(!y){s.textContent="Колода не найдена",c.textContent=`Колоды с id=${r} нет в локальной базе. Возможно, база обновилась — вернись к списку.`;return}const m=y;s.textContent=m.name||`Колода #${m.id}`,c.hidden=!0;let b=null;try{b=await Zn(m.id)}catch(C){c.hidden=!1,c.textContent=`Данные колоды загружены, а количество слов — нет: ${C instanceof Error?C.message:String(C)}`}u.innerHTML=`
|
||||||
|
<dl class="word-extra-list">
|
||||||
|
<div class="word-extra-row"><dt>id</dt><dd>${m.id}</dd></div>
|
||||||
|
<div class="word-extra-row"><dt>name</dt><dd>${Qa(m.name)}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<form id="deck-form" class="deck-form" autocomplete="off">
|
||||||
|
<label class="field-label" for="deck-forward">forward</label>
|
||||||
|
<input id="deck-forward" class="token-input" type="text" value="${bt(m.forward)}" />
|
||||||
|
<label class="field-label" for="deck-backward">backward</label>
|
||||||
|
<input id="deck-backward" class="token-input" type="text" value="${bt(m.backward)}" />
|
||||||
|
<label class="field-label" for="deck-filter">filter</label>
|
||||||
|
<input id="deck-filter" class="token-input" type="text" value="${bt(m.filter)}" />
|
||||||
|
<div class="btn-row">
|
||||||
|
<button id="deck-save" class="btn btn-primary" type="submit">💾 Сохранить</button>
|
||||||
|
</div>
|
||||||
|
<p id="deck-form-status" class="card-hint" role="status" aria-live="polite"></p>
|
||||||
|
</form>
|
||||||
|
<h3 class="deck-sub">📚 ${b==null?"Количество слов неизвестно":`${b} ${Ra(b)}`}</h3>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button id="deck-review" class="btn btn-primary" type="button"${b===0?" disabled":""}>▶ Повторять</button>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button id="deck-history" class="btn" type="button">🕘 История ответов</button>
|
||||||
|
</div>
|
||||||
|
`;const T=u.querySelector("#deck-form"),k=u.querySelector("#deck-forward"),U=u.querySelector("#deck-backward"),N=u.querySelector("#deck-filter"),w=u.querySelector("#deck-save"),E=u.querySelector("#deck-form-status"),I=u.querySelector("#deck-review"),J=u.querySelector("#deck-history");I.addEventListener("click",()=>a.goReview(m.id)),J.addEventListener("click",()=>a.goHistory(m.id));const L=()=>`${k.value}
|
||||||
|
${U.value}
|
||||||
|
${N.value}`;let z=L();function P(){w.disabled=L()===z||w.dataset.busy==="1"}for(const C of[k,U,N])C.addEventListener("input",P);P(),T.addEventListener("submit",C=>{C.preventDefault(),!w.disabled&&(w.dataset.busy="1",w.disabled=!0,w.textContent="Сохранение…",E.textContent="",(async()=>{try{const _=await Vn(m.id,{forward:k.value,backward:U.value,filter:N.value});if(!_){E.textContent="Колода пропала из базы — вернись к списку.";return}k.value=_.forward,U.value=_.backward,N.value=_.filter,z=L(),E.textContent="✅ Сохранено в локальную базу."}catch(_){E.textContent=`Не удалось сохранить: ${_ instanceof Error?_.message:String(_)}`}finally{delete w.dataset.busy,w.textContent="💾 Сохранить",P()}})())})})()}function _e(e){return e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}const Ja=50,Oa=[{label:"Не получилось",cls:"grade-fail"},{label:"Тяжело",cls:"grade-hard"},{label:"Хорошо",cls:"grade-good"},{label:"Легко",cls:"grade-easy"}];function Ka(e){const r=Oa[e.answer]??{label:`Оценка ${e.answer}`,cls:""},a=e.wordKey?`<span class="word-key">${_e(e.wordKey)}</span>`+(e.wordValue?`<span class="word-sep"> — </span><span class="word-value">${_e(e.wordValue)}</span>`:""):`<span class="empty-val">Слово #${e.word_id} (удалено)</span>`,o=e.before===e.after?`${e.before}`:`${e.before}→${e.after}`;return`<div class="hist-row-wrap"><div class="hist-main">${a}</div><div class="hist-meta"><span class="hist-grade ${r.cls}">${_e(r.label)}</span><span class="word-tag">${_e(On(e.timestamp))} · score ${_e(o)}</span></div></div>`}function Da(e,r,a){e.innerHTML=`
|
||||||
|
<button id="hist-back" class="btn btn-ghost" type="button">← Назад к колоде</button>
|
||||||
|
<section class="card" aria-labelledby="hist-title">
|
||||||
|
<h2 id="hist-title" class="card-title">История…</h2>
|
||||||
|
<p id="hist-count" class="card-hint" role="status" aria-live="polite"></p>
|
||||||
|
<div id="hist-status" class="status-bar" role="status">Загрузка истории…</div>
|
||||||
|
<div id="hist-list" class="words-list" aria-live="polite"></div>
|
||||||
|
<div class="btn-row"><button id="hist-more" class="btn" type="button" hidden>Показать ещё</button></div>
|
||||||
|
</section>
|
||||||
|
`;const o=e.querySelector("#hist-back"),s=e.querySelector("#hist-title"),c=e.querySelector("#hist-count"),u=e.querySelector("#hist-status"),g=e.querySelector("#hist-list"),y=e.querySelector("#hist-more");o.addEventListener("click",()=>a.goDeck(r));function m(w){u.textContent=w,u.hidden=w===""}let b=0,T=0,k=!1;function U(){const w=b-T;y.hidden=w<=0,w>0&&(y.textContent=`Показать ещё (${w} осталось)`)}async function N(){if(!(k||T>=b)){k=!0,y.disabled=!0,y.textContent="Загрузка…";try{const w=await ea(r,{limit:Ja,offset:T});T+=w.length,g.insertAdjacentHTML("beforeend",w.map(Ka).join("")),c.textContent=`Показано ${T} из ${b}`}catch(w){m(`Не удалось загрузить историю: ${w instanceof Error?w.message:String(w)}`)}finally{k=!1,y.disabled=!1,U()}}}y.addEventListener("click",()=>{N()}),(async()=>{if(!await Je()){s.textContent="История недоступна",m("Нет локальной базы — скачай сохранение на странице «Синхронизация».");return}try{const E=await nt(r);if(!E){s.textContent="Колода не найдена",m(`Колоды с id=${r} нет в локальной базе.`);return}if(s.textContent=`🕘 ${E.name||`Колода #${E.id}`}`,b=await An(E.id),b===0){c.textContent="Ответов пока нет — начни повторение.",m("");return}m(""),await N()}catch(E){s.textContent="История недоступна",m(`Не удалось загрузить историю: ${E instanceof Error?E.message:String(E)}`)}})()}function Xa(e){return e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}const za=[{value:0,label:"😞 Не получилось",cls:"grade-fail"},{value:1,label:"😟 Тяжело",cls:"grade-hard"},{value:2,label:"🙂 Хорошо",cls:"grade-good"},{value:3,label:"🤩 Легко",cls:"grade-easy"}];function Ba(e,r,a){e.innerHTML=`
|
||||||
|
<button id="review-back" class="btn btn-ghost" type="button">← Назад к колодам</button>
|
||||||
|
<section class="card" aria-labelledby="review-title">
|
||||||
|
<h2 id="review-title" class="card-title">Повторение…</h2>
|
||||||
|
<p id="review-progress" class="card-hint" role="status" aria-live="polite"></p>
|
||||||
|
<div id="review-status" class="status-bar" role="status">Загрузка карточек…</div>
|
||||||
|
<div id="review-body"></div>
|
||||||
|
</section>
|
||||||
|
`;const o=e.querySelector("#review-back"),s=e.querySelector("#review-title"),c=e.querySelector("#review-progress"),u=e.querySelector("#review-status"),g=e.querySelector("#review-body");o.addEventListener("click",()=>a.goDecks());function y(R){u.textContent=R,u.hidden=R===""}let m=[],b=new Map,T=[],k="",U="",N=0,w=!1,E=null,I=0,J=!1;function L(R,F){return R.length===0?`<div class="review-side ${F}"><span class="empty-val">—</span></div>`:`<div class="review-side ${F}">`+R.map(X=>`<p class="review-line">${Xa(X)}</p>`).join("")+"</div>"}async function z(){const R=++I,F=m[N],X=b.get(F);if(!X){g.innerHTML="",c.textContent="",y("Слово пропало из базы — пропускаю.");return}E=null;let j="";try{E=await jn(r,F)??1}catch{E=1,j=" (score прочитать не удалось — пишу before/after=1)"}if(R!==I)return;J=!1;const V=Gn(X,T),B=nr(k,V),H=w?nr(U,V):null;c.textContent=`Карточка ${N+1} из ${m.length}`,y("");const v=w?L(H??[],"review-back-side"):'<p class="review-tap">Тап — показать ответ</p>',x=w?`<div class="review-grades" role="group" aria-label="Оценка повторения">
|
||||||
|
${za.map(M=>`<button class="btn review-grade ${M.cls}" type="button" data-grade="${M.value}">${M.label}</button>`).join("")}
|
||||||
|
</div>`:"";g.innerHTML=`
|
||||||
|
<button id="review-card" class="review-card" type="button"
|
||||||
|
aria-expanded="${w}" aria-label="${w?"Карточка открыта":"Показать ответ"}">
|
||||||
|
${L(B,"review-front")}
|
||||||
|
${w?'<hr class="review-hr" />':""}
|
||||||
|
${v}
|
||||||
|
</button>
|
||||||
|
${x}
|
||||||
|
`,g.querySelector("#review-card").addEventListener("click",()=>{w||(w=!0,z())}),g.querySelectorAll(".review-grade").forEach(M=>{M.addEventListener("click",()=>{const Q=Number(M.dataset.grade);P(Q,j)})})}async function P(R,F){if(J)return;J=!0;const X=m[N],j=E??1,V=[...g.querySelectorAll(".review-grade")];for(const B of V)B.disabled=!0;try{await Wn({deckId:r,wordId:X,answer:R,before:j,after:j})}catch(B){y(`Ответ не записался: ${B instanceof Error?B.message:String(B)}${F}`),J=!1;for(const H of V)H.disabled=!1;return}N+=1,w=!1,E=null,N>=m.length?C():await z()}function C(){c.textContent=`Готово: ${m.length} из ${m.length}`,y(""),g.innerHTML=`
|
||||||
|
<p class="card-hint">Все карточки сессии просмотрены, ответы записаны в history.</p>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button id="review-again" class="btn btn-primary" type="button">🔀 Перемешать заново</button>
|
||||||
|
</div>
|
||||||
|
`,g.querySelector("#review-again").addEventListener("click",()=>{_()})}async function _(){if(y("Перемешиваю…"),g.innerHTML="",c.textContent="",m=await ar(r),N=0,w=!1,m.length===0){y("В колоде нет слов для повторения.");return}b=await rr(m),z()}(async()=>{if(!await Je()){s.textContent="Повторение недоступно",y("Нет локальной базы — скачай сохранение на странице «Синхронизация».");return}try{const[F,X]=await Promise.all([nt(r),vr()]);if(!F){s.textContent="Колода не найдена",y(`Колоды с id=${r} нет в локальной базе. Вернись к списку.`);return}if(T=X,k=F.forward,U=F.backward,s.textContent=F.name||`Колода #${F.id}`,m=await ar(F.id),m.length===0){c.textContent="",y("В колоде нет слов для повторения.");return}b=await rr(m),N=0,w=!1,await z()}catch(F){s.textContent="Повторение недоступно",y(`Не удалось загрузить карточки: ${F instanceof Error?F.message:String(F)}`)}})()}const lr=24,Va=/^[A-Za-z0-9]{24}$/,Er="japlearn_sync_token";function ur(e){return e.trim().replace(/\s+/g,"")}function Qe(e){return Va.test(e)}function Ya(){try{return localStorage.getItem(Er)??""}catch{return""}}function dr(e){try{localStorage.setItem(Er,e)}catch{}}const Cr="https://learning.micialware.ru";function Za(e,r=Cr){return`${r.replace(/\/+$/,"")}/download/${encodeURIComponent(e)}`}async function Ga(e,r,a=Cr){const o=Za(e,a);let s;try{s=await fetch(o,{method:"GET",...r})}catch(u){throw new Error(`Нет связи с сервером: ${u instanceof Error?u.message:String(u)}`)}if(!s.ok)throw s.status===404?new Error("Сохранение не найдено (404). Проверь токен."):new Error(`Сервер ответил ${s.status} ${s.statusText}`);const c=await s.arrayBuffer();if(c.byteLength===0)throw new Error("Сервер вернул пустой файл");return new Uint8Array(c)}function ja(e){e.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 btn-primary" type="button" disabled>📥 Скачать</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="status" class="status-bar" role="status">Введите токен из 24 символов</p>
|
||||||
|
</main>
|
||||||
|
`;const r=e.querySelector("#token-input"),a=e.querySelector("#token-error"),o=e.querySelector("#token-counter"),s=e.querySelector("#btn-download"),c=e.querySelector("#status");function u(k){c.textContent=k,console.log(`[JapLearn] ${k}`)}function g(){return ur(r.value)}function y(k){s.disabled=k||!Qe(g()),r.disabled=k}function m(){const k=g(),U=k.length;o.textContent=`${U} / ${lr}`;const N=Qe(k),w=U===0;r.classList.toggle("invalid",!w&&!N),r.setAttribute("aria-invalid",String(!w&&!N)),w?a.textContent="":U!==lr?a.textContent=`Нужно 24 символа, сейчас ${U}`:a.textContent=N?"":"Только латинские буквы и цифры",s.disabled=!N}async function b(){const k=g();if(Qe(k)){dr(k),y(!0),u("Скачивание сохранения...");try{const U=await Ga(k);u(`Скачано ${(U.length/1024).toFixed(1)} КБ, распаковка...`);const N=await Ha(U);u(`Распаковано ${(N.length/1024).toFixed(1)} КБ, сохранение в OPFS...`),await ta(N),u(`Готово: база ${(N.length/1024).toFixed(1)} КБ сохранена локально (OPFS).`)}catch(U){u(`Ошибка скачивания: ${U instanceof Error?U.message:String(U)}`)}finally{y(!1),m()}}}r.addEventListener("input",()=>{m()}),r.addEventListener("change",()=>{const k=g();r.value=k,Qe(k)&&(dr(k),u("Токен сохранён локально. Можно скачивать.")),m()}),s.addEventListener("click",()=>{b()});const T=Ya();T&&(r.value=T),m(),Qe(ur(T))&&u("Токен восстановлен. Нажми «Скачать», чтобы получить базу.")}const Wa={home:"Главная",decks:"Колоды",deck:"Колода",review:"Повторение",history:"История",sync:"Синхронизация"};function Ke(e){const r=document.getElementById(e);if(!r)throw new Error(`[JapLearn] Не найден элемент #${e}`);return r}const $r=Ke("burger-btn"),rt=Ke("drawer"),Ur=Ke("drawer-overlay"),Aa=Ke("page-title"),Ue=Ke("page");let Mt=!1;function De(e){Mt=e,rt.classList.toggle("open",e),Ur.hidden=!e,$r.setAttribute("aria-expanded",String(e)),rt.setAttribute("aria-hidden",String(!e)),document.body.classList.toggle("drawer-open",e)}function es(e){rt.querySelectorAll(".drawer-btn").forEach(r=>{r.classList.toggle("active",r.dataset.page===e)})}function me(e,r){Aa.textContent=Wa[e],es(e==="deck"||e==="review"||e==="history"?"decks":e),e==="home"?qa(Ue):e==="decks"?_a(Ue,{goDeck:a=>me("deck",a),goReview:a=>me("review",a)}):e==="deck"?Ia(Ue,r??0,{goDecks:()=>me("decks"),goReview:a=>me("review",a),goHistory:a=>me("history",a)}):e==="review"?Ba(Ue,r??0,{goDecks:()=>me("decks")}):e==="history"?Da(Ue,r??0,{goDeck:a=>me("deck",a)}):ja(Ue)}$r.addEventListener("click",()=>{De(!Mt)});Ur.addEventListener("click",()=>{De(!1)});document.addEventListener("keydown",e=>{e.key==="Escape"&&Mt&&De(!1)});rt.querySelectorAll(".drawer-btn").forEach(e=>{e.addEventListener("click",()=>{const r=e.dataset.page;(r==="home"||r==="decks"||r==="sync")&&me(r),De(!1)})});await Rn();De(!1);me("home");console.log("[JapLearn] crossOriginIsolated =",window.crossOriginIsolated);
|
||||||
Vendored
+2
-2
@@ -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-Ban37OPN.js"></script>
|
<script type="module" crossorigin src="/assets/index-Bk0XDs7i.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DkBF5-AQ.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-B1EUgva9.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
|
|||||||
+103
-1
@@ -8,6 +8,7 @@
|
|||||||
// - states.fsrs_memory: сырое INTEGER-значение FSRS (не дата, не трогаем).
|
// - states.fsrs_memory: сырое INTEGER-значение FSRS (не дата, не трогаем).
|
||||||
|
|
||||||
import { command, query } from "./sqlite-api.ts";
|
import { command, query } from "./sqlite-api.ts";
|
||||||
|
import { nowSec } from "./time.ts";
|
||||||
|
|
||||||
export interface CustomField {
|
export interface CustomField {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -355,7 +356,8 @@ export async function sampleDeckWordIds(
|
|||||||
// Тасование Фишера–Йетса: равномерная перестановка за O(n).
|
// Тасование Фишера–Йетса: равномерная перестановка за O(n).
|
||||||
for (let i = ids.length - 1; i > 0; i--) {
|
for (let i = ids.length - 1; i > 0; i--) {
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
[ids[i], ids[j]] = [ids[i]!, ids[j]!];
|
// NB: в RHS индексы перевёрнуты — иначе присваивание [i],[j] = [i],[j] ничего не меняет.
|
||||||
|
[ids[i], ids[j]] = [ids[j]!, ids[i]!];
|
||||||
}
|
}
|
||||||
if (limit != null && limit >= 0) return ids.slice(0, limit);
|
if (limit != null && limit >= 0) return ids.slice(0, limit);
|
||||||
return ids;
|
return ids;
|
||||||
@@ -371,6 +373,106 @@ export async function getCustomFields(): Promise<CustomField[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Текущий score слова: свежий states по паре (deck_id, word_id).
|
||||||
|
*
|
||||||
|
* Берём один — самый новый (ORDER BY id DESC LIMIT 1): именно он отражает
|
||||||
|
* последнее известное состояние до открытия карточки. Нет строки — null
|
||||||
|
* (слово в очередь попало из выборки, а states успели удалить).
|
||||||
|
*/
|
||||||
|
export async function getLatestStateScore(
|
||||||
|
deckId: number,
|
||||||
|
wordId: number,
|
||||||
|
): Promise<number | null> {
|
||||||
|
const rows = await query(
|
||||||
|
"SELECT score FROM states WHERE deck_id = ? AND word_id = ? ORDER BY id DESC LIMIT 1",
|
||||||
|
[deckId, wordId] as never[],
|
||||||
|
);
|
||||||
|
const r = (rows as Record<string, unknown>[])[0];
|
||||||
|
if (!r || r["score"] == null) return null;
|
||||||
|
const n = Number(r["score"]);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Оценка повторения: 0 = "не получилось", 3 = "легко". */
|
||||||
|
export type ReviewGrade = 0 | 1 | 2 | 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Записывает ответ в history.
|
||||||
|
*
|
||||||
|
* - timestamp: unix-секунды (nowSec из time.ts, тот же формат что history.timestamp);
|
||||||
|
* - fsrs: всегда 0 (false) — FSRS-шедулер подключим позже;
|
||||||
|
* - before/after: score слова. Логики пересчёта пока нет, поэтому оба равны
|
||||||
|
* score на момент открытия карточки (передаётся снаружи — см. review-page).
|
||||||
|
*
|
||||||
|
* Ничего не возвращает: id вставленной строки не нужен для UI.
|
||||||
|
*/
|
||||||
|
export async function recordReviewAnswer(args: {
|
||||||
|
deckId: number;
|
||||||
|
wordId: number;
|
||||||
|
answer: ReviewGrade;
|
||||||
|
before: number;
|
||||||
|
after: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
await command(
|
||||||
|
"INSERT INTO history (deck_id, word_id, timestamp, answer, before, after, fsrs) VALUES (?, ?, ?, ?, ?, ?, 0)",
|
||||||
|
[args.deckId, args.wordId, nowSec(), args.answer, args.before, args.after] as never[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeckHistoryFilter {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сколько всего записей history у колоды (для счётчика/пагинации). */
|
||||||
|
export async function countDeckHistory(deckId: number): Promise<number> {
|
||||||
|
const rows = await query("SELECT COUNT(*) as cnt FROM history WHERE deck_id = ?", [
|
||||||
|
deckId,
|
||||||
|
] as never[]);
|
||||||
|
const cnt = (rows[0] as Record<string, unknown> | undefined)?.["cnt"];
|
||||||
|
return typeof cnt === "number" ? cnt : Number(cnt ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Запись истории с подписью слова (LEFT JOIN — слово могли удалить). */
|
||||||
|
export interface DeckHistoryItem extends HistoryEntry {
|
||||||
|
/** key слова или '' если слово удалено. */
|
||||||
|
wordKey: string;
|
||||||
|
/** value слова или '' если слово удалено. */
|
||||||
|
wordValue: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* История ответов колоды: свежие первыми, пагинация limit/offset.
|
||||||
|
* Подтягивает key/value слова одним JOIN — без N+1.
|
||||||
|
*/
|
||||||
|
export async function getDeckHistory(
|
||||||
|
deckId: number,
|
||||||
|
filter: DeckHistoryFilter = {},
|
||||||
|
): Promise<DeckHistoryItem[]> {
|
||||||
|
const limit = Math.max(1, Math.min(filter.limit ?? 50, 500));
|
||||||
|
const offset = Math.max(0, filter.offset ?? 0);
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT h.id, h.deck_id, h.word_id, h.timestamp, h.answer, h.before, h.after, h.fsrs,
|
||||||
|
w.key as wkey, w.value as wvalue
|
||||||
|
FROM history h LEFT JOIN words w ON w.id = h.word_id
|
||||||
|
WHERE h.deck_id = ? ORDER BY h.timestamp DESC, h.id DESC LIMIT ? OFFSET ?`,
|
||||||
|
[deckId, limit, offset] as never[],
|
||||||
|
);
|
||||||
|
return (rows as Record<string, unknown>[]).map((r) => ({
|
||||||
|
id: Number(r["id"]),
|
||||||
|
deck_id: Number(r["deck_id"]),
|
||||||
|
word_id: Number(r["word_id"]),
|
||||||
|
timestamp: Number(r["timestamp"] ?? 0),
|
||||||
|
answer: Number(r["answer"] ?? 0),
|
||||||
|
before: Number(r["before"] ?? 0),
|
||||||
|
after: Number(r["after"] ?? 0),
|
||||||
|
fsrs: Number(r["fsrs"] ?? 0),
|
||||||
|
wordKey: r["wkey"] == null ? "" : String(r["wkey"]),
|
||||||
|
wordValue: r["wvalue"] == null ? "" : String(r["wvalue"]),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export interface HistoryEntry {
|
export interface HistoryEntry {
|
||||||
id: number;
|
id: number;
|
||||||
deck_id: number;
|
deck_id: number;
|
||||||
|
|||||||
+7
-1
@@ -1,4 +1,4 @@
|
|||||||
// Страница одной колоды: данные строки decks + количество связанных слов.
|
// Страница одной колоды: данные строки decks + количество связанных слов + ссылки.
|
||||||
// forward / backward / filter — редактируемые, сохраняются в локальную БД.
|
// forward / backward / filter — редактируемые, сохраняются в локальную БД.
|
||||||
// Никакого списка states здесь нет. Навигация: «← Назад к колодам» в список.
|
// Никакого списка states здесь нет. Навигация: «← Назад к колодам» в список.
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ import { hasDatabaseFile } from './opfs.ts';
|
|||||||
export type DeckNav = {
|
export type DeckNav = {
|
||||||
goDecks: () => void;
|
goDecks: () => void;
|
||||||
goReview: (deckId: number) => void;
|
goReview: (deckId: number) => void;
|
||||||
|
goHistory: (deckId: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function fmtOrDash(s: string): string {
|
function fmtOrDash(s: string): string {
|
||||||
@@ -110,6 +111,9 @@ export function renderDeckPage(root: HTMLElement, deckId: number, nav: DeckNav):
|
|||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button id="deck-review" class="btn btn-primary" type="button"${wordCount === 0 ? ' disabled' : ''}>▶ Повторять</button>
|
<button id="deck-review" class="btn btn-primary" type="button"${wordCount === 0 ? ' disabled' : ''}>▶ Повторять</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button id="deck-history" class="btn" type="button">🕘 История ответов</button>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const form = bodyEl.querySelector<HTMLFormElement>('#deck-form')!;
|
const form = bodyEl.querySelector<HTMLFormElement>('#deck-form')!;
|
||||||
@@ -119,8 +123,10 @@ export function renderDeckPage(root: HTMLElement, deckId: number, nav: DeckNav):
|
|||||||
const saveBtn = bodyEl.querySelector<HTMLButtonElement>('#deck-save')!;
|
const saveBtn = bodyEl.querySelector<HTMLButtonElement>('#deck-save')!;
|
||||||
const formStatus = bodyEl.querySelector<HTMLElement>('#deck-form-status')!;
|
const formStatus = bodyEl.querySelector<HTMLElement>('#deck-form-status')!;
|
||||||
const reviewBtn = bodyEl.querySelector<HTMLButtonElement>('#deck-review')!;
|
const reviewBtn = bodyEl.querySelector<HTMLButtonElement>('#deck-review')!;
|
||||||
|
const historyBtn = bodyEl.querySelector<HTMLButtonElement>('#deck-history')!;
|
||||||
|
|
||||||
reviewBtn.addEventListener('click', () => nav.goReview(d.id));
|
reviewBtn.addEventListener('click', () => nav.goReview(d.id));
|
||||||
|
historyBtn.addEventListener('click', () => nav.goHistory(d.id));
|
||||||
|
|
||||||
// Кнопка активна только при отличии от сохранённого.
|
// Кнопка активна только при отличии от сохранённого.
|
||||||
const snapshot = (): string =>
|
const snapshot = (): string =>
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// Страница истории ответов колоды: плоский список записей history.
|
||||||
|
// Свежие первыми, пагинация «Показать ещё». Навигация: назад на страницу колоды.
|
||||||
|
|
||||||
|
import { countDeckHistory, getDeck, getDeckHistory } from './db-types.ts';
|
||||||
|
import type { DeckHistoryItem } from './db-types.ts';
|
||||||
|
import { hasDatabaseFile } from './opfs.ts';
|
||||||
|
import { formatDbDateTime } from './time.ts';
|
||||||
|
|
||||||
|
export type HistoryNav = {
|
||||||
|
goDeck: (deckId: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
const HIST_PAGE = 50;
|
||||||
|
|
||||||
|
// answer 0..3 -> подпись + класс (те же цвета, что кнопки повторения).
|
||||||
|
const GRADE_META: ReadonlyArray<{ label: string; cls: string }> = [
|
||||||
|
{ label: 'Не получилось', cls: 'grade-fail' },
|
||||||
|
{ label: 'Тяжело', cls: 'grade-hard' },
|
||||||
|
{ label: 'Хорошо', cls: 'grade-good' },
|
||||||
|
{ label: 'Легко', cls: 'grade-easy' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function histRowHtml(h: DeckHistoryItem): string {
|
||||||
|
const g = GRADE_META[h.answer] ?? { label: `Оценка ${h.answer}`, cls: '' };
|
||||||
|
const word = h.wordKey
|
||||||
|
? `<span class="word-key">${escapeHtml(h.wordKey)}</span>`
|
||||||
|
+ (h.wordValue ? `<span class="word-sep"> — </span><span class="word-value">${escapeHtml(h.wordValue)}</span>` : '')
|
||||||
|
: `<span class="empty-val">Слово #${h.word_id} (удалено)</span>`;
|
||||||
|
const shift = h.before === h.after ? `${h.before}` : `${h.before}→${h.after}`;
|
||||||
|
return `<div class="hist-row-wrap">`
|
||||||
|
+ `<div class="hist-main">${word}</div>`
|
||||||
|
+ `<div class="hist-meta"><span class="hist-grade ${g.cls}">${escapeHtml(g.label)}</span>`
|
||||||
|
+ `<span class="word-tag">${escapeHtml(formatDbDateTime(h.timestamp))} · score ${escapeHtml(shift)}</span></div>`
|
||||||
|
+ `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderHistoryPage(root: HTMLElement, deckId: number, nav: HistoryNav): void {
|
||||||
|
root.innerHTML = `
|
||||||
|
<button id="hist-back" class="btn btn-ghost" type="button">← Назад к колоде</button>
|
||||||
|
<section class="card" aria-labelledby="hist-title">
|
||||||
|
<h2 id="hist-title" class="card-title">История…</h2>
|
||||||
|
<p id="hist-count" class="card-hint" role="status" aria-live="polite"></p>
|
||||||
|
<div id="hist-status" class="status-bar" role="status">Загрузка истории…</div>
|
||||||
|
<div id="hist-list" class="words-list" aria-live="polite"></div>
|
||||||
|
<div class="btn-row"><button id="hist-more" class="btn" type="button" hidden>Показать ещё</button></div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const backBtn = root.querySelector<HTMLButtonElement>('#hist-back')!;
|
||||||
|
const titleEl = root.querySelector<HTMLElement>('#hist-title')!;
|
||||||
|
const countEl = root.querySelector<HTMLElement>('#hist-count')!;
|
||||||
|
const statusEl = root.querySelector<HTMLElement>('#hist-status')!;
|
||||||
|
const listEl = root.querySelector<HTMLElement>('#hist-list')!;
|
||||||
|
const moreBtn = root.querySelector<HTMLButtonElement>('#hist-more')!;
|
||||||
|
|
||||||
|
backBtn.addEventListener('click', () => nav.goDeck(deckId));
|
||||||
|
|
||||||
|
function setStatus(msg: string): void {
|
||||||
|
statusEl.textContent = msg;
|
||||||
|
statusEl.hidden = msg === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
let offset = 0;
|
||||||
|
let loading = false;
|
||||||
|
|
||||||
|
function refreshMore(): void {
|
||||||
|
const left = total - offset;
|
||||||
|
moreBtn.hidden = left <= 0;
|
||||||
|
if (left > 0) moreBtn.textContent = `Показать ещё (${left} осталось)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPage(): Promise<void> {
|
||||||
|
if (loading || offset >= total) return;
|
||||||
|
loading = true;
|
||||||
|
moreBtn.disabled = true;
|
||||||
|
moreBtn.textContent = 'Загрузка…';
|
||||||
|
try {
|
||||||
|
const items = await getDeckHistory(deckId, { limit: HIST_PAGE, offset });
|
||||||
|
offset += items.length;
|
||||||
|
listEl.insertAdjacentHTML('beforeend', items.map(histRowHtml).join(''));
|
||||||
|
countEl.textContent = `Показано ${offset} из ${total}`;
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`Не удалось загрузить историю: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
moreBtn.disabled = false;
|
||||||
|
refreshMore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
moreBtn.addEventListener('click', () => void loadPage());
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const has = await hasDatabaseFile();
|
||||||
|
if (!has) {
|
||||||
|
titleEl.textContent = 'История недоступна';
|
||||||
|
setStatus('Нет локальной базы — скачай сохранение на странице «Синхронизация».');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const deck = await getDeck(deckId);
|
||||||
|
if (!deck) {
|
||||||
|
titleEl.textContent = 'Колода не найдена';
|
||||||
|
setStatus(`Колоды с id=${deckId} нет в локальной базе.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
titleEl.textContent = `🕘 ${deck.name || `Колода #${deck.id}`}`;
|
||||||
|
total = await countDeckHistory(deck.id);
|
||||||
|
if (total === 0) {
|
||||||
|
countEl.textContent = 'Ответов пока нет — начни повторение.';
|
||||||
|
setStatus('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus('');
|
||||||
|
await loadPage();
|
||||||
|
} catch (err) {
|
||||||
|
titleEl.textContent = 'История недоступна';
|
||||||
|
setStatus(`Не удалось загрузить историю: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
+1
-1
@@ -10,7 +10,7 @@ export type HomeNav = {
|
|||||||
goSync?: () => void;
|
goSync?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PAGE_SIZE = 30;
|
const PAGE_SIZE = 100;
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
function escapeHtml(s: string): string {
|
||||||
return s
|
return s
|
||||||
|
|||||||
+9
-4
@@ -2,19 +2,21 @@ import './style.css';
|
|||||||
import { renderHomePage } from './home-page.ts';
|
import { renderHomePage } from './home-page.ts';
|
||||||
import { renderDecksPage } from './decks-page.ts';
|
import { renderDecksPage } from './decks-page.ts';
|
||||||
import { renderDeckPage } from './deck-page.ts';
|
import { renderDeckPage } from './deck-page.ts';
|
||||||
|
import { renderHistoryPage } from './history-page.ts';
|
||||||
import { renderReviewPage } from './review-page.ts';
|
import { renderReviewPage } from './review-page.ts';
|
||||||
import { renderSyncPage } from './sync-page.ts';
|
import { renderSyncPage } from './sync-page.ts';
|
||||||
import { initWorker } from './sqlite-api.ts';
|
import { initWorker } from './sqlite-api.ts';
|
||||||
|
|
||||||
// Каркас приложения: топ-бар + бургер-меню + страницы (Главная / Колоды / Колода / Повторение / Синхронизация).
|
// Каркас приложения: топ-бар + бургер-меню + страницы (Главная / Колоды / Колода / Повторение / История / Синхронизация).
|
||||||
|
|
||||||
type Page = 'home' | 'decks' | 'deck' | 'review' | 'sync';
|
type Page = 'home' | 'decks' | 'deck' | 'review' | 'history' | 'sync';
|
||||||
|
|
||||||
const PAGE_TITLES: Record<Page, string> = {
|
const PAGE_TITLES: Record<Page, string> = {
|
||||||
home: 'Главная',
|
home: 'Главная',
|
||||||
decks: 'Колоды',
|
decks: 'Колоды',
|
||||||
deck: 'Колода',
|
deck: 'Колода',
|
||||||
review: 'Повторение',
|
review: 'Повторение',
|
||||||
|
history: 'История',
|
||||||
sync: 'Синхронизация',
|
sync: 'Синхронизация',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,8 +51,8 @@ function setActiveButton(page: Page): void {
|
|||||||
|
|
||||||
function renderPage(page: Page, deckId?: number): void {
|
function renderPage(page: Page, deckId?: number): void {
|
||||||
pageTitle.textContent = PAGE_TITLES[page];
|
pageTitle.textContent = PAGE_TITLES[page];
|
||||||
// Страницы колоды и повторения — подразделы «Колод»: в меню подсвечиваем «Колоды».
|
// Колода / повторение / история — подразделы «Колод»: в меню подсвечиваем «Колоды».
|
||||||
setActiveButton(page === 'deck' || page === 'review' ? 'decks' : page);
|
setActiveButton(page === 'deck' || page === 'review' || page === 'history' ? 'decks' : page);
|
||||||
if (page === 'home') {
|
if (page === 'home') {
|
||||||
renderHomePage(pageRoot, {});
|
renderHomePage(pageRoot, {});
|
||||||
} else if (page === 'decks') {
|
} else if (page === 'decks') {
|
||||||
@@ -62,9 +64,12 @@ function renderPage(page: Page, deckId?: number): void {
|
|||||||
renderDeckPage(pageRoot, deckId ?? 0, {
|
renderDeckPage(pageRoot, deckId ?? 0, {
|
||||||
goDecks: () => renderPage('decks'),
|
goDecks: () => renderPage('decks'),
|
||||||
goReview: (id) => renderPage('review', id),
|
goReview: (id) => renderPage('review', id),
|
||||||
|
goHistory: (id) => renderPage('history', id),
|
||||||
});
|
});
|
||||||
} else if (page === 'review') {
|
} else if (page === 'review') {
|
||||||
renderReviewPage(pageRoot, deckId ?? 0, { goDecks: () => renderPage('decks') });
|
renderReviewPage(pageRoot, deckId ?? 0, { goDecks: () => renderPage('decks') });
|
||||||
|
} else if (page === 'history') {
|
||||||
|
renderHistoryPage(pageRoot, deckId ?? 0, { goDeck: (id) => renderPage('deck', id) });
|
||||||
} else {
|
} else {
|
||||||
renderSyncPage(pageRoot);
|
renderSyncPage(pageRoot);
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-20
@@ -1,17 +1,19 @@
|
|||||||
// Страница повторения колоды: карточка за карточкой по случайной выборке.
|
// Страница повторения колоды: карточка за карточкой по случайной выборке.
|
||||||
// Лицевая сторона — поля из deck.forward, по тапу дорисовывается deck.backward.
|
// Лицевая сторона — поля из deck.forward, по тапу дорисовывается deck.backward.
|
||||||
// Внизу 4 кнопки самооценки: "не получилось" / "тяжело" / "хорошо" / "легко".
|
// Внизу 4 кнопки самооценки: "не получилось" / "тяжело" / "хорошо" / "легко".
|
||||||
// Пока без записи ответов: только навигация по очереди (история — следующий шаг).
|
// Ответ пишется в history при нажатии оценки: before/after = score до открытия.
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getCustomFields,
|
getCustomFields,
|
||||||
getDeck,
|
getDeck,
|
||||||
|
getLatestStateScore,
|
||||||
getWordsByIds,
|
getWordsByIds,
|
||||||
|
recordReviewAnswer,
|
||||||
renderCardSide,
|
renderCardSide,
|
||||||
sampleDeckWordIds,
|
sampleDeckWordIds,
|
||||||
wordFieldValues,
|
wordFieldValues,
|
||||||
} from './db-types.ts';
|
} from './db-types.ts';
|
||||||
import type { CustomField, Word } from './db-types.ts';
|
import type { CustomField, ReviewGrade, Word } from './db-types.ts';
|
||||||
import { hasDatabaseFile } from './opfs.ts';
|
import { hasDatabaseFile } from './opfs.ts';
|
||||||
|
|
||||||
export type ReviewNav = {
|
export type ReviewNav = {
|
||||||
@@ -27,12 +29,13 @@ function escapeHtml(s: string): string {
|
|||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
const GRADES = [
|
// Порядок = значение answer в history: 0 — "не получилось", 3 — "легко".
|
||||||
{ label: '😞 Не получилось', cls: 'grade-fail' },
|
const GRADES: ReadonlyArray<{ value: ReviewGrade; label: string; cls: string }> = [
|
||||||
{ label: '😟 Тяжело', cls: 'grade-hard' },
|
{ value: 0, label: '😞 Не получилось', cls: 'grade-fail' },
|
||||||
{ label: '🙂 Хорошо', cls: 'grade-good' },
|
{ value: 1, label: '😟 Тяжело', cls: 'grade-hard' },
|
||||||
{ label: '🤩 Легко', cls: 'grade-easy' },
|
{ value: 2, label: '🙂 Хорошо', cls: 'grade-good' },
|
||||||
] as const;
|
{ value: 3, label: '🤩 Легко', cls: 'grade-easy' },
|
||||||
|
];
|
||||||
|
|
||||||
export function renderReviewPage(root: HTMLElement, deckId: number, nav: ReviewNav): void {
|
export function renderReviewPage(root: HTMLElement, deckId: number, nav: ReviewNav): void {
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
@@ -66,6 +69,11 @@ export function renderReviewPage(root: HTMLElement, deckId: number, nav: ReviewN
|
|||||||
let backwardTpl = '';
|
let backwardTpl = '';
|
||||||
let pos = 0;
|
let pos = 0;
|
||||||
let revealed = false;
|
let revealed = false;
|
||||||
|
// score слова на момент открытия карточки (до ответа). null = ещё грузится/нет states.
|
||||||
|
let beforeScore: number | null = null;
|
||||||
|
// Защита от гонок: paint асинхронный (ждёт score), клики не должны мешать.
|
||||||
|
let paintSeq = 0;
|
||||||
|
let answering = false;
|
||||||
|
|
||||||
function sideHtml(values: string[], cls: string): string {
|
function sideHtml(values: string[], cls: string): string {
|
||||||
if (values.length === 0) {
|
if (values.length === 0) {
|
||||||
@@ -77,14 +85,28 @@ export function renderReviewPage(root: HTMLElement, deckId: number, nav: ReviewN
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function paint(): void {
|
async function paint(): Promise<void> {
|
||||||
const w = words.get(queue[pos]!);
|
const mySeq = ++paintSeq;
|
||||||
|
const wordId = queue[pos]!;
|
||||||
|
const w = words.get(wordId);
|
||||||
if (!w) {
|
if (!w) {
|
||||||
bodyEl.innerHTML = '';
|
bodyEl.innerHTML = '';
|
||||||
progressEl.textContent = '';
|
progressEl.textContent = '';
|
||||||
setStatus('Слово пропало из базы — пропускаю.');
|
setStatus('Слово пропало из базы — пропускаю.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// score ДО открытия: свежий states по (deck_id, word_id). Нет строки — считаем 1
|
||||||
|
// (дефолт схемы states.score DEFAULT 1). Ошибка чтения — тоже 1, но с пометкой.
|
||||||
|
beforeScore = null;
|
||||||
|
let scoreNote = '';
|
||||||
|
try {
|
||||||
|
beforeScore = (await getLatestStateScore(deckId, wordId)) ?? 1;
|
||||||
|
} catch {
|
||||||
|
beforeScore = 1;
|
||||||
|
scoreNote = ' (score прочитать не удалось — пишу before/after=1)';
|
||||||
|
}
|
||||||
|
if (mySeq !== paintSeq) return; // пока ждали score, очередь уже ушла дальше
|
||||||
|
answering = false;
|
||||||
const values = wordFieldValues(w, fields);
|
const values = wordFieldValues(w, fields);
|
||||||
const front = renderCardSide(forwardTpl, values);
|
const front = renderCardSide(forwardTpl, values);
|
||||||
const back = revealed ? renderCardSide(backwardTpl, values) : null;
|
const back = revealed ? renderCardSide(backwardTpl, values) : null;
|
||||||
@@ -96,7 +118,7 @@ export function renderReviewPage(root: HTMLElement, deckId: number, nav: ReviewN
|
|||||||
// Оценки — только после открытия (вместе с backward), до этого лишь тап по карточке.
|
// Оценки — только после открытия (вместе с backward), до этого лишь тап по карточке.
|
||||||
const gradesHtml = revealed
|
const gradesHtml = revealed
|
||||||
? `<div class="review-grades" role="group" aria-label="Оценка повторения">
|
? `<div class="review-grades" role="group" aria-label="Оценка повторения">
|
||||||
${GRADES.map((g) => `<button class="btn review-grade ${g.cls}" type="button">${g.label}</button>`).join('')}
|
${GRADES.map((g) => `<button class="btn review-grade ${g.cls}" type="button" data-grade="${g.value}">${g.label}</button>`).join('')}
|
||||||
</div>`
|
</div>`
|
||||||
: '';
|
: '';
|
||||||
bodyEl.innerHTML = `
|
bodyEl.innerHTML = `
|
||||||
@@ -111,25 +133,46 @@ export function renderReviewPage(root: HTMLElement, deckId: number, nav: ReviewN
|
|||||||
bodyEl.querySelector<HTMLButtonElement>('#review-card')!.addEventListener('click', () => {
|
bodyEl.querySelector<HTMLButtonElement>('#review-card')!.addEventListener('click', () => {
|
||||||
if (!revealed) {
|
if (!revealed) {
|
||||||
revealed = true;
|
revealed = true;
|
||||||
paint();
|
void paint();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
bodyEl.querySelectorAll<HTMLButtonElement>('.review-grade').forEach((btn) => {
|
bodyEl.querySelectorAll<HTMLButtonElement>('.review-grade').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
// Запись ответов (history/states) — следующим шагом, пока просто дальше.
|
const grade = Number(btn.dataset.grade) as ReviewGrade;
|
||||||
pos += 1;
|
void answer(grade, scoreNote);
|
||||||
revealed = false;
|
|
||||||
if (pos >= queue.length) paintDone();
|
|
||||||
else paint();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Нажатие оценки: пишем строку в history и идём дальше. before/after — score
|
||||||
|
// до открытия (пересчёт по ответу добавим позже, пока оба равны beforeScore).
|
||||||
|
async function answer(grade: ReviewGrade, scoreNote: string): Promise<void> {
|
||||||
|
if (answering) return;
|
||||||
|
answering = true;
|
||||||
|
const wordId = queue[pos]!;
|
||||||
|
const before = beforeScore ?? 1;
|
||||||
|
const btns = [...bodyEl.querySelectorAll<HTMLButtonElement>('.review-grade')];
|
||||||
|
for (const b of btns) b.disabled = true;
|
||||||
|
try {
|
||||||
|
await recordReviewAnswer({ deckId, wordId, answer: grade, before, after: before });
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`Ответ не записался: ${err instanceof Error ? err.message : String(err)}${scoreNote}`);
|
||||||
|
answering = false;
|
||||||
|
for (const b of btns) b.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pos += 1;
|
||||||
|
revealed = false;
|
||||||
|
beforeScore = null;
|
||||||
|
if (pos >= queue.length) paintDone();
|
||||||
|
else await paint();
|
||||||
|
}
|
||||||
|
|
||||||
function paintDone(): void {
|
function paintDone(): void {
|
||||||
progressEl.textContent = `Готово: ${queue.length} из ${queue.length}`;
|
progressEl.textContent = `Готово: ${queue.length} из ${queue.length}`;
|
||||||
setStatus('');
|
setStatus('');
|
||||||
bodyEl.innerHTML = `
|
bodyEl.innerHTML = `
|
||||||
<p class="card-hint">Все карточки сессии просмотрены. Запись оценок — следующим шагом.</p>
|
<p class="card-hint">Все карточки сессии просмотрены, ответы записаны в history.</p>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button id="review-again" class="btn btn-primary" type="button">🔀 Перемешать заново</button>
|
<button id="review-again" class="btn btn-primary" type="button">🔀 Перемешать заново</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,7 +194,7 @@ export function renderReviewPage(root: HTMLElement, deckId: number, nav: ReviewN
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
words = await getWordsByIds(queue);
|
words = await getWordsByIds(queue);
|
||||||
paint();
|
void paint();
|
||||||
}
|
}
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -182,7 +225,7 @@ export function renderReviewPage(root: HTMLElement, deckId: number, nav: ReviewN
|
|||||||
words = await getWordsByIds(queue);
|
words = await getWordsByIds(queue);
|
||||||
pos = 0;
|
pos = 0;
|
||||||
revealed = false;
|
revealed = false;
|
||||||
paint();
|
await paint();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
titleEl.textContent = 'Повторение недоступно';
|
titleEl.textContent = 'Повторение недоступно';
|
||||||
setStatus(`Не удалось загрузить карточки: ${err instanceof Error ? err.message : String(err)}`);
|
setStatus(`Не удалось загрузить карточки: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
|||||||
@@ -565,6 +565,43 @@ button.word-row-wrap:not(:disabled):hover {
|
|||||||
border-color: #7c3aed;
|
border-color: #7c3aed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- История ответов колоды: плоские строки, цветы оценок --- */
|
||||||
|
|
||||||
|
.hist-row-wrap {
|
||||||
|
padding: 9px 4px;
|
||||||
|
border-bottom: 1px solid #eee9fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hist-row-wrap:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hist-main {
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hist-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hist-grade {
|
||||||
|
flex: none;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hist-grade.grade-fail,
|
||||||
|
.hist-grade.grade-hard,
|
||||||
|
.hist-grade.grade-good,
|
||||||
|
.hist-grade.grade-easy {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Повторение: карточка + 4 кнопки оценок --- */
|
/* --- Повторение: карточка + 4 кнопки оценок --- */
|
||||||
|
|
||||||
#review-body {
|
#review-body {
|
||||||
|
|||||||
Reference in new issue
Block a user