commit db4fddc00a581b9dc762b8100ae7fe2352bde73d Author: fibe-agent Date: Mon Apr 20 15:40:25 2026 +0000 Word filter static nginx diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bae8c85 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +docker-compose.yml +.git +**/.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..923d2d3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,2 @@ +FROM nginx:1.25-alpine +COPY index.html app.js styles.css /usr/share/nginx/html/ diff --git a/app.js b/app.js new file mode 100644 index 0000000..7d6450c --- /dev/null +++ b/app.js @@ -0,0 +1,330 @@ +(function () { + const DB_NAME = "wordFilterDB"; + const DB_VER = 1; + const STORE = "kv"; + const KEY_STATE = "state"; + + const defaultState = () => ({ + queue: [], + known: [], + learning: [], + undo: [], + }); + + let dbPromise = null; + + function openDb() { + if (dbPromise) return dbPromise; + dbPromise = new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VER); + req.onerror = () => reject(req.error); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(STORE)) { + db.createObjectStore(STORE); + } + }; + req.onsuccess = () => resolve(req.result); + }); + return dbPromise; + } + + async function loadState() { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE, "readonly"); + const st = tx.objectStore(STORE); + const g = st.get(KEY_STATE); + g.onerror = () => reject(g.error); + g.onsuccess = () => { + const v = g.result; + if (!v) { + resolve(defaultState()); + return; + } + resolve({ + queue: Array.isArray(v.queue) ? v.queue.slice() : [], + known: Array.isArray(v.known) ? v.known.slice() : [], + learning: Array.isArray(v.learning) ? v.learning.slice() : [], + undo: Array.isArray(v.undo) ? v.undo.slice() : [], + }); + }; + }); + } + + async function saveState(state) { + const db = await openDb(); + const payload = { + queue: state.queue.slice(), + known: state.known.slice(), + learning: state.learning.slice(), + undo: state.undo.slice(), + }; + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE, "readwrite"); + const st = tx.objectStore(STORE); + const p = st.put(payload, KEY_STATE); + p.onerror = () => reject(p.error); + p.onsuccess = () => resolve(); + }); + } + + function normalizeKey(s) { + return String(s).trim().toLowerCase(); + } + + function tokenize(text) { + const raw = String(text); + const parts = raw.split(/[\r\n,;\t]+|\s{2,}|\s+/g); + const out = []; + for (let i = 0; i < parts.length; i++) { + const t = parts[i].trim(); + if (t.length) out.push(t); + } + return out; + } + + function mergeUnique(tokens, dedupeCi, state) { + const seen = new Set(); + const existing = new Set(); + let i; + for (i = 0; i < state.queue.length; i++) { + existing.add(dedupeCi ? normalizeKey(state.queue[i]) : state.queue[i]); + } + for (i = 0; i < state.known.length; i++) { + existing.add(dedupeCi ? normalizeKey(state.known[i]) : state.known[i]); + } + for (i = 0; i < state.learning.length; i++) { + existing.add(dedupeCi ? normalizeKey(state.learning[i]) : state.learning[i]); + } + const added = []; + for (i = 0; i < tokens.length; i++) { + const w = tokens[i]; + const key = dedupeCi ? normalizeKey(w) : w; + if (seen.has(key)) continue; + seen.add(key); + if (existing.has(key)) continue; + existing.add(key); + added.push(w); + } + return added; + } + + function pushUndo(state, entry) { + const u = state.undo; + u.push(entry); + if (u.length > 80) u.splice(0, u.length - 80); + } + + function sortAlpha(arr) { + return arr.slice().sort(function (a, b) { + return a.localeCompare(b, undefined, { sensitivity: "base" }); + }); + } + + const el = { + tabs: document.querySelectorAll(".tab"), + panels: { + import: document.getElementById("panel-import"), + review: document.getElementById("panel-review"), + data: document.getElementById("panel-data"), + }, + importText: document.getElementById("import-text"), + dedupeCi: document.getElementById("dedupe-ci"), + btnAdd: document.getElementById("btn-add"), + btnClearImport: document.getElementById("btn-clear-import"), + importStatus: document.getElementById("import-status"), + reviewCount: document.getElementById("review-count"), + reviewEmpty: document.getElementById("review-empty"), + reviewActive: document.getElementById("review-active"), + reviewWord: document.getElementById("review-word"), + btnKnown: document.getElementById("btn-known"), + btnLearn: document.getElementById("btn-learn"), + btnUndo: document.getElementById("btn-undo"), + stats: document.getElementById("stats"), + listLearning: document.getElementById("list-learning"), + listKnown: document.getElementById("list-known"), + exportLearning: document.getElementById("export-learning"), + exportKnown: document.getElementById("export-known"), + btnResetAll: document.getElementById("btn-reset-all"), + }; + + let state = defaultState(); + + function setTab(name) { + const keys = Object.keys(el.panels); + let i; + for (i = 0; i < el.tabs.length; i++) { + const t = el.tabs[i]; + const on = t.getAttribute("data-panel") === name; + t.classList.toggle("is-active", on); + t.setAttribute("aria-selected", on ? "true" : "false"); + } + for (i = 0; i < keys.length; i++) { + const k = keys[i]; + const p = el.panels[k]; + const vis = k === name; + p.hidden = !vis; + p.classList.toggle("is-visible", vis); + } + if (name === "review") { + renderReview(); + } + if (name === "data") { + renderDataPanel(); + } + } + + function renderReview() { + const n = state.queue.length; + el.reviewCount.textContent = n ? "Queue: " + n + " word" + (n === 1 ? "" : "s") : "Queue: empty"; + if (!n) { + el.reviewEmpty.hidden = false; + el.reviewActive.hidden = true; + el.reviewWord.textContent = ""; + return; + } + el.reviewEmpty.hidden = true; + el.reviewActive.hidden = false; + el.reviewWord.textContent = state.queue[0]; + } + + function renderDataPanel() { + const learnSorted = sortAlpha(state.learning); + const knownSorted = sortAlpha(state.known); + el.listLearning.value = learnSorted.join("\n"); + el.listKnown.value = knownSorted.join("\n"); + el.stats.innerHTML = + "
" + + state.queue.length + + "in queue
" + + "
" + + state.learning.length + + "learning
" + + "
" + + state.known.length + + "known
"; + } + + async function persist() { + await saveState(state); + } + + function downloadText(filename, text) { + const blob = new Blob([text], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.rel = "noopener"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + async function init() { + state = await loadState(); + renderReview(); + el.tabs.forEach(function (tab) { + tab.addEventListener("click", function () { + setTab(tab.getAttribute("data-panel")); + }); + }); + el.btnAdd.addEventListener("click", async function () { + const tokens = tokenize(el.importText.value); + const dedupe = el.dedupeCi.checked; + const added = mergeUnique(tokens, dedupe, state); + let i; + for (i = 0; i < added.length; i++) { + state.queue.push(added[i]); + } + await persist(); + el.importStatus.textContent = + added.length === 0 + ? "No new words added (duplicates or empty)." + : "Added " + added.length + " word" + (added.length === 1 ? "" : "s") + " to the queue."; + }); + el.btnClearImport.addEventListener("click", function () { + el.importText.value = ""; + el.importStatus.textContent = ""; + }); + el.btnKnown.addEventListener("click", function () { + decideKnown(); + }); + el.btnLearn.addEventListener("click", function () { + decideLearn(); + }); + el.btnUndo.addEventListener("click", function () { + undoLast(); + }); + el.exportLearning.addEventListener("click", function () { + downloadText("learning-words.txt", sortAlpha(state.learning).join("\n")); + }); + el.exportKnown.addEventListener("click", function () { + downloadText("known-words.txt", sortAlpha(state.known).join("\n")); + }); + el.btnResetAll.addEventListener("click", async function () { + if (!window.confirm("Delete all saved words in this browser?")) return; + state = defaultState(); + await persist(); + renderReview(); + renderDataPanel(); + el.importStatus.textContent = "Local data cleared."; + }); + document.addEventListener("keydown", function (e) { + if (e.defaultPrevented) return; + const t = e.target; + if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA")) return; + const k = e.key && e.key.length === 1 ? e.key.toLowerCase() : ""; + if (k === "k") { + e.preventDefault(); + decideKnown(); + } else if (k === "l") { + e.preventDefault(); + decideLearn(); + } else if (k === "u") { + e.preventDefault(); + undoLast(); + } + }); + } + + async function decideKnown() { + if (!state.queue.length) return; + const w = state.queue.shift(); + state.known.push(w); + pushUndo(state, { word: w, bucket: "known" }); + await persist(); + renderReview(); + } + + async function decideLearn() { + if (!state.queue.length) return; + const w = state.queue.shift(); + state.learning.push(w); + pushUndo(state, { word: w, bucket: "learning" }); + await persist(); + renderReview(); + } + + async function undoLast() { + if (!state.undo.length) return; + const last = state.undo.pop(); + const w = last.word; + if (last.bucket === "known") { + const idx = state.known.lastIndexOf(w); + if (idx !== -1) state.known.splice(idx, 1); + } else if (last.bucket === "learning") { + const idx = state.learning.lastIndexOf(w); + if (idx !== -1) state.learning.splice(idx, 1); + } + state.queue.unshift(w); + await persist(); + renderReview(); + } + + init().catch(function (err) { + el.importStatus.textContent = "Storage error: " + (err && err.message ? err.message : String(err)); + }); +})(); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4d87dbd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +services: + web: + build: . + labels: + fibe.gg/repo_url: "https://git-next.fibe.live/viktorvsk/word-filter" + fibe.gg/dockerfile: "Dockerfile" + fibe.gg/expose: "external:80" + fibe.gg/production: "false" diff --git a/index.html b/index.html new file mode 100644 index 0000000..7255d46 --- /dev/null +++ b/index.html @@ -0,0 +1,81 @@ + + + + + + Word filter + + + +
+

