this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: update for civility 1

+189 -521
+1 -1
CLAUDE.md
··· 47 47 48 48 All singletons live in `www/models/`: 49 49 50 - - **`app`** (`app.ts`) — single source of truth for all user data: assignments, settings, overrides. Wraps `Store` (IndexedDB via `@byojs/storage` + optional `@civility/sync` remote sync). Call `app.notify()` after mutating state; call `app.persist()` to write to storage. 50 + - **`app`** (`app.ts`) — single source of truth for all user data: assignments, settings, overrides. Wraps `Store` (IndexedDB via `@civility/store/idb`) and exposes `app.synced` (a `@civility/sync` `Synced` instance) for remote sync of collections. Call `app.notify()` after mutating state. 51 51 - **`subjects`** (`subjects.ts`) — static subject data loaded from `www/static/gen/characters.json` + `www/static/gen/vocabulary.json`. Also exports `scheduler` (FSRS-based flashcard scheduler) and `deck`. 52 52 - **`downloads`** (`downloads.ts`) — tracks audio download progress per subject/locale. 53 53
+9 -7
deno.json
··· 1 1 { 2 - "version": "4.0.1", 2 + "version": "4.1.0", 3 3 "workspace": ["./data"], 4 4 "compilerOptions": { 5 5 "lib": [ ··· 39 39 }, 40 40 "imports": { 41 41 "$/": "./www/", 42 - "@civility/store": "jsr:@civility/store@^1.0.0-beta.5", 43 - "@civility/store/memory": "jsr:@civility/store@^1.0.0-beta.5/memory", 44 - "@civility/ui": "jsr:@civility/ui@^0.2.9", 45 - "@civility/workers": "jsr:@civility/workers@^0.2.5", 42 + "@civility/store": "jsr:@civility/store@^1.0.0-beta.10", 43 + "@civility/store/idb": "jsr:@civility/store@^1.0.0-beta.10/idb", 44 + "@civility/store/memory": "jsr:@civility/store@^1.0.0-beta.10/memory", 45 + "@civility/sync": "jsr:@civility/sync@^1.0.0-beta.13", 46 + "@civility/ui": "jsr:@civility/ui@^1.0.0-beta.5", 47 + "@civility/workers": "jsr:@civility/workers@^0.2.7", 46 48 "@flashcard/core": "jsr:@flashcard/core@^0.1.0", 47 49 "@flashcard/schedulers": "jsr:@flashcard/schedulers@^0.1.0", 48 50 "@byojs/storage": "npm:@byojs/storage@^0.12.1", 49 51 "@byojs/storage/idb": "npm:@byojs/storage@^0.12.1/idb", 50 52 "@leeoniya/ufuzzy": "npm:@leeoniya/ufuzzy@^1.0.19", 51 53 "@std/assert": "jsr:@std/assert@^1.0.19", 52 - "@std/async": "jsr:@std/async@^1.2.0", 54 + "@std/async": "jsr:@std/async@^1.3.0", 53 55 "@zod/zod": "jsr:@zod/zod@^4.3.6", 54 - "fake-indexeddb": "npm:fake-indexeddb@6.0.0", 56 + "fake-indexeddb": "npm:fake-indexeddb@6.2.5", 55 57 "howler": "npm:howler@^2.2.4", 56 58 "lit": "npm:lit@^3.3.2", 57 59 "signature_pad": "npm:signature_pad@^5.1.3"
+101 -80
deno.lock
··· 1 1 { 2 2 "version": "5", 3 3 "specifiers": { 4 - "jsr:@civility/store@*": "0.3.1", 5 - "jsr:@civility/store@1.0.0-beta.5": "1.0.0-beta.5", 6 - "jsr:@civility/store@^1.0.0-beta.5": "1.0.0-beta.5", 4 + "jsr:@civility/blobs@^1.0.0-beta.4": "1.0.0-beta.4", 5 + "jsr:@civility/errors@^1.0.0-beta.1": "1.0.0-beta.1", 6 + "jsr:@civility/store@^1.0.0-beta.10": "1.0.0-beta.10", 7 7 "jsr:@civility/store@~0.3.1": "0.3.1", 8 - "jsr:@civility/ui@~0.2.9": "0.2.9", 9 - "jsr:@civility/workers@~0.2.5": "0.2.5", 10 - "jsr:@cliffy/ansi@1": "1.0.0", 11 - "jsr:@cliffy/ansi@1.0.0": "1.0.0", 12 - "jsr:@cliffy/command@1": "1.0.0", 13 - "jsr:@cliffy/flags@1": "1.0.0", 14 - "jsr:@cliffy/flags@1.0.0": "1.0.0", 15 - "jsr:@cliffy/internal@1.0.0": "1.0.0", 16 - "jsr:@cliffy/keycode@1": "1.0.0", 17 - "jsr:@cliffy/keycode@1.0.0": "1.0.0", 18 - "jsr:@cliffy/keypress@1": "1.0.0", 19 - "jsr:@cliffy/prompt@1": "1.0.0", 20 - "jsr:@cliffy/table@1": "1.0.0", 21 - "jsr:@cliffy/table@1.0.0": "1.0.0", 8 + "jsr:@civility/sync@^1.0.0-beta.13": "1.0.0-beta.13", 9 + "jsr:@civility/ui@^1.0.0-beta.5": "1.0.0-beta.5", 10 + "jsr:@civility/workers@~0.2.7": "0.2.7", 11 + "jsr:@cliffy/ansi@1": "1.0.1", 12 + "jsr:@cliffy/ansi@1.0.1": "1.0.1", 13 + "jsr:@cliffy/command@1": "1.0.1", 14 + "jsr:@cliffy/flags@1": "1.0.1", 15 + "jsr:@cliffy/flags@1.0.1": "1.0.1", 16 + "jsr:@cliffy/internal@1.0.1": "1.0.1", 17 + "jsr:@cliffy/keycode@1": "1.0.1", 18 + "jsr:@cliffy/keycode@1.0.1": "1.0.1", 19 + "jsr:@cliffy/keypress@1": "1.0.1", 20 + "jsr:@cliffy/prompt@1": "1.0.1", 21 + "jsr:@cliffy/table@1": "1.0.1", 22 + "jsr:@cliffy/table@1.0.1": "1.0.1", 22 23 "jsr:@flashcard/core@0.1": "0.1.0", 23 24 "jsr:@flashcard/schedulers@0.1": "0.1.0", 24 25 "jsr:@flashcard/utils@~0.1.1": "0.1.1", 25 - "jsr:@std/assert@^1.0.18": "1.0.19", 26 + "jsr:@paulmillr/qr@~0.5.5": "0.5.5", 26 27 "jsr:@std/assert@^1.0.19": "1.0.19", 27 - "jsr:@std/async@^1.2.0": "1.2.0", 28 + "jsr:@std/async@^1.3.0": "1.3.0", 28 29 "jsr:@std/bytes@^1.0.6": "1.0.6", 29 - "jsr:@std/collections@^1.1.6": "1.1.6", 30 + "jsr:@std/collections@^1.1.6": "1.1.7", 30 31 "jsr:@std/csv@^1.0.6": "1.0.6", 32 + "jsr:@std/data-structures@^1.0.11": "1.0.11", 31 33 "jsr:@std/dotenv@~0.225.6": "0.225.6", 32 34 "jsr:@std/encoding@^1.0.10": "1.0.10", 33 - "jsr:@std/fmt@^1.0.9": "1.0.9", 35 + "jsr:@std/fmt@^1.0.9": "1.0.10", 34 36 "jsr:@std/fs@^1.0.23": "1.0.23", 35 - "jsr:@std/html@^1.0.5": "1.0.5", 37 + "jsr:@std/html@^1.0.5": "1.0.6", 36 38 "jsr:@std/internal@^1.0.12": "1.0.12", 37 39 "jsr:@std/io@~0.225.3": "0.225.3", 38 40 "jsr:@std/path@^1.1.4": "1.1.4", 39 41 "jsr:@std/semver@^1.0.8": "1.0.8", 40 42 "jsr:@std/streams@^1.0.9": "1.0.17", 41 - "jsr:@std/text@^1.0.17": "1.0.17", 43 + "jsr:@std/text@^1.0.17": "1.0.18", 42 44 "jsr:@std/ulid@1": "1.0.0", 43 45 "jsr:@zod/zod@^4.3.6": "4.3.6", 44 46 "npm:@byojs/storage@~0.12.1": "0.12.1", 45 47 "npm:@leeoniya/ufuzzy@^1.0.19": "1.0.19", 46 48 "npm:cc-cedict@^1.1.1": "1.1.1", 47 49 "npm:chinese-to-pinyin@^1.3.1": "1.3.1", 48 - "npm:fake-indexeddb@6.0.0": "6.0.0", 50 + "npm:fake-indexeddb@6.2.5": "6.2.5", 49 51 "npm:fast-json-patch@^3.1.1": "3.1.1", 50 52 "npm:hanzi@^2.1.5": "2.1.5", 51 53 "npm:howler@^2.2.4": "2.2.4", ··· 56 58 "npm:pinyin-to-zhuyin@^1.0.3": "1.0.3", 57 59 "npm:pinyin-tone-tool@^1.0.5": "1.0.5", 58 60 "npm:signature_pad@^5.1.3": "5.1.3", 59 - "npm:ts-fsrs@^5.2.3": "5.3.1" 61 + "npm:ts-fsrs@^5.2.3": "5.3.2" 60 62 }, 61 63 "jsr": { 64 + "@civility/blobs@1.0.0-beta.4": { 65 + "integrity": "6806eb2a5b02e9e611385107b539abe0b2fe8e17066cfc42eaf467e301a6afa0" 66 + }, 67 + "@civility/errors@1.0.0-beta.1": { 68 + "integrity": "2b28c161162aa855498ba7a7c9fe95b43cc149cc8bfb1b70bfa2f895c1cc186d" 69 + }, 62 70 "@civility/store@0.3.1": { 63 71 "integrity": "0438f2cdb16145a61a97f5be509cd0b34e7cbd9f71dc657feffe2a4dd7dd0ec3", 64 72 "dependencies": [ 65 73 "jsr:@std/semver" 66 74 ] 67 75 }, 68 - "@civility/store@1.0.0-beta.5": { 69 - "integrity": "afb3c70da4d4242faf9ca07e54b269889f2b3bac4e23962272c1350a3062a54d", 76 + "@civility/store@1.0.0-beta.10": { 77 + "integrity": "9fe3ec0c4dbfe15e149462e8208e425644f686dd20a9caf3e64a1833e519a389", 70 78 "dependencies": [ 71 79 "jsr:@std/fs", 72 80 "jsr:@std/path", ··· 75 83 "npm:fast-json-patch" 76 84 ] 77 85 }, 78 - "@civility/ui@0.2.9": { 79 - "integrity": "68eff67028c540669f30d5c5d434182ad3686a4610a700137c341d4202d1f996", 86 + "@civility/sync@1.0.0-beta.13": { 87 + "integrity": "ca1b957583be279176fa3c32ef672bdeac418fe5980178094c9aa651a6405f7b", 88 + "dependencies": [ 89 + "jsr:@civility/blobs", 90 + "jsr:@civility/errors", 91 + "jsr:@civility/store@^1.0.0-beta.10", 92 + "jsr:@paulmillr/qr" 93 + ] 94 + }, 95 + "@civility/ui@1.0.0-beta.5": { 96 + "integrity": "fefa2b541adcf9bbda3fc0dfea07dcab99612497d647856ba76d857c6d98a8b7", 80 97 "dependencies": [ 81 98 "jsr:@std/html", 82 99 "npm:lit" 83 100 ] 84 101 }, 85 - "@civility/workers@0.2.5": { 86 - "integrity": "5a27340c55972cc71042d4b3ce9c6a8d508e31a77fe6133f94ccdb0d48db0e40" 102 + "@civility/workers@0.2.7": { 103 + "integrity": "c2485c3d3dc867ff0f40d846e40a6fcc3992a11d27309ae5dd31851f0bc0e4b7" 87 104 }, 88 - "@cliffy/ansi@1.0.0": { 89 - "integrity": "987008f74e50aa72cc1517ffccc769711734a14927bc4599e052efe1b9a840e2", 105 + "@cliffy/ansi@1.0.1": { 106 + "integrity": "46be51d0993a916dbed68564a6630dc1a742ebb0247744e04bc17e85d72f5bed", 90 107 "dependencies": [ 91 108 "jsr:@cliffy/internal", 92 109 "jsr:@std/encoding", 93 - "jsr:@std/fmt", 94 - "jsr:@std/io" 110 + "jsr:@std/fmt" 95 111 ] 96 112 }, 97 - "@cliffy/command@1.0.0": { 98 - "integrity": "c52a241ea68857fcdaff4f3173eb404f8017d7bc35553b6f533c592b89dde7d2", 113 + "@cliffy/command@1.0.1": { 114 + "integrity": "0172d9c7d8aeb26b8f4f44ffa833247e4fbab707e258ee271e10a2a2f87b531d", 99 115 "dependencies": [ 100 - "jsr:@cliffy/flags@1.0.0", 116 + "jsr:@cliffy/flags@1.0.1", 101 117 "jsr:@cliffy/internal", 102 - "jsr:@cliffy/table@1.0.0", 118 + "jsr:@cliffy/table@1.0.1", 103 119 "jsr:@std/fmt", 104 120 "jsr:@std/semver", 105 121 "jsr:@std/text" 106 122 ] 107 123 }, 108 - "@cliffy/flags@1.0.0": { 109 - "integrity": "8b57698adc644da8f90422d58976362d41a4ebca39c312ca1c101585d0148feb", 124 + "@cliffy/flags@1.0.1": { 125 + "integrity": "468dcf65acb33b51ecb2e232c30603209c528b1d1bd9e065921b57fcb342cb01", 110 126 "dependencies": [ 111 127 "jsr:@cliffy/internal", 112 128 "jsr:@std/text" 113 129 ] 114 130 }, 115 - "@cliffy/internal@1.0.0": { 116 - "integrity": "1e17ccbcd5420093c0a93e5b3827bbdc9abac5195bacf187edc44665e54bdde6" 131 + "@cliffy/internal@1.0.1": { 132 + "integrity": "9e2bba59ad559b790f09c57219c727a69f0179ebabc07f1bf9db25232b606760" 117 133 }, 118 - "@cliffy/keycode@1.0.0": { 119 - "integrity": "755dbf007be110dcb5625f87eb61b362b6a0ca6835453af03ebd3b34d399cf14" 134 + "@cliffy/keycode@1.0.1": { 135 + "integrity": "b01053b39bce5536e36aff9b262d84a7b289bcff03d904f3cf60f9dc1605ce9f" 120 136 }, 121 - "@cliffy/keypress@1.0.0": { 122 - "integrity": "dd2e33484bea5fedf9bad5ed4aa0248a53373427d70cb94de4aad3052f948cea", 137 + "@cliffy/keypress@1.0.1": { 138 + "integrity": "9ac0e3947697c3f60f49684518ba13ec2a4e545e02cf08f5e6c385af870a043b", 123 139 "dependencies": [ 124 140 "jsr:@cliffy/internal", 125 - "jsr:@cliffy/keycode@1.0.0" 141 + "jsr:@cliffy/keycode@1.0.1" 126 142 ] 127 143 }, 128 - "@cliffy/prompt@1.0.0": { 129 - "integrity": "48b4cd35199fda7832f35e1fe0a3e8bc2b1ea49ba57b4ec0e29e22db44e8ca9f", 144 + "@cliffy/prompt@1.0.1": { 145 + "integrity": "717265ab9a2ad2a3c06d2a090bb3068f1a5a5efd6ea3c6da9ea09274f74830cd", 130 146 "dependencies": [ 131 - "jsr:@cliffy/ansi@1.0.0", 147 + "jsr:@cliffy/ansi@1.0.1", 132 148 "jsr:@cliffy/internal", 133 - "jsr:@cliffy/keycode@1.0.0", 134 - "jsr:@std/assert@^1.0.18", 149 + "jsr:@cliffy/keycode@1.0.1", 150 + "jsr:@std/assert", 135 151 "jsr:@std/fmt", 136 - "jsr:@std/io", 137 152 "jsr:@std/path", 138 153 "jsr:@std/text" 139 154 ] 140 155 }, 141 - "@cliffy/table@1.0.0": { 142 - "integrity": "3fdaa9e1ef1ea62022108adabd826932bdea8dd05497079896febcd41322907f", 156 + "@cliffy/table@1.0.1": { 157 + "integrity": "2b1baa3ef9e16ecb7f3fdcf1deb9f5fa5ffa308ea0e68478224a4c2d44d58166", 143 158 "dependencies": [ 144 159 "jsr:@std/fmt" 145 160 ] ··· 161 176 "@flashcard/utils@0.1.1": { 162 177 "integrity": "a22b83b8ef743c43e50366c405ce252eddb95bd9ee7b164a5cf0d0cd141747fb", 163 178 "dependencies": [ 164 - "jsr:@flashcard/core", 165 179 "jsr:@std/io", 166 180 "jsr:@std/path" 167 181 ] 182 + }, 183 + "@paulmillr/qr@0.5.5": { 184 + "integrity": "2f8ff22c8d2194f2147eac1b3093f5e85f648c0a8005d5635a617fb72bf5ae38" 168 185 }, 169 186 "@std/assert@1.0.19": { 170 187 "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", ··· 172 189 "jsr:@std/internal" 173 190 ] 174 191 }, 175 - "@std/async@1.2.0": { 176 - "integrity": "c059c6f6d95ca7cc012ae8e8d7164d1697113d54b0b679e4372b354b11c2dee5" 192 + "@std/async@1.3.0": { 193 + "integrity": "80485538a4f7baaa46bfe2246168069e02ed142b9f9079cd164f43bb060ad9e9", 194 + "dependencies": [ 195 + "jsr:@std/data-structures" 196 + ] 177 197 }, 178 198 "@std/bytes@1.0.6": { 179 199 "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" 180 200 }, 181 - "@std/collections@1.1.6": { 182 - "integrity": "b458160ce65ea5ad35da05d0a5cbee4b583677c8b443a10d7beb0c4ac63f2baa" 201 + "@std/collections@1.1.7": { 202 + "integrity": "56f659d011218a69740b12829cf5ea2c9b70bbed0af02597e27dc1eb5e69e208" 183 203 }, 184 204 "@std/csv@1.0.6": { 185 205 "integrity": "52ef0e62799a0028d278fa04762f17f9bd263fad9a8e7f98c14fbd371d62d9fd", ··· 187 207 "jsr:@std/streams" 188 208 ] 189 209 }, 210 + "@std/data-structures@1.0.11": { 211 + "integrity": "53b98ed7efa61f107dfc14244bd2ec5557f7f7ee0bbaef6d449d7937facacb89" 212 + }, 190 213 "@std/dotenv@0.225.6": { 191 214 "integrity": "1d6f9db72f565bd26790fa034c26e45ecb260b5245417be76c2279e5734c421b" 192 215 }, 193 216 "@std/encoding@1.0.10": { 194 217 "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" 195 218 }, 196 - "@std/fmt@1.0.9": { 197 - "integrity": "2487343e8899fb2be5d0e3d35013e54477ada198854e52dd05ed0422eddcabe0" 219 + "@std/fmt@1.0.10": { 220 + "integrity": "90dfba288802ac6de82fb31d0917eb9e4450b9925b954d5e51fc29ac07419db5" 198 221 }, 199 222 "@std/fs@1.0.23": { 200 223 "integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37", ··· 203 226 "jsr:@std/path" 204 227 ] 205 228 }, 206 - "@std/html@1.0.5": { 207 - "integrity": "4e2d693f474cae8c16a920fa5e15a3b72267b94b84667f11a50c6dd1cb18d35e" 229 + "@std/html@1.0.6": { 230 + "integrity": "eaf759c8141e0733ca30eb49e4c08d8e6ca442b85c4d51f9894a56f502993e08" 208 231 }, 209 232 "@std/internal@1.0.12": { 210 233 "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" ··· 225 248 "integrity": "dc830e8b8b6a380c895d53fbfd1258dc253704ca57bbe1629ac65fd7830179b7" 226 249 }, 227 250 "@std/streams@1.0.17": { 228 - "integrity": "7859f3d9deed83cf4b41f19223d4a67661b3d3819e9fc117698f493bf5992140", 229 - "dependencies": [ 230 - "jsr:@std/bytes" 231 - ] 251 + "integrity": "7859f3d9deed83cf4b41f19223d4a67661b3d3819e9fc117698f493bf5992140" 232 252 }, 233 - "@std/text@1.0.17": { 234 - "integrity": "4b2c4ef67ae5b6c1dfd447c81c83a43718f52e3c7e748d8b33f694aba9895f95" 253 + "@std/text@1.0.18": { 254 + "integrity": "d199e516f80599813c64fd4aee5b8f26f6f7d1e1434c88fd153aeea6fea6a9b9" 235 255 }, 236 256 "@std/ulid@1.0.0": { 237 257 "integrity": "d41c3d27a907714413649fee864b7cde8d42ee68437d22b79d5de4f81d808780" ··· 294 314 "readable-stream" 295 315 ] 296 316 }, 297 - "fake-indexeddb@6.0.0": { 298 - "integrity": "sha512-YEboHE5VfopUclOck7LncgIqskAqnv4q0EWbYCaxKKjAvO93c+TJIaBuGy8CBFdbg9nKdpN3AuPRwVBJ4k7NrQ==" 317 + "fake-indexeddb@6.2.5": { 318 + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==" 299 319 }, 300 320 "fast-json-patch@3.1.1": { 301 321 "integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==" ··· 412 432 "safe-buffer" 413 433 ] 414 434 }, 415 - "ts-fsrs@5.3.1": { 416 - "integrity": "sha512-PP3X4E014TX5QvteWxcrjqDkL8+NDiHhL4ACppejxSWansOndXpk/5EMljYlORRueDJL7ngL7YRC5RhBUhiJ3g==" 435 + "ts-fsrs@5.3.2": { 436 + "integrity": "sha512-moJJfYAeG9ynyyGCNaQPUloi0sspTMHFtgCvsx2wDchELu3O2c513/7fp+6PxsGth0ztxNjEtG8d85gX4ce0og==" 417 437 }, 418 438 "universalify@2.0.1": { 419 439 "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==" ··· 437 457 }, 438 458 "workspace": { 439 459 "dependencies": [ 440 - "jsr:@civility/store@^1.0.0-beta.5", 441 - "jsr:@civility/ui@~0.2.9", 442 - "jsr:@civility/workers@~0.2.5", 460 + "jsr:@civility/store@^1.0.0-beta.10", 461 + "jsr:@civility/sync@^1.0.0-beta.13", 462 + "jsr:@civility/ui@^1.0.0-beta.5", 463 + "jsr:@civility/workers@~0.2.7", 443 464 "jsr:@flashcard/core@0.1", 444 465 "jsr:@flashcard/schedulers@0.1", 445 466 "jsr:@std/assert@^1.0.19", 446 - "jsr:@std/async@^1.2.0", 467 + "jsr:@std/async@^1.3.0", 447 468 "jsr:@zod/zod@^4.3.6", 448 469 "npm:@byojs/storage@~0.12.1", 449 470 "npm:@leeoniya/ufuzzy@^1.0.19", 450 - "npm:fake-indexeddb@6.0.0", 471 + "npm:fake-indexeddb@6.2.5", 451 472 "npm:howler@^2.2.4", 452 473 "npm:lit@^3.3.2", 453 474 "npm:signature_pad@^5.1.3"
+35 -56
www/models/app.ts
··· 9 9 import z from '@zod/zod' 10 10 import { Store } from '@civility/store' 11 11 import { IDBStorage } from '@civility/store/idb' 12 + import { Synced } from '@civility/sync' 12 13 import { 13 14 AppData, 14 15 Assignment, ··· 78 79 #overridesColl 79 80 #journalColl 80 81 #ready: Promise<void> 82 + synced: Synced 81 83 82 84 constructor(store?: Store) { 83 - const s = store ?? createStore(new IDBStorage({ dbName: 'hanzi-store' })) 85 + const s = store ?? createStore(new IDBStorage({ dbName: 'bpev-hanzi' })) 84 86 this.#store = s 85 87 this.#settingsDoc = s.document<Settings>('settings', defaultSettings) 86 88 this.#flagsDoc = s.document<Flags>('flags', defaultFlags) 87 - this.#userLevelsDoc = s.document<UserLevels>( 88 - 'userLevels', 89 - defaultUserLevels(), 90 - ) 89 + this.#userLevelsDoc = s 90 + .document<UserLevels>('userLevels', defaultUserLevels()) 91 91 this.#statsDoc = s.document<Stats>('stats', defaultStats) 92 92 this.#searchHistoryDoc = s.document<string[]>('searchHistory', []) 93 93 this.#assignmentsColl = s.collection<StoredAssignment>('assignments') 94 94 this.#overridesColl = s.collection<Override>('overrides') 95 95 this.#journalColl = s.collection<JournalEntry>('journal') 96 + 97 + this.synced = new Synced({ 98 + stores: [ 99 + this.#assignmentsColl, 100 + this.#overridesColl, 101 + this.#journalColl, 102 + ], 103 + appId: 'bpev-hanzi', 104 + }) 96 105 97 106 s.subscribe(() => this.#notify()) 98 107 this.#ready = this.#init() 99 108 } 100 109 110 + async dispose(): Promise<void> { 111 + this.synced.dispose() 112 + await this.#store.dispose() 113 + } 114 + 101 115 async #init(): Promise<void> { 102 116 await Promise.all([ 103 117 this.#settingsDoc.preload(), ··· 136 150 137 151 notify(): void { 138 152 this.#notify() 139 - } 140 - 141 - get store() { 142 - return { 143 - testConnection: () => { 144 - throw new Error('Civility Sync Coming Soon') 145 - }, 146 - syncFromServer: () => { 147 - throw new Error('Civility Sync Coming Soon') 148 - }, 149 - syncToServer: () => { 150 - throw new Error('Civility Sync Coming Soon') 151 - }, 152 - } 153 153 } 154 154 155 155 // ====== STATE ACCESS ====== ··· 345 345 await this.#searchHistoryDoc.set([]) 346 346 } 347 347 348 - // ====== Civility Sync Connection ====== 349 - 350 - setSyncUrl(_syncLinkUrl: string): void { 351 - this.#setError('Civility Sync Coming Soon') 352 - } 353 - 354 - disconnectStore(): void { 355 - // Civility Sync is not supported 356 - } 357 - 358 - get isConnected(): boolean { 359 - return false 360 - } 361 - 362 348 /** True once the Store's async init has resolved and real data is available */ 363 349 get storeReady(): boolean { 364 350 return this.#settingsDoc.value !== undefined ··· 542 528 } 543 529 } 544 530 545 - /** For backward compatibility - load from external source */ 546 - loadStore(_loader: () => Promise<unknown>): Promise<void> { 547 - this.#setError('loadStore is no longer supported. Use importStore instead.') 548 - return Promise.resolve() 549 - } 550 - 551 - /** For backward compatibility - persist current state */ 552 - persist(): boolean { 553 - return true 531 + async deleteAllData(): Promise<{ success: boolean; error?: string }> { 532 + try { 533 + await this.dispose() 534 + await new Promise<void>((resolve, reject) => { 535 + const req = indexedDB.deleteDatabase('bpev-hanzi') 536 + req.onsuccess = () => resolve() 537 + req.onerror = () => reject(req.error) 538 + req.onblocked = () => resolve() 539 + }) 540 + return { success: true } 541 + } catch (error) { 542 + return { 543 + success: false, 544 + error: error instanceof Error ? error.message : 'Delete failed', 545 + } 546 + } 554 547 } 555 548 } 556 549 557 550 const app = new App() 558 - 559 - export async function exportState( 560 - filename = `hanzi-app-export_${Date.now()}`, 561 - ): Promise<string> { 562 - const result = await app.exportStore(filename) 563 - if (!result.success) { 564 - throw new Error(result.error || 'Export failed') 565 - } 566 - return result.path 567 - } 568 - 569 - export async function resetState(): Promise<void> { 570 - await app.resetAppData() 571 - } 572 551 573 552 export default app
+15 -369
www/routes/settings.ts
··· 1 1 import { html, LitElement, TemplateResult } from 'lit' 2 + import type { UiDataActionMethods } from '@civility/ui' 2 3 import getString from '$/utils/get_string.ts' 3 - import app, { exportState, resetState } from '$/models/app.ts' 4 - import { subjects } from '$/models/subjects.ts' 4 + import app from '$/models/app.ts' 5 5 import { CardSortMethod } from '@flashcard/core' 6 6 7 - interface ImportedData { 8 - version?: string 9 - exportedAt?: string 10 - documents?: { 11 - settings?: { 12 - name?: string 13 - version?: string 14 - exportedAt?: string 15 - documents?: Record<string, { id?: string; data?: { locale?: string } }> 16 - } 17 - flags?: Record<string, unknown> 18 - userLevels?: { 19 - name?: string 20 - version?: string 21 - exportedAt?: string 22 - documents?: Record<string, { id?: string; data?: Record<string, number> }> 23 - } 24 - stats?: Record<string, unknown> 25 - searchHistory?: Record<string, unknown> 26 - } 27 - collections?: { 28 - assignments?: { 29 - name?: string 30 - version?: string 31 - exportedAt?: string 32 - documents?: Record<string, { 33 - id?: string 34 - data?: { subjectId: string; locale?: string; unlockedAt?: number } 35 - }> 36 - } 37 - overrides?: Record<string, unknown> 38 - journal?: Record<string, unknown> 39 - } 40 - // Legacy format 41 - settings?: { locale?: string } 42 - assignments?: Record< 43 - string, 44 - Record<string, { 45 - subjectId: string 46 - unlockedAt?: number 47 - }> 48 - > 49 - userLevels?: Record<string, number> 50 - } 51 - 52 7 type NumInputKey = 53 8 | 'learnLimit' 54 9 | 'learnSessionSize' ··· 56 11 | 'reviewSessionSize' 57 12 58 13 export class SettingsRoute extends LitElement { 59 - #importProgress: ImportedData | undefined = undefined 60 - #importProgressName = '' 61 - #syncLinkUrl = '' 62 - #isSyncing = false 63 - #syncError: string | null = null 64 - #syncSuccess: string | null = null 65 - #syncMsgTimeout: number | null = null 66 - 67 14 #onUpdate = () => this.requestUpdate() 68 15 69 16 override createRenderRoot() { ··· 72 19 73 20 override connectedCallback() { 74 21 super.connectedCallback() 75 - this.#importProgress = undefined 76 - this.#importProgressName = '' 77 - this.#isSyncing = false 78 - this.#syncError = null 79 - this.#syncSuccess = null 80 - 81 - try { 82 - const stored = localStorage.getItem('hanzi-sync-url') 83 - if (stored) { 84 - const parsed = JSON.parse(stored) 85 - this.#syncLinkUrl = parsed.data?.url || '' 86 - } 87 - } catch { 88 - this.#syncLinkUrl = '' 89 - } 90 - 91 22 app.addEventListener(this.#onUpdate) 92 23 globalThis.scrollTo(0, 0) 93 24 } ··· 95 26 override disconnectedCallback() { 96 27 super.disconnectedCallback() 97 28 app.removeEventListener(this.#onUpdate) 98 - if (this.#syncMsgTimeout !== null) { 99 - clearTimeout(this.#syncMsgTimeout) 100 - this.#syncMsgTimeout = null 101 - } 102 - } 103 - 104 - #setSyncMessage(msg: { error?: string; success?: string }) { 105 - if (this.#syncMsgTimeout !== null) clearTimeout(this.#syncMsgTimeout) 106 - this.#syncError = msg.error ?? null 107 - this.#syncSuccess = msg.success ?? null 108 - this.requestUpdate() 109 - this.#syncMsgTimeout = setTimeout(() => { 110 - this.#syncError = null 111 - this.#syncSuccess = null 112 - this.#syncMsgTimeout = null 113 - this.requestUpdate() 114 - }, 3000) as unknown as number 115 29 } 116 30 117 31 #renderNumInput( ··· 200 114 ` 201 115 } 202 116 203 - #renderImportPreview(): TemplateResult { 204 - const progress = this.#importProgress 205 - if (!progress) { 206 - return html` 207 - 208 - ` 209 - } 210 - 211 - const importAssignments: Record< 212 - string, 213 - { subjectId: string; unlockedAt?: number } 214 - > = {} 215 - const docs = progress.collections?.assignments?.documents || {} as Record< 216 - string, 217 - { data: { subjectId: string; locale?: string; unlockedAt?: number } } 218 - > 219 - const settingsDoc = progress.documents?.settings?.documents?.['_doc'] 220 - ?.data 221 - const userLevelsDoc = progress.documents?.userLevels?.documents?.['_doc'] 222 - ?.data 223 - const locale = settingsDoc?.locale || app.locale 224 - const importLevel: number | undefined = userLevelsDoc?.[locale] 225 - 226 - for (const [_key, entry] of Object.entries(docs)) { 227 - const entryLocale = entry.data.locale || (_key.split('__')[0] || locale) 228 - if (entryLocale === locale) { 229 - importAssignments[entry.data.subjectId] = entry.data 230 - } 231 - } 232 - 233 - const dismiss = () => { 234 - this.#importProgress = undefined 235 - this.#importProgressName = '' 236 - this.requestUpdate() 237 - } 238 - 239 - return html` 240 - <ui-dialog ?open="${!!this.#importProgress}" @dismiss="${dismiss}"> 241 - <dialog> 242 - <article> 243 - <h2>${getString('override_progress_prompt')}</h2> 244 - <p><b>${getString('filename')}</b>: <span>${this 245 - .#importProgressName}</span></p> 246 - <p> 247 - <b>${getString('level')}</b>: 248 - <span>${importLevel}</span> 249 - </p> 250 - <p> 251 - <b>${getString('assignments')}</b>: 252 - <span>${Object.keys(importAssignments).length}</span> 253 - </p> 254 - <p><b>${getString('last_unlocked')}</b></p> 255 - <div class="flex flex-wrap"> 256 - ${Object.values(importAssignments) 257 - .sort((a, b) => { 258 - if (!a?.unlockedAt) return -1 259 - if (!b?.unlockedAt) return 1 260 - return Number(b.unlockedAt) - Number(a.unlockedAt) 261 - }) 262 - .reverse() 263 - .slice(0, 20) 264 - .map( 265 - (assignment: { subjectId: string; unlockedAt?: number }) => { 266 - const subject = subjects.state.byId[assignment.subjectId] 267 - const chars = subject?.data?.character 268 - return html` 269 - <span class="purple-dots cursor tc pa1 ma1 b1 br2">${chars || 270 - ''}</span> 271 - ` 272 - }, 273 - )} 274 - </div> 275 - <br /> 276 - <button 277 - style="margin: auto; display: block" 278 - type="button" 279 - @click="${async () => { 280 - try { 281 - if (!this.#importProgress) return 282 - await app.importData(this.#importProgress) 283 - this.#importProgress = undefined 284 - this.#importProgressName = '' 285 - location.replace('/') 286 - } catch (e) { 287 - globalThis.alert(`import failed: ${e}`) 288 - } 289 - }}" 290 - > 291 - ${getString('import_now')} 292 - </button> 293 - <button 294 - style="margin: auto; display: block" 295 - type="button" 296 - @click="${dismiss}" 297 - > 298 - ${getString('cancel')} 299 - </button> 300 - </article> 301 - </dialog> 302 - </ui-dialog> 303 - ` 304 - } 305 - 306 117 override render() { 307 118 return html` 308 119 <h2>${getString('Version')}</h2> ··· 379 190 <h2>${getString('card_order')}</h2> 380 191 ${this.#renderCardSortSelect()} 381 192 193 + <h2>${getString('sync')}</h2> 194 + <ui-sync 195 + storage-key="hanzi-sync" 196 + .synced="${app.synced}" 197 + ></ui-sync> 198 + 382 199 <h2>${getString('progress')}</h2> 383 - <div class="full-button"> 384 - <button 385 - @click="${() => { 386 - const input = document.createElement('input') 387 - input.type = 'file' 388 - input.accept = '.json' 389 - input.onchange = async () => { 390 - const file = input.files?.[0] 391 - if (!file) return 392 - try { 393 - const content = JSON.parse(await file.text()) 394 - this.#importProgress = content as ImportedData 395 - this.#importProgressName = file.name 396 - this.requestUpdate() 397 - } catch (e) { 398 - globalThis.alert(`Failed to read file: ${e}`) 399 - } 400 - } 401 - input.click() 402 - }}" 403 - > 404 - ${getString('import_progress')} 405 - </button> 406 - </div> 407 - <div class="full-button"> 408 - <button @click="${() => exportState()}">${getString( 409 - 'export_progress', 410 - )}</button> 411 - </div> 412 - <div class="full-button"> 413 - <button 414 - @click="${async () => { 415 - if ( 416 - globalThis.confirm('Are you sure? This will delete everything.') 417 - ) { 418 - await resetState() 419 - location.assign('/') 420 - } 421 - }}" 422 - > 423 - ${getString('reset_progress')} 424 - </button> 425 - </div> 200 + <ui-data-actions 201 + .methods="${{ 202 + exportData: (filename?: string) => app.exportStore(filename), 203 + importData: () => app.importStore(), 204 + deleteAllData: () => app.deleteAllData(), 205 + } as UiDataActionMethods}" 206 + ></ui-data-actions> 426 207 <div class="full-button"> 427 208 <button 428 209 @click="${async () => { ··· 438 219 ${getString('reset_help_dialogs')} 439 220 </button> 440 221 </div> 441 - ${this.#renderImportPreview()} 442 - 443 - <h2>${getString('sync')}</h2> 444 - ${this.#syncError 445 - ? html` 446 - <div class="error pa2 ma2 ba b--red bg-washed-red"> 447 - ${this.#syncError} 448 - <button 449 - class="ml2" 450 - @click="${() => { 451 - this.#syncError = null 452 - this.requestUpdate() 453 - }}" 454 - > 455 - × 456 - </button> 457 - </div> 458 - ` 459 - : ''} ${this.#syncSuccess 460 - ? html` 461 - <div class="success pa2 ma2 ba b--green bg-washed-green"> 462 - ${this.#syncSuccess} 463 - </div> 464 - ` 465 - : ''} 466 - <div class="item"> 467 - <label for="syncLinkUrl">${getString('sync_url')}</label> 468 - <input 469 - class="blue-shadow" 470 - name="syncLinkUrl" 471 - type="url" 472 - placeholder="https://sync.example.com/apps/your-app?token=..." 473 - .value="${this.#syncLinkUrl}" 474 - @input="${(e: InputEvent) => { 475 - this.#syncLinkUrl = (e.target as HTMLInputElement).value 476 - }}" 477 - /> 478 - </div> 479 - <div class="full-button"> 480 - <button 481 - ?disabled="${this.#isSyncing || !this.#syncLinkUrl.trim()}" 482 - @click="${async () => { 483 - if (!this.#syncLinkUrl.trim()) { 484 - this.#setSyncMessage({ error: 'Please enter a SyncLink URL' }) 485 - return 486 - } 487 - try { 488 - this.#isSyncing = true 489 - this.#syncError = null 490 - this.#syncSuccess = null 491 - this.requestUpdate() 492 - await app.setSyncUrl(this.#syncLinkUrl) 493 - await app.store.testConnection() 494 - this.#setSyncMessage({ 495 - success: 'Successfully connected to SyncLink!', 496 - }) 497 - } catch (e) { 498 - this.#syncError = `Connection failed: ${e}` 499 - this.requestUpdate() 500 - } finally { 501 - this.#isSyncing = false 502 - this.requestUpdate() 503 - } 504 - }}" 505 - > 506 - ${getString( 507 - this.#isSyncing ? 'testing_connection' : 'test_connection', 508 - )} 509 - </button> 510 - </div> 511 - ${app.isConnected 512 - ? html` 513 - <div class="item pa2 ma2 ba b--green bg-washed-green"> 514 - <span>${getString('sync_active')}</span> 515 - </div> 516 - <div class="full-button"> 517 - <button 518 - ?disabled="${this.#isSyncing}" 519 - @click="${async () => { 520 - try { 521 - this.#isSyncing = true 522 - this.#syncError = null 523 - this.#syncSuccess = null 524 - this.requestUpdate() 525 - await app.store.syncFromServer() 526 - this.#setSyncMessage({ success: 'Synced from server!' }) 527 - } catch (e) { 528 - this.#syncError = `Sync failed: ${e}` 529 - this.requestUpdate() 530 - } finally { 531 - this.#isSyncing = false 532 - this.requestUpdate() 533 - } 534 - }}" 535 - > 536 - ${getString(this.#isSyncing ? 'syncing' : 'sync_from_server')} 537 - </button> 538 - </div> 539 - <div class="full-button"> 540 - <button 541 - ?disabled="${this.#isSyncing}" 542 - @click="${async () => { 543 - try { 544 - this.#isSyncing = true 545 - this.#syncError = null 546 - this.#syncSuccess = null 547 - this.requestUpdate() 548 - await app.store.syncToServer() 549 - this.#setSyncMessage({ success: 'Synced to server!' }) 550 - } catch (e) { 551 - this.#syncError = `Sync failed: ${e}` 552 - this.requestUpdate() 553 - } finally { 554 - this.#isSyncing = false 555 - this.requestUpdate() 556 - } 557 - }}" 558 - > 559 - ${getString(this.#isSyncing ? 'syncing' : 'sync_to_server')} 560 - </button> 561 - </div> 562 - <div class="full-button"> 563 - <button 564 - class="bg-light-red" 565 - @click="${() => { 566 - app.disconnectStore() 567 - this.#syncLinkUrl = '' 568 - this.requestUpdate() 569 - }}" 570 - > 571 - ${getString('disconnect')} 572 - </button> 573 - </div> 574 - ` 575 - : ''} 576 222 ` 577 223 } 578 224 }
+23
www/routes/settings/about.ts
··· 30 30 'open_source', 31 31 )}</a> 32 32 </p> 33 + <section style="text-align: center; padding-top: var(--s4);"> 34 + <p> 35 + <a 36 + href="https://apps.bpev.me" 37 + rel="noopener noreferrer" 38 + target="_blank" 39 + style="font-size: var(--f3); color: black;" 40 + >Made by Ben</a> 41 + </p> 42 + 43 + <a 44 + href="https://ko-fi.com/O5O21ETSMZ" 45 + target="_blank" 46 + style="cursor: pointer;" 47 + > 48 + <img 49 + height="36" 50 + style="border:0px;height:36px;" 51 + src="https://storage.ko-fi.com/cdn/kofi5.png?v=6" 52 + border="0" 53 + alt="Buy Me a Coffee at ko-fi.com" 54 + ></a> 55 + </section> 33 56 <section> 34 57 <h2>Acknowledgements</h2> 35 58 <p>Word lists were compiled from:</p>
+5 -8
www/utils/custom_sets.ts
··· 13 13 subjects: unknown[] 14 14 } 15 15 16 - const storage = new IDBStorage<CustomSetData>({ dbName: 'hanzi-custom-sets' }) 17 - const customSets = new Collection<CustomSetData>(storage, { 18 - name: 'sets', 16 + const storage = new IDBStorage<CustomSetData>({ 17 + dbName: 'bpev-hanzi-custom-sets' 19 18 }) 19 + const customSets = new Collection<CustomSetData>(storage, { name: 'sets' }) 20 20 21 21 let cachedSets: CustomSet[] = [] 22 22 23 23 async function refreshCache() { 24 24 const all = await customSets.getAll() 25 - cachedSets = Array.from(all.entries()).map(([id, { name, locale }]) => ({ 26 - id, 27 - name, 28 - locale, 29 - })) 25 + cachedSets = Array.from(all.entries()) 26 + .map(([id, { name, locale }]) => ({ id, name, locale })) 30 27 } 31 28 32 29 refreshCache()