font update
No files matched your search
@@ -0,0 +1,27 @@
|
||||
.DS_Store
|
||||
.idea
|
||||
.pnpm-store
|
||||
*.cpuprofile
|
||||
*.local
|
||||
*.log
|
||||
/.vscode/
|
||||
/docs/.vitepress/cache
|
||||
/docs/.vitepress/.temp
|
||||
/packages/vite/LICENSE
|
||||
dist
|
||||
dist-ssr
|
||||
explorations
|
||||
node_modules
|
||||
!**/glob-import/root/dir/node_modules
|
||||
!**/fixtures/glob-exports/node_modules
|
||||
!**/fixtures/glob-exports/node_modules/my-pkg/dist
|
||||
!**/fixtures/config/native-compat/json-named-import-bare/node_modules
|
||||
!**/fixtures/config/native-compat/json-named-import-ok/node_modules
|
||||
playground-temp
|
||||
temp
|
||||
TODOs.md
|
||||
.eslintcache
|
||||
|
||||
# runtime-created symlink for glob-import playground tests
|
||||
playground/glob-import/root/follow-symlinks/linked/my-lib
|
||||
jwl.tar.gz
|
||||
@@ -1,43 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#282828" />
|
||||
<link rel="preload" href="/fonts/NotoSansJP-VariableFont_wght.ttf" as="font" type="font/ttf" crossorigin />
|
||||
<title>JapLearn — интервальные повторения</title>
|
||||
<script type="module" crossorigin src="/assets/index-CYVTnbnz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CE-H_FG6.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header class="top-bar">
|
||||
<button id="burger-btn" class="burger-btn" type="button" aria-label="Меню" aria-expanded="false" aria-controls="drawer">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</button>
|
||||
<div class="top-bar-title">
|
||||
<h1>JapLearn</h1>
|
||||
<p id="page-title">Главная</p>
|
||||
</div>
|
||||
<div class="top-bar-spacer"></div>
|
||||
</header>
|
||||
|
||||
<div id="drawer-overlay" class="drawer-overlay" hidden></div>
|
||||
<nav id="drawer" class="drawer" aria-label="Навигация" aria-hidden="true">
|
||||
<div class="drawer-header">
|
||||
<div class="drawer-logo">JapLearn</div>
|
||||
<div class="drawer-subtitle">Интервальные повторения</div>
|
||||
</div>
|
||||
<button class="drawer-btn active" data-page="home" type="button">Главная</button>
|
||||
<button class="drawer-btn" data-page="decks" type="button">Колоды</button>
|
||||
<button class="drawer-btn" data-page="sync" type="button">Синхронизация</button>
|
||||
</nav>
|
||||
|
||||
<div id="page" class="app-content">
|
||||
<!-- Страницы рендерятся сюда -->
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,7 +4,46 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#282828" />
|
||||
<!-- PWA: <link rel="manifest"> и sw.js подставляет vite-plugin-pwa при сборке.
|
||||
Здесь только то, что нужно iOS (у него нет install prompt и он не читает
|
||||
часть полей манифеста) и иконки/сплэши для «на экран Домой». -->
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="JapLearn" />
|
||||
<link rel="icon" href="/icons/favicon-32.png" sizes="32x32" type="image/png" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180.png" />
|
||||
<link rel="preload" href="/fonts/NotoSansJP-VariableFont_wght.ttf" as="font" type="font/ttf" crossorigin />
|
||||
<!-- Стартовые экраны iOS (иконка + название на фоне --bg).
|
||||
Генерируются скриптом tools/gen_pwa_assets.py. -->
|
||||
<link rel="apple-touch-startup-image" href="/splash/iphone-se.png"
|
||||
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/iphone-8-plus.png"
|
||||
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/iphone-x.png"
|
||||
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/iphone-xr.png"
|
||||
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/iphone-xs-max.png"
|
||||
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/iphone-12.png"
|
||||
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/iphone-12-pro-max.png"
|
||||
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/iphone-14-pro.png"
|
||||
media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/iphone-14-pro-max.png"
|
||||
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/iphone-16-pro.png"
|
||||
media="(device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/iphone-16-pro-max.png"
|
||||
media="(device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/ipad-9-7.png"
|
||||
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/ipad-pro-11.png"
|
||||
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/ipad-pro-12-9.png"
|
||||
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" />
|
||||
<title>JapLearn — интервальные повторения</title>
|
||||
</head>
|
||||
<body>
|
||||
@@ -37,6 +76,8 @@
|
||||
<!-- Страницы рендерятся сюда -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- Баннер PWA (установка / новая версия / офлайн). Логика — src/pwa.ts -->
|
||||
<div id="pwa-banner" class="pwa-banner" role="status" aria-live="polite" hidden></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +1 @@
|
||||
@font-face{font-family:Noto Sans JP;src:url(/fonts/NotoSansJP-VariableFont_wght.ttf) format("truetype");font-weight:100 900;font-style:normal;font-display:swap}:root{color-scheme:dark;--bg: #282828;--fg: #fbf1c7;--bg-weakest: #2f2f2f;--bg-weak: #414141;--bg-strong: #4f4f4f;--muted: #908c7c;--secondary: #908c7c;--secondary-strong: #9f9b8d;--on-secondary: #1f1e1a;--primary: #458588;--primary-weak: #3b6c6e;--primary-strong: #49a8ac;--on-primary: #0a1c1d;--success: #98971a;--success-strong: #7c7900;--on-success: #222102;--warning: #d79921;--warning-strong: #b67a00;--on-warning: #342203;--danger: #cc241d;--danger-strong: #ff0000;--on-danger: #fdf9f9;--danger-text: #fb4934;--fg-soft: rgb(251 241 199 / .85);--fg-faint: rgb(251 241 199 / .3);--surface-press: rgb(255 255 255 / .1);--surface-press-soft: rgb(255 255 255 / .08);--space: 16px;--space-half: 8px;--space-quarter: 4px;--radius: 8px;--radius-sm: 12px;--radius-xs: 2px;font-family:Noto Sans JP,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:17px;line-height:1.5;color:var(--fg);background:var(--bg)}*{box-sizing:border-box}body{margin:0;min-height:100dvh;display:flex;justify-content:center;background:var(--bg);color:var(--fg);padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)}::selection{background:var(--primary-weak);color:var(--fg)}#app{width:100%;max-width:480px;padding:var(--space) var(--space) calc(var(--space) * 2);display:flex;flex-direction:column;gap:var(--space)}.top-bar{display:flex;align-items:center;gap:12px}.burger-btn{width:44px;height:44px;border:none;border-radius:var(--radius);background:transparent;color:var(--fg);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:1px;background:currentColor}.burger-btn:active,.burger-btn[aria-expanded=true]{background:var(--surface-press)}.burger-btn[aria-expanded=true] span{background:var(--primary-strong)}.top-bar-title h1{margin:0;font-size:22px;font-weight:700;line-height:1.2}.top-bar-title p{margin:0;font-size:.9rem;color:var(--muted)}.top-bar-spacer{flex:1}.app-content{display:flex;flex-direction:column;gap:var(--space)}.drawer-overlay{position:fixed;inset:0;background:#282828b8;z-index:40}.drawer-overlay[hidden]{display:none}.drawer{position:fixed;top:0;left:0;bottom:0;width:min(300px,85vw);background:var(--bg-weakest);border-right:1px solid var(--bg-weak);z-index:50;transform:translate(-105%);visibility:hidden;transition:transform .22s ease,visibility 0s linear .22s;display:flex;flex-direction:column;gap:var(--space-quarter);padding:var(--space) var(--space-half)}.drawer.open{transform:translate(0);visibility:visible;transition:transform .22s ease,visibility 0s}.drawer-header{padding:var(--space-quarter) var(--space-half) 14px;border-bottom:1px solid var(--bg-strong);margin-bottom:var(--space-half)}.drawer-logo{font-size:22px;font-weight:800;color:var(--primary-strong)}.drawer-subtitle{font-size:.9rem;color:var(--muted)}.drawer-btn{border:none;background:transparent;text-align:left;font:inherit;font-size:1.05rem;font-weight:600;color:var(--fg);padding:13px 12px;border-radius:var(--radius);cursor:pointer;touch-action:manipulation}.drawer-btn:hover,.drawer-btn:active{background:var(--surface-press)}.drawer-btn.active{background:var(--surface-press);color:var(--primary-strong)}body.drawer-open{overflow:hidden}.card{background:var(--bg-weakest);border:1px solid var(--bg-weak);border-radius:var(--radius-sm);padding:var(--space);display:flex;flex-direction:column;gap:12px}.card-title{margin:0;font-size:1.25rem;font-weight:700}.card-hint{margin:0;color:var(--muted);font-size:.95rem}.field-label{font-weight:600;font-size:.95rem}.token-input,.words-search,.words-group{padding:12px;font:inherit;font-size:1rem;color:var(--fg);background:var(--bg);border:1px solid var(--bg-strong);border-radius:var(--radius);outline:none;appearance:none}.token-input::placeholder,.words-search::placeholder{color:var(--muted)}.token-input:focus,.words-search:focus,.words-group:focus{border-color:var(--primary-strong)}.token-input:disabled{background:var(--bg-weak);color:var(--muted)}.token-input{width:100%;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.04em}.token-input.invalid{border-color:var(--danger-text)}.token-meta{display:flex;justify-content:space-between;align-items:baseline;gap:var(--space-half);min-height:1.4em}.token-error{color:var(--danger-text);font-size:.9rem}.token-counter{color:var(--muted);font-variant-numeric:tabular-nums;white-space:nowrap}.btn-row{display:flex;gap:12px}.btn{flex:1;padding:12px 14px;font:inherit;font-size:1rem;font-weight:600;border:1px solid transparent;border-radius:var(--radius);background:var(--secondary);color:var(--on-secondary);cursor:pointer;touch-action:manipulation}.btn:not(:disabled):active{background:var(--secondary-strong)}.btn-primLine truncated
|
||||
@font-face{font-family:Noto Sans JP;src:url(/fonts/NotoSansJP-VariableFont_wght.ttf) format("truetype");font-weight:100 900;font-style:normal;font-display:swap}:root{color-scheme:dark;--bg: #282828;--fg: #fbf1c7;--bg-weakest: #2f2f2f;--bg-weak: #414141;--bg-strong: #4f4f4f;--muted: #908c7c;--secondary: #908c7c;--secondary-strong: #9f9b8d;--on-secondary: #1f1e1a;--primary: #458588;--primary-weak: #3b6c6e;--primary-strong: #49a8ac;--on-primary: #0a1c1d;--success: #98971a;--success-strong: #7c7900;--on-success: #222102;--warning: #d79921;--warning-strong: #b67a00;--on-warning: #342203;--danger: #cc241d;--danger-strong: #ff0000;--on-danger: #fdf9f9;--danger-text: #fb4934;--fg-soft: rgb(251 241 199 / .85);--fg-faint: rgb(251 241 199 / .3);--surface-press: rgb(255 255 255 / .1);--surface-press-soft: rgb(255 255 255 / .08);--space: 16px;--space-half: 8px;--space-quarter: 4px;--radius: 8px;--radius-sm: 12px;--radius-xs: 2px;font-family:Noto Sans JP,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:17px;line-height:1.5;color:var(--fg);background:var(--bg)}*{box-sizing:border-box}body{margin:0;min-height:100dvh;display:flex;justify-content:center;background:var(--bg);color:var(--fg);padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)}::selection{background:var(--primary-weak);color:var(--fg)}#app{width:100%;max-width:480px;padding:var(--space) var(--space) calc(var(--space) * 2);display:flex;flex-direction:column;gap:var(--space)}.top-bar{display:flex;align-items:center;gap:12px}.burger-btn{width:44px;height:44px;border:none;border-radius:var(--radius);background:transparent;color:var(--fg);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:1px;background:currentColor}.burger-btn:active,.burger-btn[aria-expanded=true]{background:var(--surface-press)}.burger-btn[aria-expanded=true] span{background:var(--primary-strong)}.top-bar-title h1{margin:0;font-size:22px;font-weight:700;line-height:1.2}.top-bar-title p{margin:0;font-size:.9rem;color:var(--muted)}.top-bar-spacer{flex:1}.app-content{display:flex;flex-direction:column;gap:var(--space)}.drawer-overlay{position:fixed;inset:0;background:#282828b8;z-index:40}.drawer-overlay[hidden]{display:none}.drawer{position:fixed;top:0;left:0;bottom:0;width:min(300px,85vw);background:var(--bg-weakest);border-right:1px solid var(--bg-weak);z-index:50;transform:translate(-105%);visibility:hidden;transition:transform .22s ease,visibility 0s linear .22s;display:flex;flex-direction:column;gap:var(--space-quarter);padding:var(--space) var(--space-half)}.drawer.open{transform:translate(0);visibility:visible;transition:transform .22s ease,visibility 0s}.drawer-header{padding:var(--space-quarter) var(--space-half) 14px;border-bottom:1px solid var(--bg-strong);margin-bottom:var(--space-half)}.drawer-logo{font-size:22px;font-weight:800;color:var(--primary-strong)}.drawer-subtitle{font-size:.9rem;color:var(--muted)}.drawer-btn{border:none;background:transparent;text-align:left;font:inherit;font-size:1.05rem;font-weight:600;color:var(--fg);padding:13px 12px;border-radius:var(--radius);cursor:pointer;touch-action:manipulation}.drawer-btn:hover,.drawer-btn:active{background:var(--surface-press)}.drawer-btn.active{background:var(--surface-press);color:var(--primary-strong)}body.drawer-open{overflow:hidden}.card{background:var(--bg-weakest);border:1px solid var(--bg-weak);border-radius:var(--radius-sm);padding:var(--space);display:flex;flex-direction:column;gap:12px}.card-title{margin:0;font-size:1.25rem;font-weight:700}.card-hint{margin:0;color:var(--muted);font-size:.95rem}.field-label{font-weight:600;font-size:.95rem}.token-input,.words-search,.words-group{padding:12px;font:inherit;font-size:1rem;color:var(--fg);background:var(--bg);border:1px solid var(--bg-strong);border-radius:var(--radius);outline:none;appearance:none}.token-input::placeholder,.words-search::placeholder{color:var(--muted)}.token-input:focus,.words-search:focus,.words-group:focus{border-color:var(--primary-strong)}.token-input:disabled{background:var(--bg-weak);color:var(--muted)}.token-input{width:100%;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.04em}.token-input.invalid{border-color:var(--danger-text)}.token-meta{display:flex;justify-content:space-between;align-items:baseline;gap:var(--space-half);min-height:1.4em}.token-error{color:var(--danger-text);font-size:.9rem}.token-counter{color:var(--muted);font-variant-numeric:tabular-nums;white-space:nowrap}.btn-row{display:flex;gap:12px}.btn{flex:1;padding:12px 14px;font:inherit;font-size:1rem;font-weight:600;border:1px solid transparent;border-radius:var(--radius);background:var(--secondary);color:var(--on-secondary);cursor:pointer;touch-action:manipulation}.btn:not(:disabled):active{background:var(--secondary-strong)}.btn-primLine truncated
|
||||
@@ -1,7 +1,7 @@
|
||||
(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
|
||||
(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 Qe=new Worker(new URL("/assets/db-worker-DFFhVJkA.js",import.meta.url),{type:"module"});let pr=0,Ae=null;const et=new Map;function On(){return Ae||(Ae=new Promise((e,r)=>{Qe.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")))},Qe.onerror=o=>{console.error("[SQLite Worker] Ошибка:",o),r(new Error("Ошибка Worker"))};const a=++pr;et.set(a,{resolve:e,reject:r}),Qe.postMessage({type:"start",id:a})}),Ae)}function hr(e,r){return new Promise((a,o)=>{if(!Qe){o(new Error("Worker не создан"));return}const s=++pr;et.set(s,{resolve:a,reject:o}),Qe.postMessage({type:e,id:s,...r})})}async function ie(e,r){return await hr("query",{sql:e,params:r??[]})}async function wr(e,r){await hr("command",{sql:e,params:r??[]})}function gr(e){return new Date(e*1e3)}function Jn(){return Math.floor(Date.now()/1e3)}function Kn(e){const r=gr(e),a=String(r.getDate()).padStart(2,"0"),o=String(r.getMonth()+1).padStart(2,"0");return`${a}.${o}.${r.getFullYear()}`}function Dn(e){const r=gr(e),a=String(r.getHours()).padStart(2,"0"),o=String(r.getMinutes()).padStart(2,"0");return`${Kn(e)} ${a}:${o}`}function Xn(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 zn(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 mr(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:Xn(s),more:zn(e.more),group_id:Number.isFinite(c)?c:1}}function vr(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 Bn(e={}){const{clause:r,params:a}=vr(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}=vr(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(mr)}async function Vn(){return(await ie("SELECT id, name FROM word_groups ORDER BY id")).map(r=>({id:Number(r.id),name:String(r.name??"")}))}async function Yn(){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 Zn(e,r){return await wr("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 Gn(){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 jn(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(","),h=await ie(`SELECT id, key, value, tags, more, group_id FROM words WHERE id IN (${u})`,c);for(const v of h.map(mr))r.set(v.id,v)}return r}function Wn(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
|
||||
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 na(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 aa(){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 Oe(){try{return(await(await(await xt()).getFileHandle(Ut)).getFile()).size>0}catch{return!1}}var sa=ArrayBuffer,G=Uint8Array,tt=Uint16Array,ia=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},oa=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++]},ca=["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||ca[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},la=function(e,r){return(e[r]|e[r+1]<<8|e[r+2]<<16|e[r+3]<<24)>>>0},ua=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,h=o>>6;o&8&&A(0);var v=6-s,p=u==3?4:u,y=sr(e,v,p);v+=p;var C=h?1<<h:s,k=sr(e,v,C)+(h==1&&256),E=k;if(!s){var $=1<<10+(e[5]>>3);E=$+($>>3)*(e[5]&7)}E>2145386496&&A(1);var w=new G((r==1?k||E:r?0:E)+12);return w[0]=1,w[4]=4,w[8]=8,{b:v+C,y:0,l:0,d:y,w:r&&r!=1?r:w.subarray(12),e:E,o:new at(w.buffer,0,3),u:k,c,m:Math.min(131072,E)}}else if((a>>4|e[3]<<20)==25481893)return la(e,4)+8;A(0)},Ce=function(e){for(var r=0;1<<r<=e;++r);return r-1},Je=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,h=-1,v=-1,p=-1,y=c,C=new sa(512+(c<<2)),k=new ia(C,0,256),E=new tt(C,0,256),$=new tt(C,512,c),w=512+(c<<1),U=new G(C,w,c),I=new G(C,w+c);h<255&&u>0;){var O=Ce(u+1),H=o>>3,z=(1<<O+1)-1,R=(e[H]|e[H+1]<<8|e[H+2]<<16)>>(o&7)&z,x=(1<<O)-1,F=z-u-1,Q=R&x;if(Q<F?(o+=O,R=Q):(o+=O+1,R>x&&(R-=F)),k[++h]=--R,R==-1?(u+=R,U[--y]=h):u-=R,!R)do{var q=o>>3;v=(e[q]|e[q+1]<<8)>>(o&7)&3,o+=2,h+=v}while(v==3)}(h>255||u)&&A(0);for(var X=0,j=(c>>1)+(c>>3)+3,V=c-1,B=0;B<=h;++B){var L=k[B];if(L<1){E[B]=-L;continue}for(p=0;p<L;++p){U[X]=B;do X=X+j&V;while(X>=y)}}for(X&&A(0),p=0;p<c;++p){var b=E[U[p]]++,S=I[p]=s-Ce(b);$[p]=(b<<S)-c}return[o+7>>3,{b:s,s:U,n:I,t:$}]},da=function(e,r){var a=0,o=-1,s=new G(292),c=e[r],u=s.subarray(0,256),h=s.subarray(256,268),v=new tt(s.buffer,268);if(c<128){var p=Je(e,r+1,6),y=p[0],C=p[1];r+=c;var k=y<<3,E=e[r];E||A(0);for(var $=0,w=0,U=C.b,I=U,O=(++r<<3)-8+Ce(E);O-=U,!(O<k);){var H=O>>3;if($+=(e[H]|e[H+1]<<8)>>(O&7)&(1<<U)-1,u[++o]=C.s[$],O-=I,O<k)break;H=O>>3,w+=(e[H]|e[H+1]<<8)>>(O&7)&(1<<I)-1,u[++o]=C.s[w],U=C.n[$],$=C.t[$],I=C.n[w],w=C.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 R=0;for(a=0;a<o;++a){var x=u[a];x>11&&A(0),R+=x&&1<<x-1}var F=Ce(R)+1,Q=1<<F,q=Q-R;for(q&q-1&&A(0),u[o++]=Ce(q)+1,a=0;a<o;++a){var x=u[a];++h[u[a]=x&&F+1-x]}var X=new G(Q<<1),j=X.subarray(0,Q),V=X.subarray(Q);for(v[F]=0,a=F;a>0;--a){var B=v[a];Ie(V,a,B,v[a-1]=B+h[a]*(1<<F-a))}for(v[0]!=Q&&A(0),a=0;a<o;++a){var L=u[a];if(L){var b=v[L];Ie(j,a,b,v[L]=b+(1<<F-L))}}return[r,{n:V,b:F,s:j}]},fa=Je(new G([81,16,99,140,49,198,24,99,12,33,196,24,99,102,102,134,70,146,4]),0,6)[1],pa=Je(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],ha=Je(new G([32,132,16,66,102,70,68,68,68,68,36,73,2]),0,5)[1],br=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),wa=br(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>
|
||||
@@ -20,47 +20,47 @@
|
||||
<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
|
||||
`;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"),h=e.querySelector("#words-count"),v=e.querySelector("#words-status"),p=e.querySelector("#words-list"),y=e.querySelector("#words-more");function C(b,S){a.hidden=!1,o.textContent=b,s.hidden=!1}function k(){a.hidden=!0}s.addEventListener("click",()=>{document.querySelector('.drawer-btn[data-page="sync"]')?.click()});let E=!1,$=new Map,w=0,U=[],I=null,O=0,H=!1,z=0,R=0;function x(){const b=u.value;if(b==="")return{search:c.value.trim(),groupId:null};const S=Number(b);return{search:c.value.trim(),groupId:Number.isFinite(S)?S:null}}function F(){y.hidden=!(E&&U.length<w),y.hidden||(y.textContent=`Показать ещё (${w-U.length} осталось)`)}function Q(){p.innerHTML=U.map(b=>or(b,$,b.id===I)).join(""),h.textContent=w>0?`Показано ${U.length} из ${w}`:"",F()}function q(b){if(I===b){b!=null&&X(b),I=null;return}I!=null&&X(I),I=b,b!=null&&j(b)}function X(b){const S=p.querySelector(`.word-row-wrap[data-word-id="${b}"]`);if(!S)return;const T=S.querySelector(".word-row"),P=S.querySelector(".word-extra");T&&(T.classList.remove("open"),T.setAttribute("aria-expanded","false")),P&&(P.hidden=!0,P.innerHTML="")}function j(b){const S=U.find(he=>he.id===b),T=p.querySelector(`.word-row-wrap[data-word-id="${b}"]`);if(!S||!T)return;const P=T.querySelector(".word-row"),J=T.querySelector(".word-extra");P&&(P.classList.add("open"),P.setAttribute("aria-expanded","true")),J&&(J.hidden=!1,J.innerHTML=Er(S,$))}function V(b){v.textContent=b,v.hidden=b===""}async function B(){if(!E||H)return;H=!0;const b=++z,S=x();y.disabled=!0,U.length===0&&V("Загрузка слов…");try{const[T,P]=await Promise.all([Bn({search:S.search,groupId:S.groupId??void 0}),tr({search:S.search,groupId:S.groupId??void 0,limit:ir,offset:0})]);if(b!==z)return;w=T,U=P,O=P.length,I!=null&&!U.some(J=>J.id===I)&&(I=null),Q(),V(P.length===0?"По этому запросу слов нет.":""),P.length===0&&w===0&&(h.textContent="Слов в базе пока нет.")}catch(T){if(b!==z)return;V(`Не удалось загрузить слова: ${T instanceof Error?T.message:String(T)}`)}finally{b===z&&(H=!1,y.disabled=!1,F())}}async function L(){if(!E||H||U.length>=w)return;H=!0;const b=++z,S=x();y.disabled=!0,y.textContent="Загрузка…";try{const T=await tr({search:S.search,groupId:S.groupId??void 0,limit:ir,offset:O});if(b!==z)return;U=U.concat(T),O+=T.length,p.insertAdjacentHTML("beforeend",T.map(P=>or(P,$,P.id===I)).join("")),h.textContent=`Показано ${U.length} из ${w}`,F()}catch(T){if(b!==z)return;V(`Не удалось догрузить: ${T instanceof Error?T.message:String(T)}`)}finally{b===z&&(H=!1,y.disabled=!1,F())}}c.addEventListener("input",()=>{window.clearTimeout(R),R=window.setTimeout(()=>{B()},300)}),c.addEventListener("keydown",b=>{b.key==="Enter"&&(window.clearTimeout(R),B())}),u.addEventListener("change",()=>{B()}),y.addEventListener("click",()=>{L()}),p.addEventListener("click",b=>{const S=b.target.closest(".word-row");if(!S||S.disabled)return;const T=Number(S.dataset.wordId);Number.isFinite(T)&&q(I===T?null:T)}),(async()=>{if(!await Oe()){C("Локальной базы нет — скачай сохранение."),V("Нет локальной базы — скачай сохранение на странице «Синхронизация»."),h.textContent="";return}try{const S=await aa();if(!$t(S)){C("Файл в OPFS не похож на SQLite — перекачай сохранение.",!0),V("Файл в OPFS не похож на SQLite — перекачай сохранение.");return}k();try{const[P,J]=await Promise.all([Vn(),yr()]),he=P;$=new Map(J.map(ue=>[ue.id,ue])),u.innerHTML='<option value="">Все группы</option>'+he.map(ue=>`<option value="${ue.id}">${Ee(ue.name)}</option>`).join(""),E=!0,await B(),u.options[0].textContent=`Все группы (${w})`}catch(P){V(`БД есть, но слова не прочитались: ${P instanceof Error?P.message:String(P)}`)}}catch(S){C(`Не удалось прочитать базу: ${S instanceof Error?S.message:String(S)}`)}})()}function cr(e){return e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}function Fa(e){const r=e%10,a=e%100;return r===1&&a!==11?"запись":r>=2&&r<=4&&(a<12||a>14)?"записи":"записей"}function Pa(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} ${Fa(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=`
|
||||
`;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 h=u.target.closest("[data-act]");if(!h)return;const v=Number(h.dataset.deckId);Number.isFinite(v)&&(h.dataset.act==="review"?r.goReview(v):r.goDeck(v))}),(async()=>{if(!await Oe()){c("Нет локальной базы — скачай сохранение на странице «Синхронизация»."),a.textContent="";return}c("Загрузка колод…");try{const[h,v]=await Promise.all([Yn(),Gn()]);if(h.length===0){a.textContent="Колоды пока отсутствуют.",c(""),s.innerHTML="";return}a.textContent=`Всего: ${h.length}`,c(""),s.innerHTML=h.map(p=>Pa(p,v.get(p.id)??0)).join("")}catch(h){c(`Не удалось загрузить колоды: ${h instanceof Error?h.message:String(h)}`)}})()}function Qa(e){return e?Ia(e):'<span class="empty-val">—</span>'}function Ia(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 Oa(e){const r=e%10,a=e%100;return r===1&&a!==11?"слово":r>=2&&r<=4&&(a<12||a>14)?"слова":"слов"}function Ja(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=`
|
||||
`;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 Oe()){s.textContent="Колода недоступна",c.textContent="Нет локальной базы — скачай сохранение на странице «Синхронизация».";return}let v=null;try{v=await nt(r)}catch(x){s.textContent="Колода недоступна",c.textContent=`Не удалось загрузить колоду: ${x instanceof Error?x.message:String(x)}`;return}if(!v){s.textContent="Колода не найдена",c.textContent=`Колоды с id=${r} нет в локальной базе. Возможно, база обновилась — вернись к списку.`;return}const p=v;s.textContent=p.name||`Колода #${p.id}`,c.hidden=!0;let y=null;try{y=await jn(p.id)}catch(x){c.hidden=!1,c.textContent=`Данные колоды загружены, а количество слов — нет: ${x instanceof Error?x.message:String(x)}`}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>
|
||||
<div class="word-extra-row"><dt>id</dt><dd>${p.id}</dd></div>
|
||||
<div class="word-extra-row"><dt>name</dt><dd>${Qa(p.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)}" />
|
||||
<input id="deck-forward" class="token-input" type="text" value="${bt(p.forward)}" />
|
||||
<label class="field-label" for="deck-backward">backward</label>
|
||||
<input id="deck-backward" class="token-input" type="text" value="${bt(m.backward)}" />
|
||||
<input id="deck-backward" class="token-input" type="text" value="${bt(p.backward)}" />
|
||||
<label class="field-label" for="deck-filter">filter</label>
|
||||
<input id="deck-filter" class="token-input" type="text" value="${bt(m.filter)}" />
|
||||
<input id="deck-filter" class="token-input" type="text" value="${bt(p.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>
|
||||
<h3 class="deck-sub">${y==null?"Количество слов неизвестно":`${y} ${Oa(y)}`}</h3>
|
||||
<div class="btn-row">
|
||||
<button id="deck-review" class="btn btn-primary" type="button"${b===0?" disabled":""}>Повторять</button>
|
||||
<button id="deck-review" class="btn btn-primary" type="button"${y===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=`
|
||||
`;const C=u.querySelector("#deck-form"),k=u.querySelector("#deck-forward"),E=u.querySelector("#deck-backward"),$=u.querySelector("#deck-filter"),w=u.querySelector("#deck-save"),U=u.querySelector("#deck-form-status"),I=u.querySelector("#deck-review"),O=u.querySelector("#deck-history");I.addEventListener("click",()=>a.goReview(p.id)),O.addEventListener("click",()=>a.goHistory(p.id));const H=()=>`${k.value}
|
||||
${E.value}
|
||||
${$.value}`;let z=H();function R(){w.disabled=H()===z||w.dataset.busy==="1"}for(const x of[k,E,$])x.addEventListener("input",R);R(),C.addEventListener("submit",x=>{x.preventDefault(),!w.disabled&&(w.dataset.busy="1",w.disabled=!0,w.textContent="Сохранение…",U.textContent="",(async()=>{try{const F=await Zn(p.id,{forward:k.value,backward:E.value,filter:$.value});if(!F){U.textContent="Колода пропала из базы — вернись к списку.";return}k.value=F.forward,E.value=F.backward,$.value=F.filter,z=H(),U.textContent="Сохранено в локальную базу."}catch(F){U.textContent=`Не удалось сохранить: ${F instanceof Error?F.message:String(F)}`}finally{delete w.dataset.busy,w.textContent="Сохранить",R()}})())})})()}function Fe(e){return e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}const Ka=50,Da=[{label:"Не получилось",cls:"grade-fail"},{label:"Тяжело",cls:"grade-hard"},{label:"Хорошо",cls:"grade-good"},{label:"Легко",cls:"grade-easy"}];function Xa(e){const r=Da[e.answer]??{label:`Оценка ${e.answer}`,cls:""},a=e.wordKey?`<span class="word-key">${Fe(e.wordKey)}</span>`+(e.wordValue?`<span class="word-sep"> — </span><span class="word-value">${Fe(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}">${Fe(r.label)}</span><span class="word-tag">${Fe(Dn(e.timestamp))} · score ${Fe(o)}</span></div></div>`}function za(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>
|
||||
@@ -69,7 +69,7 @@ ${N.value}`;let z=L();function P(){w.disabled=L()===z||w.dataset.busy==="1"}for(
|
||||
<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=`
|
||||
`;const o=e.querySelector("#hist-back"),s=e.querySelector("#hist-title"),c=e.querySelector("#hist-count"),u=e.querySelector("#hist-status"),h=e.querySelector("#hist-list"),v=e.querySelector("#hist-more");o.addEventListener("click",()=>a.goDeck(r));function p(w){u.textContent=w,u.hidden=w===""}let y=0,C=0,k=!1;function E(){const w=y-C;v.hidden=w<=0,w>0&&(v.textContent=`Показать ещё (${w} осталось)`)}async function $(){if(!(k||C>=y)){k=!0,v.disabled=!0,v.textContent="Загрузка…";try{const w=await ra(r,{limit:Ka,offset:C});C+=w.length,h.insertAdjacentHTML("beforeend",w.map(Xa).join("")),c.textContent=`Показано ${C} из ${y}`}catch(w){p(`Не удалось загрузить историю: ${w instanceof Error?w.message:String(w)}`)}finally{k=!1,v.disabled=!1,E()}}}v.addEventListener("click",()=>{$()}),(async()=>{if(!await Oe()){s.textContent="История недоступна",p("Нет локальной базы — скачай сохранение на странице «Синхронизация».");return}try{const U=await nt(r);if(!U){s.textContent="Колода не найдена",p(`Колоды с id=${r} нет в локальной базе.`);return}if(s.textContent=U.name||`Колода #${U.id}`,y=await ta(U.id),y===0){c.textContent="Ответов пока нет — начни повторение.",p("");return}p(""),await $()}catch(U){s.textContent="История недоступна",p(`Не удалось загрузить историю: ${U instanceof Error?U.message:String(U)}`)}})()}function Ba(e){return e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}const Va=[{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 Ya(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>
|
||||
@@ -77,22 +77,22 @@ ${N.value}`;let z=L();function P(){w.disabled=L()===z||w.dataset.busy==="1"}for(
|
||||
<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=`
|
||||
`;const o=e.querySelector("#review-back"),s=e.querySelector("#review-title"),c=e.querySelector("#review-progress"),u=e.querySelector("#review-status"),h=e.querySelector("#review-body");o.addEventListener("click",()=>a.goDecks());function v(Q){u.textContent=Q,u.hidden=Q===""}let p=[],y=new Map,C=[],k="",E="",$=0,w=!1,U=null,I=0,O=!1;function H(Q,q){return Q.length===0?`<div class="review-side ${q}"><span class="empty-val">—</span></div>`:`<div class="review-side ${q}">`+Q.map(X=>`<p class="review-line">${Ba(X)}</p>`).join("")+"</div>"}async function z(){const Q=++I,q=p[$],X=y.get(q);if(!X){h.innerHTML="",c.textContent="",v("Слово пропало из базы — пропускаю.");return}U=null;let j="";try{U=await An(r,q)??1}catch{U=1,j=" (score прочитать не удалось — пишу before/after=1)"}if(Q!==I)return;O=!1;const V=Wn(X,C),B=nr(k,V),L=w?nr(E,V):null;c.textContent=`Карточка ${$+1} из ${p.length}`,v("");const b=w?H(L??[],"review-back-side"):'<p class="review-tap">Тап — показать ответ</p>',S=w?`<div class="review-grades" role="group" aria-label="Оценка повторения">
|
||||
${Va.map(T=>`<button class="btn review-grade ${T.cls}" type="button" data-grade="${T.value}">${T.label}</button>`).join("")}
|
||||
</div>`:"";h.innerHTML=`
|
||||
<button id="review-card" class="review-card" type="button"
|
||||
aria-expanded="${w}" aria-label="${w?"Карточка открыта":"Показать ответ"}">
|
||||
${L(B,"review-front")}
|
||||
${H(B,"review-front")}
|
||||
${w?'<hr class="review-hr" />':""}
|
||||
${v}
|
||||
${b}
|
||||
</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=`
|
||||
${S}
|
||||
`,h.querySelector("#review-card").addEventListener("click",()=>{w||(w=!0,z())}),h.querySelectorAll(".review-grade").forEach(T=>{T.addEventListener("click",()=>{const P=Number(T.dataset.grade);R(P,j)})})}async function R(Q,q){if(O)return;O=!0;const X=p[$],j=U??1,V=[...h.querySelectorAll(".review-grade")];for(const B of V)B.disabled=!0;try{await ea({deckId:r,wordId:X,answer:Q,before:j,after:j})}catch(B){v(`Ответ не записался: ${B instanceof Error?B.message:String(B)}${q}`),O=!1;for(const L of V)L.disabled=!1;return}$+=1,w=!1,U=null,$>=p.length?x():await z()}function x(){c.textContent=`Готово: ${p.length} из ${p.length}`,v(""),h.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=`
|
||||
`,h.querySelector("#review-again").addEventListener("click",()=>{F()})}async function F(){if(v("Перемешиваю…"),h.innerHTML="",c.textContent="",p=await ar(r),$=0,w=!1,p.length===0){v("В колоде нет слов для повторения.");return}y=await rr(p),z()}(async()=>{if(!await Oe()){s.textContent="Повторение недоступно",v("Нет локальной базы — скачай сохранение на странице «Синхронизация».");return}try{const[q,X]=await Promise.all([nt(r),yr()]);if(!q){s.textContent="Колода не найдена",v(`Колоды с id=${r} нет в локальной базе. Вернись к списку.`);return}if(C=X,k=q.forward,E=q.backward,s.textContent=q.name||`Колода #${q.id}`,p=await ar(q.id),p.length===0){c.textContent="",v("В колоде нет слов для повторения.");return}y=await rr(p),$=0,w=!1,await z()}catch(q){s.textContent="Повторение недоступно",v(`Не удалось загрузить карточки: ${q instanceof Error?q.message:String(q)}`)}})()}const lr=24,Za=/^[A-Za-z0-9]{24}$/,Cr="japlearn_sync_token";function ur(e){return e.trim().replace(/\s+/g,"")}function Pe(e){return Za.test(e)}function Ga(){try{return localStorage.getItem(Cr)??""}catch{return""}}function dr(e){try{localStorage.setItem(Cr,e)}catch{}}const $r="https://learning.micialware.ru";function ja(e,r=$r){return`${r.replace(/\/+$/,"")}/download/${encodeURIComponent(e)}`}async function Wa(e,r,a=$r){const o=ja(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 Aa(e){e.innerHTML=`
|
||||
<main class="card" aria-labelledby="sync-title">
|
||||
<h2 id="sync-title" class="card-title">Синхронизация</h2>
|
||||
<p class="card-hint">Токен сохранения — 24 символа: латинские буквы и цифры.</p>
|
||||
@@ -123,4 +123,4 @@ ${N.value}`;let z=L();function P(){w.disabled=L()===z||w.dataset.busy==="1"}for(
|
||||
|
||||
<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);
|
||||
`;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 h(){return ur(r.value)}function v(k){s.disabled=k||!Pe(h()),r.disabled=k}function p(){const k=h(),E=k.length;o.textContent=`${E} / ${lr}`;const $=Pe(k),w=E===0;r.classList.toggle("invalid",!w&&!$),r.setAttribute("aria-invalid",String(!w&&!$)),w?a.textContent="":E!==lr?a.textContent=`Нужно 24 символа, сейчас ${E}`:a.textContent=$?"":"Только латинские буквы и цифры",s.disabled=!$}async function y(){const k=h();if(Pe(k)){dr(k),v(!0),u("Скачивание сохранения...");try{const E=await Wa(k);u(`Скачано ${(E.length/1024).toFixed(1)} КБ, распаковка...`);const $=await Ha(E);u(`Распаковано ${($.length/1024).toFixed(1)} КБ, сохранение в OPFS...`),await na($),u(`Готово: база ${($.length/1024).toFixed(1)} КБ сохранена локально (OPFS).`)}catch(E){u(`Ошибка скачивания: ${E instanceof Error?E.message:String(E)}`)}finally{v(!1),p()}}}r.addEventListener("input",()=>{p()}),r.addEventListener("change",()=>{const k=h();r.value=k,Pe(k)&&(dr(k),u("Токен сохранён локально. Можно скачивать.")),p()}),s.addEventListener("click",()=>{y()});const C=Ga();C&&(r.value=C),p(),Pe(ur(C))&&u("Токен восстановлен. Нажми «Скачать», чтобы получить базу.")}const es="modulepreload",ts=function(e){return"/"+e},fr={},rs=function(r,a,o){let s=Promise.resolve();if(a&&a.length>0){let v=function(p){return Promise.all(p.map(y=>Promise.resolve(y).then(C=>({status:"fulfilled",value:C}),C=>({status:"rejected",reason:C}))))};document.getElementsByTagName("link");const u=document.querySelector("meta[property=csp-nonce]"),h=u?.nonce||u?.getAttribute("nonce");s=v(a.map(p=>{if(p=ts(p),p in fr)return;fr[p]=!0;const y=p.endsWith(".css"),C=y?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${p}"]${C}`))return;const k=document.createElement("link");if(k.rel=y?"stylesheet":es,y||(k.as="script"),k.crossOrigin="",k.href=p,h&&k.setAttribute("nonce",h),document.head.appendChild(k),y)return new Promise((E,$)=>{k.addEventListener("load",E),k.addEventListener("error",()=>$(new Error(`Unable to preload CSS for ${p}`)))})}))}function c(u){const h=new Event("vite:preloadError",{cancelable:!0});if(h.payload=u,window.dispatchEvent(h),!h.defaultPrevented)throw u}return s.then(u=>{for(const h of u||[])h.status==="rejected"&&c(h.reason);return r().catch(c)})};function ns(e={}){const{immediate:r=!1,onNeedReload:a,onNeedRefresh:o,onOfflineReady:s,onRegistered:c,onRegisteredSW:u,onRegisterError:h}=e;let v,p,y;const C=async(E=!0)=>{await p,y?.()};async function k(){if("serviceWorker"in navigator){if(v=await rs(async()=>{const{Workbox:E}=await import("./workbox-window.prod.es5-BBnX5xw4.js");return{Workbox:E}},[]).then(({Workbox:E})=>new E("/sw.js",{scope:"/",type:"classic"})).catch(E=>{h?.(E)}),!v)return;y=()=>{v?.messageSkipWaiting()};{let E=!1;const $=()=>{E=!0,v?.addEventListener("controlling",w=>{w.isUpdate&&(a?a():window.location.reload())}),o?.()};v.addEventListener("installed",w=>{typeof w.isUpdate>"u"?typeof w.isExternal<"u"&&w.isExternal?$():!E&&s?.():w.isUpdate||s?.()}),v.addEventListener("waiting",$)}v.register({immediate:r}).then(E=>{u?u("/sw.js",E):c?.(E)}).catch(E=>{h?.(E)})}}return p=k(),C}const Ur="japlearn_ios_install_hint_seen";function as(){const e=navigator;return window.matchMedia("(display-mode: standalone)").matches||e.standalone===!0}function ss(){const e=navigator.userAgent;return(/iPhone|iPad|iPod/.test(e)||navigator.platform==="MacIntel"&&navigator.maxTouchPoints>1)&&/WebKit/.test(e)&&!/CriOS|FxiOS|EdgiOS|OPiOS/.test(e)}function is(){try{return localStorage.getItem(Ur)==="1"}catch{return!0}}function os(){const e=document.getElementById("pwa-banner");if(!e)return;let r=null,a=null,o=!1;function s(){e.hidden=!0,e.replaceChildren()}function c(p,y){const C=document.createElement("span");C.className="pwa-banner-text",C.textContent=p;const k=document.createElement("div");k.className="pwa-actions";for(const E of y){const $=document.createElement("button");$.type="button",$.className=E.primary?"btn btn-primary pwa-btn":"btn pwa-btn",$.textContent=E.label,$.addEventListener("click",E.onClick),k.append($)}e.replaceChildren(C,k),e.hidden=!1}function u(){if(r){c("Доступна новая версия приложения",[{label:"Обновить",primary:!0,onClick:()=>{r?.()}},{label:"Позже",onClick:()=>{r=null,u()}}]);return}if(a){c("Установить JapLearn на домашний экран?",[{label:"Установить",primary:!0,onClick:()=>{h()}},{label:"Не сейчас",onClick:()=>{a=null,u()}}]);return}if(ss()&&!as()&&!is()){c("На iPhone: «Поделитьс
Line truncated
|
||||
@@ -0,0 +1,2 @@
|
||||
try{self["workbox:window:7.4.0"]&&_()}catch{}function b(t,r){return new Promise((function(n){var u=new MessageChannel;u.port1.onmessage=function(s){n(s.data)},t.postMessage(r,[u.port2])}))}function P(t,r){(r==null||r>t.length)&&(r=t.length);for(var n=0,u=Array(r);n<r;n++)u[n]=t[n];return u}function j(t,r,n){return r&&(function(u,s){for(var c=0;c<s.length;c++){var o=s[c];o.enumerable=o.enumerable||!1,o.configurable=!0,"value"in o&&(o.writable=!0),Object.defineProperty(u,W(o.key),o)}})(t.prototype,r),Object.defineProperty(t,"prototype",{writable:!1}),t}function S(t,r){var n=typeof Symbol<"u"&&t[Symbol.iterator]||t["@@iterator"];if(n)return(n=n.call(t)).next.bind(n);if(Array.isArray(t)||(n=(function(s,c){if(s){if(typeof s=="string")return P(s,c);var o={}.toString.call(s).slice(8,-1);return o==="Object"&&s.constructor&&(o=s.constructor.name),o==="Map"||o==="Set"?Array.from(s):o==="Arguments"||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(o)?P(s,c):void 0}})(t))||r){n&&(t=n);var u=0;return function(){return u>=t.length?{done:!0}:{done:!1,value:t[u++]}}}throw new TypeError(`Invalid attempt to iterate non-iterable instance.
|
||||
In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}function w(t,r){return w=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(n,u){return n.__proto__=u,n},w(t,r)}function W(t){var r=(function(n,u){if(typeof n!="object"||!n)return n;var s=n[Symbol.toPrimitive];if(s!==void 0){var c=s.call(n,u);if(typeof c!="object")return c;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(n)})(t,"string");return typeof r=="symbol"?r:r+""}try{self["workbox:core:7.4.0"]&&_()}catch{}var m=function(){var t=this;this.promise=new Promise((function(r,n){t.resolve=r,t.reject=n}))};function y(t,r){var n=location.href;return new URL(t,n).href===new URL(r,n).href}var d=function(t,r){this.type=t,Object.assign(this,r)};function l(t,r,n){return t&&t.then||(t=Promise.resolve(t)),r?t.then(r):t}function k(){}var L={type:"SKIP_WAITING"};function E(t,r){return t&&t.then?t.then(k):Promise.resolve()}var O=(function(t){function r(c,o){var e,i;return o===void 0&&(o={}),(e=t.call(this)||this).nn={},e.tn=0,e.rn=new m,e.en=new m,e.on=new m,e.un=0,e.an=new Set,e.cn=function(){var f=e.fn,a=f.installing;e.tn>0||!y(a.scriptURL,e.sn.toString())||performance.now()>e.un+6e4?(e.vn=a,f.removeEventListener("updatefound",e.cn)):(e.hn=a,e.an.add(a),e.rn.resolve(a)),++e.tn,a.addEventListener("statechange",e.ln)},e.ln=function(f){var a=e.fn,v=f.target,h=v.state,p=v===e.vn,g={sw:v,isExternal:p,originalEvent:f};!p&&e.mn&&(g.isUpdate=!0),e.dispatchEvent(new d(h,g)),h==="installed"?e.wn=self.setTimeout((function(){h==="installed"&&a.waiting===v&&e.dispatchEvent(new d("waiting",g))}),200):h==="activating"&&(clearTimeout(e.wn),p||e.en.resolve(v))},e.yn=function(f){var a=e.hn,v=a!==navigator.serviceWorker.controller;e.dispatchEvent(new d("controlling",{isExternal:v,originalEvent:f,sw:a,isUpdate:e.mn})),v||e.on.resolve(a)},e.gn=(i=function(f){var a=f.data,v=f.ports,h=f.source;return l(e.getSW(),(function(){e.an.has(h)&&e.dispatchEvent(new d("message",{data:a,originalEvent:f,ports:v,sw:h}))}))},function(){for(var f=[],a=0;a<arguments.length;a++)f[a]=arguments[a];try{return Promise.resolve(i.apply(this,f))}catch(v){return Promise.reject(v)}}),e.sn=c,e.nn=o,navigator.serviceWorker.addEventListener("message",e.gn),e}var n,u;u=t,(n=r).prototype=Object.create(u.prototype),n.prototype.constructor=n,w(n,u);var s=r.prototype;return s.register=function(c){var o=(c===void 0?{}:c).immediate,e=o!==void 0&&o;try{var i=this;return l((function(f,a){var v=f();return v&&v.then?v.then(a):a(v)})((function(){if(!e&&document.readyState!=="complete")return E(new Promise((function(f){return window.addEventListener("load",f)})))}),(function(){return i.mn=!!navigator.serviceWorker.controller,i.dn=i.pn(),l(i.bn(),(function(f){i.fn=f,i.dn&&(i.hn=i.dn,i.en.resolve(i.dn),i.on.resolve(i.dn),i.dn.addEventListener("statechange",i.ln,{once:!0}));var a=i.fn.waiting;return a&&y(a.scriptURL,i.sn.toString())&&(i.hn=a,Promise.resolve().then((function(){i.dispatchEvent(new d("waiting",{sw:a,wasWaitingBeforeRegister:!0}))})).then((function(){}))),i.hn&&(i.rn.resolve(i.hn),i.an.add(i.hn)),i.fn.addEventListener("updatefound",i.cn),navigator.serviceWorker.addEventListener("controllerchange",i.yn),i.fn}))})))}catch(f){return Promise.reject(f)}},s.update=function(){try{return this.fn?l(E(this.fn.update())):l()}catch(c){return Promise.reject(c)}},s.getSW=function(){return this.hn!==void 0?Promise.resolve(this.hn):this.rn.promise},s.messageSW=function(c){try{return l(this.getSW(),(function(o){return b(o,c)}))}catch(o){return Promise.reject(o)}},s.messageSkipWaiting=function(){this.fn&&this.fn.waiting&&b(this.fn.waiting,L)},s.pn=function(){var c=navigator.serviceWorker.controller;return c&&y(c.scriptURL,this.sn.toString())?c:void 0},s.bn=function(){try{var c=this;return l((function(o,e){try{var i=o()}catch(f){return e(f)}return i&&i.then?i.then(void 0,e):i})((function(){return l(navigator.serviceWorker.register(c.sn,c.nn),(function(o){return c.un=performance.now(),o}))}),(function(o){throw o})))}catch(o){return Promise.reject(o)}},j(r,[{key:"active",get:function(){return this.en.promise}},{key:"controlling",get:function(){return this.on.promise}}])})((function(){function t(){this.Pn=new Map}var r=t.prototype;return r.addEventListener=function(n,u){this.jn(n).add(u)},r.removeEventListener=function(n,u){this.jn(n).delete(u)},r.dispatchEvent=function(n){n.target=this;for(var u,s=S(this.jn(n.type));!(u=s()).done;)(0,u.value)(n)},r.jn=function(n){return this.Pn.has(n)||this.Pn.set(n,new Set),this.Pn.get(n)},t})());export{O as Workbox,d as WorkboxEvent,b as messageSW};
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 697 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,84 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#282828" />
|
||||
<!-- PWA: <link rel="manifest"> и sw.js подставляет vite-plugin-pwa при сборке.
|
||||
Здесь только то, что нужно iOS (у него нет install prompt и он не читает
|
||||
часть полей манифеста) и иконки/сплэши для «на экран Домой». -->
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="JapLearn" />
|
||||
<link rel="icon" href="/icons/favicon-32.png" sizes="32x32" type="image/png" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180.png" />
|
||||
<link rel="preload" href="/fonts/NotoSansJP-VariableFont_wght.ttf" as="font" type="font/ttf" crossorigin />
|
||||
<!-- Стартовые экраны iOS (иконка + название на фоне --bg).
|
||||
Генерируются скриптом tools/gen_pwa_assets.py. -->
|
||||
<link rel="apple-touch-startup-image" href="/splash/iphone-se.png"
|
||||
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/iphone-8-plus.png"
|
||||
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/iphone-x.png"
|
||||
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/iphone-xr.png"
|
||||
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/iphone-xs-max.png"
|
||||
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/iphone-12.png"
|
||||
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/iphone-12-pro-max.png"
|
||||
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/iphone-14-pro.png"
|
||||
media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/iphone-14-pro-max.png"
|
||||
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/iphone-16-pro.png"
|
||||
media="(device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/iphone-16-pro-max.png"
|
||||
media="(device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/ipad-9-7.png"
|
||||
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/ipad-pro-11.png"
|
||||
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/ipad-pro-12-9.png"
|
||||
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" />
|
||||
<title>JapLearn — интервальные повторения</title>
|
||||
<script type="module" crossorigin src="/assets/index-CFPL1rcQ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BQ2pEdzg.css">
|
||||
<link rel="manifest" href="/manifest.webmanifest"></head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header class="top-bar">
|
||||
<button id="burger-btn" class="burger-btn" type="button" aria-label="Меню" aria-expanded="false" aria-controls="drawer">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</button>
|
||||
<div class="top-bar-title">
|
||||
<h1>JapLearn</h1>
|
||||
<p id="page-title">Главная</p>
|
||||
</div>
|
||||
<div class="top-bar-spacer"></div>
|
||||
</header>
|
||||
|
||||
<div id="drawer-overlay" class="drawer-overlay" hidden></div>
|
||||
<nav id="drawer" class="drawer" aria-label="Навигация" aria-hidden="true">
|
||||
<div class="drawer-header">
|
||||
<div class="drawer-logo">JapLearn</div>
|
||||
<div class="drawer-subtitle">Интервальные повторения</div>
|
||||
</div>
|
||||
<button class="drawer-btn active" data-page="home" type="button">Главная</button>
|
||||
<button class="drawer-btn" data-page="decks" type="button">Колоды</button>
|
||||
<button class="drawer-btn" data-page="sync" type="button">Синхронизация</button>
|
||||
</nav>
|
||||
|
||||
<div id="page" class="app-content">
|
||||
<!-- Страницы рендерятся сюда -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- Баннер PWA (установка / новая версия / офлайн). Логика — src/pwa.ts -->
|
||||
<div id="pwa-banner" class="pwa-banner" role="status" aria-live="polite" hidden></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"JapLearn — интервальные повторения","short_name":"JapLearn","description":"Японские слова, колоды и интервальные повторения. Работает офлайн.","start_url":"/","display":"standalone","background_color":"#282828","theme_color":"#282828","lang":"ru","scope":"/","id":"/","dir":"ltr","orientation":"portrait","categories":["education"],"icons":[{"src":"/icons/icon-192.png","sizes":"192x192","type":"image/png","purpose":"any"},{"src":"/icons/icon-512.png","sizes":"512x512","type":"image/png","purpose":"any"},{"src":"/icons/maskable-192.png","sizes":"192x192","type":"image/png","purpose":"maskable"},{"src":"/icons/maskable-512.png","sizes":"512x512","type":"image/png","purpose":"maskable"}]}
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1 @@
|
||||
if(!self.define){let e,s={};const n=(n,i)=>(n=new URL(n+".js",i).href,s[n]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=s,document.head.appendChild(e)}else e=n,importScripts(n),s()}).then(()=>{let e=s[n];if(!e)throw new Error(`Module ${n} didn’t register its module`);return e}));self.define=(i,a)=>{const r=e||("document"in self?document.currentScript.src:"")||location.href;if(s[r])return;let o={};const c=e=>n(e,r),l={module:{uri:r},exports:o,require:c};s[r]=Promise.all(i.map(e=>l[e]||c(e))).then(e=>(a(...e),o))}}define(["./workbox-c121765f"],function(e){"use strict";self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"manifest.webmanifest",revision:"2da03f1edad9a85ad34c166a4e435cbb"},{url:"index.html",revision:"10aab2584277ecfcf8cb333d74afe097"},{url:"splash/iphone-xs-max.png",revision:"96de251d25c908ab353c72a5a5aa425b"},{url:"splash/iphone-xr.png",revision:"8bad9ac0a6d8203f3ba2eab11b8e8a56"},{url:"splash/iphone-x.png",revision:"99de1ba939a2e816263c6cae54553f96"},{url:"splash/iphone-se.png",revision:"425ec0bf4e8625d4568ee0245c1575bf"},{url:"splash/iphone-8-plus.png",revision:"13d8fc39327e6fb2fca1397092049cfb"},{url:"splash/iphone-16-pro.png",revision:"721d8b9f466f40c1b112d658cea58e34"},{url:"splash/iphone-16-pro-max.png",revision:"e7ee30320004cb39ba3745edb04ece51"},{url:"splash/iphone-14-pro.png",revision:"9f6311e5dae77fc5ff92ed0ef04aaca1"},{url:"splash/iphone-14-pro-max.png",revision:"b86349f7265cb8f6cd5290b92bec16d2"},{url:"splash/iphone-12.png",revision:"3abc8ee9dc2fb636bc3068f15d693b11"},{url:"splash/iphone-12-pro-max.png",revision:"26a5b61dda690259033a37c2364d6c41"},{url:"splash/ipad-pro-12-9.png",revision:"1f587dc282457eb85bcd52f6364326fd"},{url:"splash/ipad-pro-11.png",revision:"4e65d63e83da28c3ba4c7cf436bb67d4"},{url:"splash/ipad-9-7.png",revision:"7429faf1ae77533095601991cfc14583"},{url:"icons/maskable-512.png",revision:"958c675f87dce6b9a81b557606db4050"},{url:"icons/maskable-192.png",revision:"8c5f5a60642a21d6ed5ff50decad6018"},{url:"icons/icon-512.png",revision:"59a7eaf099d3fefcdb02477617fc3894"},{url:"icons/icon-192.png",revision:"725ca4f82c280ff1ab08bd8766edde4a"},{url:"icons/favicon-32.png",revision:"4a4e4bd2a12403a5896c9164318e8f70"},{url:"icons/apple-touch-icon-180.png",revision:"1f93d3d4fd9ca1fcc3bdcd60b68c1cf4"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/sqlite3-worker1-bundler-friendly-CqPDHTTx.js",revision:null},{url:"assets/sqlite3-opfs-async-proxy-B_ImRJXp.js",revision:null},{url:"assets/sqlite3-DBpDb1lf.wasm",revision:null},{url:"assets/index-CFPL1rcQ.js",revision:null},{url:"assets/index-BQ2pEdzg.css",revision:null},{url:"assets/db-worker-DFFhVJkA.js",revision:null},{url:"icons/icon-192.png",revision:"725ca4f82c280ff1ab08bd8766edde4a"},{url:"icons/icon-512.png",revision:"59a7eaf099d3fefcdb02477617fc3894"},{url:"icons/maskable-192.png",revision:"8c5f5a60642a21d6ed5ff50decad6018"},{url:"icons/maskable-512.png",revision:"958c675f87dce6b9a81b557606db4050"},{url:"manifest.webmanifest",revision:"2da03f1edad9a85ad34c166a4e435cbb"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"))),e.registerRoute(/\/fonts\/.*\.ttf$/,new e.CacheFirst({cacheName:"japlearn-fonts",plugins:[new e.ExpirationPlugin({maxEntries:4}),new e.CacheableResponsePlugin({statuses:[0,200]})]}),"GET"),e.registerRoute(/^https:\/\/learning\.micialware\.ru\//,new e.NetworkOnly,"GET")});
|
||||
@@ -0,0 +1 @@
|
||||
define(["exports"],function(t){"use strict";try{self["workbox:core:7.4.0"]&&_()}catch(t){}const e=(t,...e)=>{let s=t;return e.length>0&&(s+=` :: ${JSON.stringify(e)}`),s};class s extends Error{constructor(t,s){super(e(t,s)),this.name=t,this.details=s}}try{self["workbox:routing:7.4.0"]&&_()}catch(t){}const n=t=>t&&"object"==typeof t?t:{handle:t};class r{constructor(t,e,s="GET"){this.handler=n(e),this.match=t,this.method=s}setCatchHandler(t){this.catchHandler=n(t)}}class i extends r{constructor(t,e,s){super(({url:e})=>{const s=t.exec(e.href);if(s&&(e.origin===location.origin||0===s.index))return s.slice(1)},e,s)}}class a{constructor(){this.t=new Map,this.i=new Map}get routes(){return this.t}addFetchListener(){self.addEventListener("fetch",t=>{const{request:e}=t,s=this.handleRequest({request:e,event:t});s&&t.respondWith(s)})}addCacheListener(){self.addEventListener("message",t=>{if(t.data&&"CACHE_URLS"===t.data.type){const{payload:e}=t.data,s=Promise.all(e.urlsToCache.map(e=>{"string"==typeof e&&(e=[e]);const s=new Request(...e);return this.handleRequest({request:s,event:t})}));t.waitUntil(s),t.ports&&t.ports[0]&&s.then(()=>t.ports[0].postMessage(!0))}})}handleRequest({request:t,event:e}){const s=new URL(t.url,location.href);if(!s.protocol.startsWith("http"))return;const n=s.origin===location.origin,{params:r,route:i}=this.findMatchingRoute({event:e,request:t,sameOrigin:n,url:s});let a=i&&i.handler;const o=t.method;if(!a&&this.i.has(o)&&(a=this.i.get(o)),!a)return;let c;try{c=a.handle({url:s,request:t,event:e,params:r})}catch(t){c=Promise.reject(t)}const h=i&&i.catchHandler;return c instanceof Promise&&(this.o||h)&&(c=c.catch(async n=>{if(h)try{return await h.handle({url:s,request:t,event:e,params:r})}catch(t){t instanceof Error&&(n=t)}if(this.o)return this.o.handle({url:s,request:t,event:e});throw n})),c}findMatchingRoute({url:t,sameOrigin:e,request:s,event:n}){const r=this.t.get(s.method)||[];for(const i of r){let r;const a=i.match({url:t,sameOrigin:e,request:s,event:n});if(a)return r=a,(Array.isArray(r)&&0===r.length||a.constructor===Object&&0===Object.keys(a).length||"boolean"==typeof a)&&(r=void 0),{route:i,params:r}}return{}}setDefaultHandler(t,e="GET"){this.i.set(e,n(t))}setCatchHandler(t){this.o=n(t)}registerRoute(t){this.t.has(t.method)||this.t.set(t.method,[]),this.t.get(t.method).push(t)}unregisterRoute(t){if(!this.t.has(t.method))throw new s("unregister-route-but-not-found-with-method",{method:t.method});const e=this.t.get(t.method).indexOf(t);if(!(e>-1))throw new s("unregister-route-route-not-registered");this.t.get(t.method).splice(e,1)}}let o;const c=()=>(o||(o=new a,o.addFetchListener(),o.addCacheListener()),o);function h(t,e,n){let a;if("string"==typeof t){const s=new URL(t,location.href);a=new r(({url:t})=>t.href===s.href,e,n)}else if(t instanceof RegExp)a=new i(t,e,n);else if("function"==typeof t)a=new r(t,e,n);else{if(!(t instanceof r))throw new s("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});a=t}return c().registerRoute(a),a}const u={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"workbox",runtime:"runtime",suffix:"undefined"!=typeof registration?registration.scope:""},l=t=>[u.prefix,t,u.suffix].filter(t=>t&&t.length>0).join("-"),f=t=>t||l(u.precache),w=t=>t||l(u.runtime);function d(t){t.then(()=>{})}const p=new Set;function y(){return y=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var s=arguments[e];for(var n in s)({}).hasOwnProperty.call(s,n)&&(t[n]=s[n])}return t},y.apply(null,arguments)}let m,g;const R=new WeakMap,v=new WeakMap,b=new WeakMap,q=new WeakMap,D=new WeakMap;let U={get(t,e,s){if(t instanceof IDBTransaction){if("done"===e)return v.get(t);if("objectStoreNames"===e)return t.objectStoreNames||b.get(t);if("store"===e)return s.objectStoreNames[1]?void 0:s.objectStore(s.objectStoreNames[0])}return E(t[e])},set:(t,e,s)=>(t[e]=s,!0),has:(t,e)=>t instanceof IDBTransaction&&("done"===e||"store"===e)||e in t};function x(t){return t!==IDBDatabase.prototype.transaction||"objectStoreNames"in IDBTransaction.prototype?(g||(g=[IDBCursor.prototype.advance,IDBCursor.prototype.continue,IDBCursor.prototype.continuePrimaryKey])).includes(t)?function(...e){return t.apply(I(this),e),E(R.get(this))}:function(...e){return E(t.apply(I(this),e))}:function(e,...s){const n=t.call(I(this),e,...s);return b.set(n,e.sort?e.sort():[e]),E(n)}}function L(t){return"function"==typeof t?x(t):(t instanceof IDBTransaction&&function(t){if(v.has(t))return;const e=new Promise((e,s)=>{const n=()=>{t.removeEventListener("complete",r),t.removeEventListener("error",i),t.removeEventListener("abort",i)},r=()=>{e(),n()},i=()=>{s(t.error||new DOMException("AbortError","AbortError")),n()};t.addEventListener("complete",r),t.addEventListener("error",i),t.addEventListener("abort",i)});v.set(t,e)}(t),e=t,(m||(m=[IDBDatabase,IDBObjectStore,IDBIndex,IDBCursor,IDBTransaction])).some(t=>e instanceof t)?new Proxy(t,U):t);var e}Line truncated
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"hash": "b95e48b0",
|
||||
"configHash": "7e5ba4af",
|
||||
"lockfileHash": "0299b8d0",
|
||||
"browserHash": "6e4eb490",
|
||||
"hash": "ca8d4d83",
|
||||
"configHash": "c5f74d21",
|
||||
"lockfileHash": "cbeb4802",
|
||||
"browserHash": "60fc50c7",
|
||||
"optimized": {
|
||||
"@hpcc-js/wasm-zstd": {
|
||||
"src": "../../@hpcc-js/wasm-zstd/dist/index.js",
|
||||
"file": "@hpcc-js_wasm-zstd.js",
|
||||
"fileHash": "e5eae5ad",
|
||||
"fileHash": "4f8f2ae4",
|
||||
"needsInterop": false
|
||||
}
|
||||
},
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.7"
|
||||
"vite": "^7.1.7",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hpcc-js/wasm-zstd": "^1.16.0",
|
||||
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 697 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 23 KiB |
@@ -6,6 +6,7 @@ import { renderHistoryPage } from './history-page.ts';
|
||||
import { renderReviewPage } from './review-page.ts';
|
||||
import { renderSyncPage } from './sync-page.ts';
|
||||
import { initWorker } from './sqlite-api.ts';
|
||||
import { setupPwa } from './pwa.ts';
|
||||
|
||||
// Каркас приложения: топ-бар + бургер-меню + страницы (Главная / Колоды / Колода / Повторение / История / Синхронизация).
|
||||
|
||||
@@ -101,4 +102,5 @@ drawer.querySelectorAll<HTMLButtonElement>('.drawer-btn').forEach((btn) => {
|
||||
await initWorker();
|
||||
setDrawer(false);
|
||||
renderPage('home');
|
||||
setupPwa();
|
||||
console.log('[JapLearn] crossOriginIsolated =', window.crossOriginIsolated);
|
||||
@@ -0,0 +1,3 @@
|
||||
// Типы виртуальных модулей vite-plugin-pwa:
|
||||
// virtual:pwa-register, virtual:pwa-register/vue, __WB_MANIFEST и т.п.
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
@@ -0,0 +1,195 @@
|
||||
// PWA-слой: регистрация service worker, баннер (установка / обновление / офлайн)
|
||||
// и запрос persistent-хранилища для OPFS с локальной БД.
|
||||
//
|
||||
// Сам sw.js и манифест генерирует vite-plugin-pwa (см. vite.config.ts).
|
||||
// На iOS нет beforeinstallprompt — там показываем подсказку «Поделиться →
|
||||
// На экран „Домой“» один раз (флаг в localStorage).
|
||||
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
|
||||
/** beforeinstallprompt — Chrome-only API, в DOM-типах его нет. */
|
||||
type BeforeInstallPromptEvent = Event & {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||
};
|
||||
|
||||
type Action = { label: string; primary?: boolean; onClick: () => void };
|
||||
|
||||
const IOS_HINT_KEY = 'japlearn_ios_install_hint_seen';
|
||||
|
||||
function isStandalone(): boolean {
|
||||
const nav = navigator as Navigator & { standalone?: boolean };
|
||||
return window.matchMedia('(display-mode: standalone)').matches || nav.standalone === true;
|
||||
}
|
||||
|
||||
/** iOS + именно WebKit-браузер: только Safari умеет «На экран „Домой“». */
|
||||
function isIosSafari(): boolean {
|
||||
const ua = navigator.userAgent;
|
||||
const ios =
|
||||
/iPhone|iPad|iPod/.test(ua) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); // iPad на iPadOS
|
||||
return ios && /WebKit/.test(ua) && !/CriOS|FxiOS|EdgiOS|OPiOS/.test(ua);
|
||||
}
|
||||
|
||||
function iosHintSeen(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(IOS_HINT_KEY) === '1';
|
||||
} catch {
|
||||
return true; // приватный режим и т.п. — не показываем
|
||||
}
|
||||
}
|
||||
|
||||
export function setupPwa(): void {
|
||||
const host = document.getElementById('pwa-banner');
|
||||
if (!host) return;
|
||||
|
||||
let updateFn: (() => Promise<void>) | null = null;
|
||||
let installEvent: BeforeInstallPromptEvent | null = null;
|
||||
let offlineReady = false;
|
||||
|
||||
function hide(): void {
|
||||
host!.hidden = true;
|
||||
host!.replaceChildren();
|
||||
}
|
||||
|
||||
function show(text: string, actions: Action[]): void {
|
||||
const textEl = document.createElement('span');
|
||||
textEl.className = 'pwa-banner-text';
|
||||
textEl.textContent = text;
|
||||
|
||||
const actionsEl = document.createElement('div');
|
||||
actionsEl.className = 'pwa-actions';
|
||||
for (const action of actions) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = action.primary ? 'btn btn-primary pwa-btn' : 'btn pwa-btn';
|
||||
btn.textContent = action.label;
|
||||
btn.addEventListener('click', action.onClick);
|
||||
actionsEl.append(btn);
|
||||
}
|
||||
|
||||
host!.replaceChildren(textEl, actionsEl);
|
||||
host!.hidden = false;
|
||||
}
|
||||
|
||||
// Приоритет: новая версия → установка → подсказка для iOS → «офлайн готово».
|
||||
function render(): void {
|
||||
if (updateFn) {
|
||||
show('Доступна новая версия приложения', [
|
||||
{
|
||||
label: 'Обновить',
|
||||
primary: true,
|
||||
onClick: () => void updateFn?.(),
|
||||
},
|
||||
{
|
||||
label: 'Позже',
|
||||
onClick: () => {
|
||||
updateFn = null; // в этой сессии больше не предлагаем
|
||||
render();
|
||||
},
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (installEvent) {
|
||||
show('Установить JapLearn на домашний экран?', [
|
||||
{ label: 'Установить', primary: true, onClick: () => void doInstall() },
|
||||
{
|
||||
label: 'Не сейчас',
|
||||
onClick: () => {
|
||||
installEvent = null;
|
||||
render();
|
||||
},
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (isIosSafari() && !isStandalone() && !iosHintSeen()) {
|
||||
show('На iPhone: «Поделиться» → «На экран „Домой“»', [
|
||||
{
|
||||
label: 'Понятно',
|
||||
primary: true,
|
||||
onClick: () => {
|
||||
try {
|
||||
localStorage.setItem(IOS_HINT_KEY, '1');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
render();
|
||||
},
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (offlineReady) {
|
||||
show('Готово: приложение работает офлайн', [
|
||||
{
|
||||
label: 'Хорошо',
|
||||
primary: true,
|
||||
onClick: () => {
|
||||
offlineReady = false;
|
||||
render();
|
||||
},
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
hide();
|
||||
}
|
||||
|
||||
async function doInstall(): Promise<void> {
|
||||
const event = installEvent;
|
||||
if (!event) return;
|
||||
installEvent = null;
|
||||
try {
|
||||
await event.prompt();
|
||||
const choice = await event.userChoice;
|
||||
console.log('[JapLearn] установка:', choice.outcome);
|
||||
} catch (err) {
|
||||
console.warn('[JapLearn] install prompt:', err);
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
// registerType: 'prompt' в конфиге → новая версия ждёт клика пользователя.
|
||||
const updateSW = registerSW({
|
||||
immediate: true,
|
||||
onNeedRefresh() {
|
||||
updateFn = () => updateSW(true);
|
||||
render();
|
||||
},
|
||||
onOfflineReady() {
|
||||
offlineReady = true;
|
||||
render();
|
||||
},
|
||||
onRegisterError(err) {
|
||||
console.warn('[JapLearn] service worker:', err);
|
||||
},
|
||||
});
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (event) => {
|
||||
event.preventDefault(); // свою кнопку показываем сами
|
||||
installEvent = event as BeforeInstallPromptEvent;
|
||||
render();
|
||||
});
|
||||
|
||||
window.addEventListener('appinstalled', () => {
|
||||
installEvent = null;
|
||||
render();
|
||||
});
|
||||
|
||||
// Локальная БД живёт в OPFS: просим браузер не вытеснять хранилище
|
||||
// (особенно важно на iOS, где иначе данные могут пропасть).
|
||||
void (async () => {
|
||||
try {
|
||||
if ((await navigator.storage.persisted()) === false) {
|
||||
const granted = await navigator.storage.persist();
|
||||
console.log('[JapLearn] storage.persist =', granted);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[JapLearn] storage.persist:', err);
|
||||
}
|
||||
})();
|
||||
|
||||
render();
|
||||
}
|
||||
@@ -484,6 +484,60 @@ body.drawer-open {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* --- Баннер PWA (установка / новая версия / офлайн) ---
|
||||
Фиксирован внизу; z-index ниже drawer (40/50), чтобы меню его перекрывало. */
|
||||
|
||||
.pwa-banner {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: calc(var(--space-half) + env(safe-area-inset-bottom));
|
||||
transform: translateX(-50%);
|
||||
width: min(480px, calc(100vw - var(--space) * 2));
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-half);
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-weakest);
|
||||
border: 1px solid var(--bg-weak);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.35);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.pwa-banner[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pwa-actions {
|
||||
display: flex;
|
||||
gap: var(--space-half);
|
||||
}
|
||||
|
||||
.pwa-btn {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.pwa-banner {
|
||||
animation: pwa-rise 0.18s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pwa-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, 8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Список слов (главная) --- */
|
||||
|
||||
.words-toolbar {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Генератор PWA-ассетов JapLearn: иконки + iOS splash-экраны.
|
||||
|
||||
Запуск (из корня проекта): python3 tools/gen_pwa_assets.py
|
||||
Требуется Pillow (PIL) и ImageFont из системного Python.
|
||||
|
||||
Что делает:
|
||||
- public/icons/*.png — 192/512 (any), 192/512 (maskable), apple-touch 180, favicon 32
|
||||
- public/splash/*.png — apple-touch-startup-image под iPhone/iPad
|
||||
- печатает готовый HTML-сниппет splash-ссылок для index.html
|
||||
|
||||
Палитра — Gruvbox Dark из src/style.css (--bg #282828, --primary-strong #49a8ac).
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
FONT_PATH = ROOT / "public/fonts/NotoSansJP-VariableFont_wght.ttf"
|
||||
ICONS_DIR = ROOT / "public/icons"
|
||||
SPLASH_DIR = ROOT / "public/splash"
|
||||
|
||||
BG = (40, 40, 40) # --bg #282828
|
||||
ACCENT = (73, 168, 172) # --primary-strong #49a8ac
|
||||
MUTED = (144, 140, 124) # --muted #908c7c
|
||||
GLYPH = "語" # «слово» — символ приложения
|
||||
|
||||
# Порядок: (файл, CSS-ширина, CSS-высота, DPR, подпись модели)
|
||||
SPLASHES = [
|
||||
("iphone-se", 375, 667, 2, "iPhone SE / 8"),
|
||||
("iphone-8-plus", 414, 736, 3, "iPhone 8 Plus"),
|
||||
("iphone-x", 375, 812, 3, "iPhone X/XS/11 Pro/12 mini/13 mini"),
|
||||
("iphone-xr", 414, 896, 2, "iPhone XR/11"),
|
||||
("iphone-xs-max", 414, 896, 3, "iPhone XS Max/11 Pro Max"),
|
||||
("iphone-12", 390, 844, 3, "iPhone 12/13/14"),
|
||||
("iphone-12-pro-max", 428, 926, 3, "iPhone 12/13/14 Pro Max"),
|
||||
("iphone-14-pro", 393, 852, 3, "iPhone 14 Pro/15/15 Pro/16"),
|
||||
("iphone-14-pro-max", 430, 932, 3, "iPhone 14 Pro Max/15 Plus/15 Pro Max"),
|
||||
("iphone-16-pro", 402, 874, 3, "iPhone 16 Pro"),
|
||||
("iphone-16-pro-max", 440, 956, 3, "iPhone 16 Pro Max/16 Plus"),
|
||||
("ipad-9-7", 768, 1024, 2, "iPad 9.7/10.2"),
|
||||
("ipad-pro-11", 834, 1194, 2, "iPad Pro 11\"/Air"),
|
||||
("ipad-pro-12-9", 1024, 1366, 2, "iPad Pro 12.9\""),
|
||||
]
|
||||
|
||||
|
||||
def load_font(size: int, weight: int = 700) -> ImageFont.FreeTypeFont:
|
||||
"""Noto Sans JP — variable, поэтому вес выставляем через ось wght."""
|
||||
font = ImageFont.truetype(str(FONT_PATH), size)
|
||||
try:
|
||||
font.set_variation_by_axes([weight])
|
||||
except Exception:
|
||||
pass # статическая сборка шрифта — берём как есть
|
||||
return font
|
||||
|
||||
|
||||
def centered(draw: ImageDraw.ImageDraw, text: str, font, box_w: int, box_h: int, fill) -> None:
|
||||
left, top, right, bottom = draw.textbbox((0, 0), text, font=font)
|
||||
draw.text(
|
||||
((box_w - (right - left)) / 2 - left, (box_h - (bottom - top)) / 2 - top),
|
||||
text,
|
||||
font=font,
|
||||
fill=fill,
|
||||
)
|
||||
|
||||
|
||||
def make_icon(size: int, glyph_ratio: float, radius_ratio: float, opaque: bool) -> Image.Image:
|
||||
img = Image.new("RGBA", (size, size), (*BG, 255 if opaque else 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
if radius_ratio > 0:
|
||||
mask = Image.new("L", (size, size), 0)
|
||||
ImageDraw.Draw(mask).rounded_rectangle(
|
||||
(0, 0, size - 1, size - 1), radius=int(size * radius_ratio), fill=255
|
||||
)
|
||||
img.putalpha(mask)
|
||||
draw = ImageDraw.Draw(img)
|
||||
centered(draw, GLYPH, load_font(int(size * glyph_ratio)), size, size, ACCENT)
|
||||
return img
|
||||
|
||||
|
||||
def make_splash(w: int, h: int) -> Image.Image:
|
||||
img = Image.new("RGB", (w, h), BG)
|
||||
draw = ImageDraw.Draw(img)
|
||||
glyph_size = int(w * 0.34)
|
||||
centered(draw, GLYPH, load_font(glyph_size), w, int(h * 0.46), ACCENT)
|
||||
centered(draw, "JapLearn", load_font(int(w * 0.075), 500), w, int(h * 0.60), MUTED)
|
||||
return img
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ICONS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
SPLASH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Иконки: any — со скруглением (как покажет Android/desktop), maskable —
|
||||
# full-bleed без прозрачности и со «сжатым» логотипом (safe zone 80%),
|
||||
# apple-touch — full-bleed без альфы, iOS скруглит сам.
|
||||
for size in (192, 512):
|
||||
make_icon(size, 0.56, 0.18, opaque=False).save(ICONS_DIR / f"icon-{size}.png")
|
||||
make_icon(size, 0.44, 0.0, opaque=True).save(ICONS_DIR / f"maskable-{size}.png")
|
||||
make_icon(180, 0.56, 0.0, opaque=True).save(ICONS_DIR / "apple-touch-icon-180.png")
|
||||
make_icon(32, 0.62, 0.18, opaque=False).save(ICONS_DIR / "favicon-32.png")
|
||||
|
||||
for name, cw, ch, dpr, label in SPLASHES:
|
||||
make_splash(cw * dpr, ch * dpr).save(SPLASH_DIR / f"{name}.png")
|
||||
|
||||
print("Иконки:")
|
||||
for p in sorted(ICONS_DIR.glob("*.png")):
|
||||
print(f" {p.relative_to(ROOT)} {p.stat().st_size // 1024} КБ")
|
||||
print("Splash:")
|
||||
total = sum(p.stat().st_size for p in SPLASH_DIR.glob("*.png"))
|
||||
print(f" {len(list(SPLASH_DIR.glob('*.png')))} файлов, {total // 1024} КБ всего")
|
||||
|
||||
print("\nHTML для index.html:\n")
|
||||
for name, cw, ch, dpr, label in SPLASHES:
|
||||
print(
|
||||
f' <link rel="apple-touch-startup-image" href="/splash/{name}.png"\n'
|
||||
f' media="(device-width: {cw}px) and (device-height: {ch}px) '
|
||||
f'and (-webkit-device-pixel-ratio: {dpr}) and (orientation: portrait)" />'
|
||||
f" <!-- {label} -->"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
export default defineConfig({
|
||||
// @sqlite.org/sqlite-wasm:
|
||||
@@ -23,6 +24,14 @@ export default defineConfig({
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
// Те же заголовки для `vite preview` — иначе прод-сборку локально
|
||||
// не проверить: без COOP/COEP не будет crossOriginIsolated и OPFS-БД.
|
||||
headers: {
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
name: 'configure-response-headers',
|
||||
@@ -34,5 +43,66 @@ export default defineConfig({
|
||||
});
|
||||
},
|
||||
},
|
||||
// PWA: манифест + precache app shell (Workbox) + sw.js.
|
||||
// Регистрацию SW делаем сами (injectRegister: null) — см. src/pwa.ts,
|
||||
// чтобы показать баннер «Доступна новая версия».
|
||||
VitePWA({
|
||||
registerType: 'prompt',
|
||||
injectRegister: null,
|
||||
manifest: {
|
||||
id: '/',
|
||||
name: 'JapLearn — интервальные повторения',
|
||||
short_name: 'JapLearn',
|
||||
description: 'Японские слова, колоды и интервальные повторения. Работает офлайн.',
|
||||
lang: 'ru',
|
||||
dir: 'ltr',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait',
|
||||
background_color: '#282828',
|
||||
theme_color: '#282828',
|
||||
categories: ['education'],
|
||||
icons: [
|
||||
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: '/icons/maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: '/icons/maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
// В precache идёт вся сборка: js/css/html, sqlite-wasm, иконки, сплэши.
|
||||
globPatterns: ['**/*.{js,css,html,wasm,png,svg,webmanifest}'],
|
||||
// Шрифт (8.8 МБ variable TTF) в precache не берём: он нужен только для
|
||||
// отрисовки, кладём его в runtime-кэш CacheFirst ниже.
|
||||
globIgnores: ['**/fonts/*.ttf'],
|
||||
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
|
||||
// SPA без URL-роутинга: любая навигация отдаётся из кэша index.html.
|
||||
navigateFallback: '/index.html',
|
||||
cleanupOutdatedCaches: true,
|
||||
clientsClaim: true,
|
||||
runtimeCaching: [
|
||||
{
|
||||
// Локальный шрифт: после первой загрузки работает офлайн.
|
||||
urlPattern: /\/fonts\/.*\.ttf$/,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'japlearn-fonts',
|
||||
expiration: { maxEntries: 4 },
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
{
|
||||
// Синхронизация (скачивание .sqlite) — только сеть, никакого кэша.
|
||||
urlPattern: /^https:\/\/learning\.micialware\.ru\//,
|
||||
handler: 'NetworkOnly',
|
||||
},
|
||||
],
|
||||
},
|
||||
devOptions: {
|
||||
// В dev SW не нужен и мешает отладке кэша (HMR + OPFS).
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||