Word filter

+

Import a list, then sort each word: already know it, or still learning.

+
+ + + +
+
+ +
+ +
+
+ + +
+

+
+ + + + +
+ + + + + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..cb4a252 --- /dev/null +++ b/styles.css @@ -0,0 +1,323 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +:root { + --bg: #0f1419; + --surface: #1a2332; + --border: #2d3a4d; + --text: #e8eef7; + --muted: #8b9cb3; + --accent: #5b9fd4; + --danger: #c75c5c; + --ok: #5a9e6a; + --focus: #e0b045; + font-family: system-ui, "Segoe UI", Roboto, Ubuntu, sans-serif; +} + +body { + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--text); + line-height: 1.5; +} + +.top { + padding: 1.25rem 1.5rem 0.5rem; + max-width: 52rem; + margin: 0 auto; +} + +.top h1 { + margin: 0 0 0.25rem; + font-size: 1.35rem; + font-weight: 600; +} + +.tagline { + margin: 0; + color: var(--muted); + font-size: 0.95rem; +} + +.tabs { + display: flex; + gap: 0.35rem; + padding: 0 1.5rem; + max-width: 52rem; + margin: 0 auto 0.75rem; + border-bottom: 1px solid var(--border); +} + +.tab { + appearance: none; + border: none; + background: transparent; + color: var(--muted); + padding: 0.55rem 0.85rem; + cursor: pointer; + font-size: 0.95rem; + border-bottom: 2px solid transparent; + margin-bottom: -1px; +} + +.tab:hover { + color: var(--text); +} + +.tab.is-active { + color: var(--text); + border-bottom-color: var(--accent); +} + +main { + max-width: 52rem; + margin: 0 auto; + padding: 0 1.5rem 2rem; +} + +.panel[hidden] { + display: none !important; +} + +.panel { + padding-top: 0.75rem; +} + +.field { + display: block; +} + +.label { + display: block; + font-size: 0.9rem; + color: var(--muted); + margin-bottom: 0.35rem; +} + +textarea { + width: 100%; + padding: 0.65rem 0.75rem; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + font-size: 0.95rem; + resize: vertical; + min-height: 8rem; +} + +textarea:focus { + outline: 2px solid var(--focus); + outline-offset: 1px; +} + +.row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem 1rem; + margin-top: 0.65rem; +} + +.actions { + margin-top: 0.85rem; +} + +.inline { + font-size: 0.9rem; + color: var(--muted); + cursor: pointer; + user-select: none; +} + +.btn { + appearance: none; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + padding: 0.5rem 1rem; + font-size: 0.95rem; + cursor: pointer; +} + +.btn:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + +.btn.primary { + background: #2a4a66; + border-color: #3d6a8a; +} + +.btn.primary:hover { + background: #335c80; +} + +.btn.success { + background: #264530; + border-color: #3a6b48; +} + +.btn.success:hover { + background: #2f563a; +} + +.btn.danger { + background: #4a2a2a; + border-color: #6b3d3d; +} + +.btn.danger:hover { + background: #5c3434; +} + +.btn.danger.outline { + background: transparent; +} + +.btn.ghost { + background: transparent; +} + +.btn.wide { + flex: 1 1 10rem; + min-height: 3.25rem; + font-size: 1.05rem; +} + +.status { + min-height: 1.25rem; + font-size: 0.9rem; + color: var(--muted); + margin-top: 0.5rem; +} + +.review-bar { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem 1rem; + margin-bottom: 1rem; +} + +.badge { + font-size: 0.95rem; + color: var(--muted); +} + +.hint { + font-size: 0.85rem; + color: var(--muted); +} + +kbd { + display: inline-block; + padding: 0.1rem 0.35rem; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--surface); + font-size: 0.8rem; + font-family: inherit; +} + +.empty { + padding: 1.5rem; + border: 1px dashed var(--border); + border-radius: 10px; + color: var(--muted); +} + +.review-card { + text-align: center; + padding: 1.5rem 1rem 1rem; + border-radius: 12px; + border: 1px solid var(--border); + background: var(--surface); +} + +.word { + margin: 0 0 1.25rem; + font-size: clamp(1.75rem, 5vw, 2.75rem); + font-weight: 600; + letter-spacing: 0.02em; + line-height: 1.2; + word-break: break-word; +} + +.review-actions { + justify-content: center; + margin-bottom: 0.75rem; +} + +.key-hint { + opacity: 0.65; + font-size: 0.85em; + margin-left: 0.25rem; +} + +.stats { + display: flex; + flex-wrap: wrap; + gap: 0.75rem 1.25rem; + margin-bottom: 1rem; +} + +.stat { + display: flex; + flex-direction: column; + gap: 0.15rem; + padding: 0.5rem 0.75rem; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--surface); + min-width: 6rem; +} + +.stat strong { + font-size: 1.25rem; +} + +.stat span { + font-size: 0.8rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.columns { + display: grid; + gap: 1.25rem; +} + +@media (min-width: 720px) { + .columns { + grid-template-columns: 1fr 1fr; + } +} + +.col h2 { + margin: 0 0 0.5rem; + font-size: 1rem; + font-weight: 600; +} + +.danger-zone { + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid var(--border); +} + +.foot { + max-width: 52rem; + margin: 0 auto; + padding: 0 1.5rem 1.5rem; + font-size: 0.85rem; + color: var(--muted); +}