diff --git a/app.css b/app.css index a29263c..4471d8a 100644 --- a/app.css +++ b/app.css @@ -186,9 +186,10 @@ button.row:active, .deck-row:active { background: var(--c); } .deck-row:active { transform: scale(.98); } .deck-row .name { flex: 1; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .deck-row .ic:first-child { color: var(--on-variant); } -.counts { display: flex; gap: 8px; font-size: 12px; line-height: 16px; font-weight: 700; font-variant-numeric: tabular-nums; } +.counts { display: flex; gap: 8px; font-size: 11px; line-height: 16px; font-weight: 700; font-variant-numeric: tabular-nums; } .counts .n { color: var(--new); } .counts .l { color: var(--learn); } .counts .d { color: var(--due); } .counts .zero { color: var(--outline); font-weight: 400; } +.count-lbl { font-size: 10px; font-weight: 500; opacity: 0.8; margin-right: 2px; } .deck-row .ic.done { color: var(--due); } .banner { @@ -264,7 +265,24 @@ button.row:active, .deck-row:active { background: var(--c); } .dv-counts .n b { color: var(--new); } .dv-counts .l b { color: var(--learn); } .dv-counts .d b { color: var(--due); } .card-row { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; } .card-row .q { font-weight: 600; } -.card-row .a { font-size: 13px; color: var(--on-variant); } +.card-row .a { + font-size: 13px; + color: var(--on-variant); + filter: blur(5.5px); + user-select: none; + cursor: pointer; + transition: filter 0.2s, background 0.2s; + background: color-mix(in srgb, var(--on-variant) 12%, transparent); + border-radius: var(--r-sm); + padding: 0 6px; + display: inline-block; +} +.card-row .a.revealed { + filter: none; + background: none; + padding: 0; + user-select: auto; +} .card-row .meta { font-size: 12px; color: var(--outline); } .card-row .q, .card-row .a { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; } @@ -300,8 +318,27 @@ button.row:active, .deck-row:active { background: var(--c); } .face.back .card-text.dim { font-size: 15px; line-height: 20px; color: var(--on-variant); font-weight: 600; } .face hr { width: 64px; border: 0; border-top: .5px solid var(--outline-variant); } .hint { text-align: center; color: var(--outline); font-size: 13px; padding-bottom: 10px; } -.face-tools { display: flex; justify-content: space-between; border-top: .5px solid var(--outline-variant); - padding-top: 6px; margin: 0 -4px -6px; color: var(--on-variant); } +.face-tools { + position: absolute; + top: 12px; + right: 12px; + display: flex; + gap: 8px; + color: var(--on-variant); +} +.face-tools .icon-btn { + width: 36px; + height: 36px; + border-radius: var(--r); + background: var(--c-low); + border: .5px solid var(--outline-variant); + display: grid; + place-items: center; +} +.face-tools .icon-btn .ic { + width: 18px; + height: 18px; +} .rating { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--stack); padding: 0 var(--m-page) calc(env(safe-area-inset-bottom) + 16px); } @@ -322,11 +359,38 @@ button.row:active, .deck-row:active { background: var(--c); } flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: var(--m-page) 32px calc(var(--m-page) + 40px); text-align: center; animation: fade .3s var(--ease); } .done-panel[hidden] { display: none; } -.done-check { width: 56px; height: 56px; color: var(--due); stroke-width: 1.6; } +.done-check { width: 56px; height: 56px; color: var(--due); stroke-width: 1.6; animation: pop-check 0.5s var(--ease); } +@keyframes pop-check { + 0% { transform: scale(0); } + 90% { transform: scale(1.1); } + 100% { transform: scale(1); } +} .done-panel h3 { font-size: 24px; line-height: 30px; } .done-panel p { color: var(--on-variant); } .done-panel .primary-btn { max-width: 280px; margin-top: 12px; } +.streak-badge { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + background: color-mix(in srgb, var(--tertiary) 10%, var(--c-lowest)); + border: 1px solid var(--tertiary); + border-radius: var(--r-xl); + padding: 10px 24px; + font-size: 16px; + font-weight: 700; + color: var(--tertiary); + margin: 12px 0; + animation: bounce-in 0.6s var(--ease); +} +@keyframes bounce-in { + 0% { transform: scale(0.3); opacity: 0; } + 50% { transform: scale(1.08); } + 75% { transform: scale(0.95); } + 100% { transform: scale(1); opacity: 1; } +} + /* ---------- sheet ---------- */ .scrim { position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 30; animation: fadein .25s; } .scrim[hidden] { display: none; } diff --git a/app.js b/app.js index 130dfda..8da9e87 100644 --- a/app.js +++ b/app.js @@ -63,9 +63,9 @@ function renderHome() { ${icon('folder')} ${esc(d.name)} ${clear && total ? `${icon('check', 'ic done')}` : ` - ${c.n} - ${c.l} - ${c.d}`} + 新規${c.n} + 学習中${c.l} + 復習${c.d}`} ${icon('chevron', 'ic chev')} `; }).join(''); @@ -246,6 +246,19 @@ function grade(g) { function finishReview() { const mins = Math.max(1, Math.round((Date.now() - sessionStart) / 60000)); $('#rv-done-sub').textContent = `${answered}枚を復習しました(約${mins}分)`; + + const days = new Set(db.log.map(l => dayKey(l.t))); + let streak = 0, cur = startOfDay(Date.now()); + if (!days.has(dayKey(cur))) cur -= DAY; + while (days.has(dayKey(cur))) { streak++; cur -= DAY; } + + if (streak > 0) { + $('#rv-streak-count').textContent = streak; + $('#rv-done-streak').hidden = false; + } else { + $('#rv-done-streak').hidden = true; + } + $('#rv-done').hidden = false; $('#rating').classList.add('hidden'); } @@ -346,6 +359,12 @@ function go(tab) { /* ---------------- events ---------------- */ document.addEventListener('click', e => { const t = e.target; + if (t.classList.contains('a') && t.closest('.card-row')) { + e.stopPropagation(); + e.preventDefault(); + t.classList.toggle('revealed'); + return; + } const tab = t.closest('[data-goto]'); if (tab) return go(tab.dataset.goto); const deck = t.closest('[data-deck]'); if (deck) return openDeckView(deck.dataset.deck); const card = t.closest('[data-card]'); if (card) return cardSheet(db.cards.find(c => c.id === card.dataset.card)); @@ -376,7 +395,54 @@ $('#set-theme').onchange = e => { db.settings.theme = e.target.value; save(); ap $('#opt-' + k).onchange = e => { deckOf(openDeck)[k] = e.target.checked; save(); renderDeck(); }; }); $('#btn-import-json').onclick = () => pickFile('json'); -$('#btn-import-csv').onclick = () => pickFile('csv'); +function showCSVImportModal() { + const bodyHTML = ` +
+

取り込むファイルのフォーマットが以下のように構成されていることを確認してください。

+
+ + + + + + + + + + + + + +
1列目2列目
表面(問題)裏面(答え)
+
+

+ ※カンマ区切り(.csv)およびタブ区切り(.tsv/.txt)に対応しています。ファイル名がそのまま新規作成されるデッキの名前になります。 +

+
+ + +
+
+ `; + + openSheet('CSV / TSV インポート', bodyHTML, null); + + $('#btn-modal-select-file').onclick = () => { + closeSheet(); + pickFile('csv'); + }; + + $('#btn-modal-sample-dl').onclick = () => { + const csvContent = "front,back\nubiquitous,遍在する、どこにでもある\nephemeral,つかの間の、短命な\n"; + const blob = new Blob([new Uint8Array([0xEF, 0xBB, 0xBF]), csvContent], { type: 'text/csv;charset=utf-8;' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = "sample_deck.csv"; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); + }; +} +$('#btn-import-csv').onclick = showCSVImportModal; $('#btn-wipe').onclick = wipe; $('#file-input').onchange = e => e.target.files[0] && handleFile(e.target.files[0]); diff --git a/core.js b/core.js index 273954c..be1e4ce 100644 --- a/core.js +++ b/core.js @@ -44,11 +44,11 @@ function schedule(card, g, now, retention = 0.9) { } function fmtInterval(days) { - if (days < 1 / 24) return '<1m'; - if (days < 1) return Math.round(days * 24) + 'h'; - if (days < 30) return Math.round(days) + 'd'; - if (days < 365) return (days / 30.4).toFixed(days < 90 ? 1 : 0) + 'mo'; - return (days / 365).toFixed(1) + 'y'; + if (days < 1 / 24) return '<1分'; + if (days < 1) return Math.round(days * 24) + '時間後'; + if (days < 30) return Math.round(days) + '日後'; + if (days < 365) return (days / 30.4).toFixed(days < 90 ? 1 : 0) + 'ヶ月後'; + return (days / 365).toFixed(1) + '年後'; } /** Minimal RFC4180 parser; auto-detects TSV (Anki's export format). */ diff --git a/index.html b/index.html index 00a5cf1..f7eb80f 100644 --- a/index.html +++ b/index.html @@ -41,7 +41,10 @@

デッキ

- +
@@ -186,8 +189,12 @@

diff --git a/server.log b/server.log deleted file mode 100644 index 2353d52..0000000 --- a/server.log +++ /dev/null @@ -1,11 +0,0 @@ -127.0.0.1 - - [13/Aug/2026 15:20:05] "GET / HTTP/1.1" 200 - -127.0.0.1 - - [13/Aug/2026 15:20:05] "GET /app.css HTTP/1.1" 200 - -127.0.0.1 - - [13/Aug/2026 15:20:05] "GET /core.js HTTP/1.1" 200 - -127.0.0.1 - - [13/Aug/2026 15:20:05] "GET /app.js HTTP/1.1" 200 - -127.0.0.1 - - [13/Aug/2026 15:20:05] "GET /store.js HTTP/1.1" 200 - -127.0.0.1 - - [13/Aug/2026 15:20:06] "GET /sw.js HTTP/1.1" 200 - -127.0.0.1 - - [13/Aug/2026 15:20:06] "GET /index.html HTTP/1.1" 200 - -127.0.0.1 - - [13/Aug/2026 15:20:06] "GET /manifest.webmanifest HTTP/1.1" 200 - -127.0.0.1 - - [13/Aug/2026 15:20:06] "GET /icons/icon.svg HTTP/1.1" 200 - -127.0.0.1 - - [13/Aug/2026 15:20:06] "GET /icons/icon-192.png HTTP/1.1" 200 - -127.0.0.1 - - [13/Aug/2026 15:20:06] "GET /icons/icon-512.png HTTP/1.1" 200 - diff --git a/test.cjs b/test.cjs index f6fd7f5..49f2cb5 100644 --- a/test.cjs +++ b/test.cjs @@ -69,9 +69,9 @@ const assert = require('assert'); 'lower retention target => longer intervals'); // --- interval labels - assert.strictEqual(fmtInterval(1 / 1440), '<1m'); - assert.strictEqual(fmtInterval(4), '4d'); - assert.strictEqual(fmtInterval(400), '1.1y'); + assert.strictEqual(fmtInterval(1 / 1440), '<1分'); + assert.strictEqual(fmtInterval(4), '4日後'); + assert.strictEqual(fmtInterval(400), '1.1年後'); // --- CSV / TSV parsing assert.deepStrictEqual(parseCSV('a,b\n"c,1","d""x"\n\n# note,skip\n,empty').map(r => r.slice(0, 2)),