An app for logging board climbs
0
fork

Configure Feed

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

feat: move to civility 0.2

+450 -814
+4 -2
deno.json
··· 1 1 { 2 - "version": "0.1.0", 2 + "version": "0.1.1", 3 3 "compilerOptions": { 4 4 "lib": [ 5 5 "deno.ns", ··· 21 21 "fetch:benchmarks": "deno run --allow-net --allow-read --allow-write --allow-env scripts/moon.ts" 22 22 }, 23 23 "imports": { 24 - "@bpev/civility": "jsr:@bpev/civility@^0.1.1", 25 24 "@bpev/sync-link": "jsr:@bpev/sync-link@^0.0.17", 25 + "@civility/store": "jsr:@civility/store@^0.2.0", 26 + "@civility/ui": "jsr:@civility/ui@^0.2.0", 27 + "@civility/workers": "jsr:@civility/workers@^0.2.0", 26 28 "@inro/simple-tools": "jsr:@inro/simple-tools@^0.5.2", 27 29 "@std/assert": "jsr:@std/assert@^1.0.19", 28 30 "@std/dotenv": "jsr:@std/dotenv@^0.225.6",
+18 -449
deno.lock
··· 1 1 { 2 2 "version": "5", 3 3 "specifiers": { 4 - "jsr:@bpev/civility@~0.1.1": "0.1.1", 5 4 "jsr:@bpev/sync-link@^0.0.17": "0.0.17", 6 - "jsr:@cliffy/ansi@1.0.0": "1.0.0", 7 - "jsr:@cliffy/command@1.0.0": "1.0.0", 8 - "jsr:@cliffy/flags@1.0.0": "1.0.0", 9 - "jsr:@cliffy/internal@1.0.0": "1.0.0", 10 - "jsr:@cliffy/table@1.0.0": "1.0.0", 5 + "jsr:@civility/store@0.2": "0.2.0", 6 + "jsr:@civility/ui@0.2": "0.2.0", 7 + "jsr:@civility/workers@0.2": "0.2.0", 11 8 "jsr:@inro/simple-tools@0.5.2": "0.5.2", 12 9 "jsr:@inro/simple-tools@~0.5.2": "0.5.2", 13 - "jsr:@luca/esbuild-deno-loader@~0.11.1": "0.11.1", 14 10 "jsr:@paulmillr/qr@~0.5.2": "0.5.4", 15 - "jsr:@rodney/parsedown@^1.4.3": "1.4.3", 16 11 "jsr:@std/assert@^1.0.17": "1.0.19", 17 12 "jsr:@std/assert@^1.0.19": "1.0.19", 18 - "jsr:@std/bytes@^1.0.2": "1.0.6", 19 - "jsr:@std/cli@^1.0.28": "1.0.28", 20 13 "jsr:@std/collections@^1.1.0": "1.1.6", 21 - "jsr:@std/collections@^1.1.3": "1.1.6", 22 14 "jsr:@std/data-structures@^1.0.10": "1.0.10", 23 15 "jsr:@std/dotenv@~0.225.6": "0.225.6", 24 - "jsr:@std/encoding@^1.0.10": "1.0.10", 25 - "jsr:@std/encoding@^1.0.5": "1.0.10", 26 - "jsr:@std/fmt@^1.0.9": "1.0.9", 27 - "jsr:@std/front-matter@^1.0.9": "1.0.9", 28 16 "jsr:@std/fs@^1.0.17": "1.0.23", 29 17 "jsr:@std/fs@^1.0.22": "1.0.23", 30 18 "jsr:@std/fs@^1.0.23": "1.0.23", 31 19 "jsr:@std/html@^1.0.5": "1.0.5", 32 - "jsr:@std/http@^1.0.25": "1.0.25", 33 20 "jsr:@std/internal@^1.0.12": "1.0.12", 34 - "jsr:@std/media-types@^1.1.0": "1.1.0", 35 - "jsr:@std/net@^1.0.6": "1.0.6", 36 - "jsr:@std/path@^1.0.6": "1.1.4", 37 21 "jsr:@std/path@^1.1.4": "1.1.4", 38 - "jsr:@std/streams@^1.0.17": "1.0.17", 39 22 "jsr:@std/testing@^1.0.17": "1.0.17", 40 - "jsr:@std/text@^1.0.17": "1.0.17", 41 - "jsr:@std/toml@^1.0.3": "1.0.11", 42 - "jsr:@std/yaml@^1.0.5": "1.0.12", 43 23 "npm:@hono/zod-openapi@^1.1.0": "1.2.2_hono@4.12.3_zod@4.3.6", 44 24 "npm:@tauri-apps/plugin-store@^2.2.0": "2.4.2", 45 - "npm:cheerio@^1.2.0": "1.2.0", 46 - "npm:esbuild@~0.27.3": "0.27.3", 47 25 "npm:hammerjs@^2.0.8": "2.0.8", 48 26 "npm:lit@^3.3.2": "3.3.2", 49 27 "npm:native-file-system-adapter@^3.0.1": "3.0.1", ··· 51 29 "npm:zod@^4.3.6": "4.3.6" 52 30 }, 53 31 "jsr": { 54 - "@bpev/civility@0.1.1": { 55 - "integrity": "f52813cc0e20345a033766174dd6c467f4dc8f59026a4d6d78375fc57e72dcf6", 56 - "dependencies": [ 57 - "jsr:@cliffy/ansi", 58 - "jsr:@cliffy/command", 59 - "jsr:@luca/esbuild-deno-loader", 60 - "jsr:@rodney/parsedown", 61 - "jsr:@std/front-matter", 62 - "jsr:@std/fs@^1.0.23", 63 - "jsr:@std/html", 64 - "jsr:@std/http", 65 - "jsr:@std/path@^1.1.4", 66 - "npm:cheerio", 67 - "npm:esbuild", 68 - "npm:lit" 69 - ] 70 - }, 71 32 "@bpev/sync-link@0.0.17": { 72 33 "integrity": "a21a2994482b64a90da7061b113703e07f5af90498474103e3ce073118871ee6", 73 34 "dependencies": [ ··· 77 38 "npm:native-file-system-adapter" 78 39 ] 79 40 }, 80 - "@cliffy/ansi@1.0.0": { 81 - "integrity": "987008f74e50aa72cc1517ffccc769711734a14927bc4599e052efe1b9a840e2", 41 + "@civility/store@0.2.0": { 42 + "integrity": "7afac8051b7b76c299d93577e403cbf494cdd2798e2c3f4f5b9b7b0fcb44a9fb", 82 43 "dependencies": [ 83 - "jsr:@std/fmt" 44 + "jsr:@std/fs@^1.0.23" 84 45 ] 85 46 }, 86 - "@cliffy/command@1.0.0": { 87 - "integrity": "c52a241ea68857fcdaff4f3173eb404f8017d7bc35553b6f533c592b89dde7d2", 47 + "@civility/ui@0.2.0": { 48 + "integrity": "75dd43f74906bfd8f1c9800f7a8070648434c28ad8c4aefe6477a92aa94b9ff7", 88 49 "dependencies": [ 89 - "jsr:@cliffy/flags", 90 - "jsr:@cliffy/internal", 91 - "jsr:@cliffy/table", 92 - "jsr:@std/fmt", 93 - "jsr:@std/text" 50 + "jsr:@std/html", 51 + "npm:lit" 94 52 ] 95 53 }, 96 - "@cliffy/flags@1.0.0": { 97 - "integrity": "8b57698adc644da8f90422d58976362d41a4ebca39c312ca1c101585d0148feb", 98 - "dependencies": [ 99 - "jsr:@cliffy/internal", 100 - "jsr:@std/text" 101 - ] 102 - }, 103 - "@cliffy/internal@1.0.0": { 104 - "integrity": "1e17ccbcd5420093c0a93e5b3827bbdc9abac5195bacf187edc44665e54bdde6" 105 - }, 106 - "@cliffy/table@1.0.0": { 107 - "integrity": "3fdaa9e1ef1ea62022108adabd826932bdea8dd05497079896febcd41322907f", 108 - "dependencies": [ 109 - "jsr:@std/fmt" 110 - ] 54 + "@civility/workers@0.2.0": { 55 + "integrity": "a733044797e1a2579254b8090dd50ef0409c0b213ed66745d95db4870140e3dc" 111 56 }, 112 57 "@inro/simple-tools@0.5.2": { 113 58 "integrity": "cc34cd0914b9e0576d9bed9a66a91994123b73f3fd87a4e8db76880181731ee5", 114 59 "dependencies": [ 115 - "jsr:@std/collections@^1.1.0", 60 + "jsr:@std/collections", 116 61 "jsr:@std/fs@^1.0.17", 117 62 "npm:@tauri-apps/plugin-store", 118 63 "npm:ts-fsrs" 119 64 ] 120 65 }, 121 - "@luca/esbuild-deno-loader@0.11.1": { 122 - "integrity": "dc020d16d75b591f679f6b9288b10f38bdb4f24345edb2f5732affa1d9885267", 123 - "dependencies": [ 124 - "jsr:@std/bytes", 125 - "jsr:@std/encoding@^1.0.5", 126 - "jsr:@std/path@^1.0.6" 127 - ] 128 - }, 129 66 "@paulmillr/qr@0.5.4": { 130 67 "integrity": "b9c40e31104c63700df2c37c5d2674770112c75713d7dd1b922a23926500b26b" 131 - }, 132 - "@rodney/parsedown@1.4.3": { 133 - "integrity": "fd5cbee4554286fc835a0157f7cb28d2c4de6ac82ed62b6b2f91291eaa9fbb2f" 134 68 }, 135 69 "@std/assert@1.0.19": { 136 70 "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", ··· 138 72 "jsr:@std/internal" 139 73 ] 140 74 }, 141 - "@std/bytes@1.0.6": { 142 - "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" 143 - }, 144 - "@std/cli@1.0.28": { 145 - "integrity": "74ef9b976db59ca6b23a5283469c9072be6276853807a83ec6c7ce412135c70a" 146 - }, 147 75 "@std/collections@1.1.6": { 148 76 "integrity": "b458160ce65ea5ad35da05d0a5cbee4b583677c8b443a10d7beb0c4ac63f2baa" 149 77 }, ··· 153 81 "@std/dotenv@0.225.6": { 154 82 "integrity": "1d6f9db72f565bd26790fa034c26e45ecb260b5245417be76c2279e5734c421b" 155 83 }, 156 - "@std/encoding@1.0.10": { 157 - "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" 158 - }, 159 - "@std/fmt@1.0.9": { 160 - "integrity": "2487343e8899fb2be5d0e3d35013e54477ada198854e52dd05ed0422eddcabe0" 161 - }, 162 - "@std/front-matter@1.0.9": { 163 - "integrity": "ee6201d06674cbef137dda2252f62477450b48249e7d8d9ab57a30f85ff6f051", 164 - "dependencies": [ 165 - "jsr:@std/toml", 166 - "jsr:@std/yaml" 167 - ] 168 - }, 169 84 "@std/fs@1.0.23": { 170 85 "integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37", 171 86 "dependencies": [ 172 - "jsr:@std/internal", 173 - "jsr:@std/path@^1.1.4" 87 + "jsr:@std/path" 174 88 ] 175 89 }, 176 90 "@std/html@1.0.5": { 177 91 "integrity": "4e2d693f474cae8c16a920fa5e15a3b72267b94b84667f11a50c6dd1cb18d35e" 178 92 }, 179 - "@std/http@1.0.25": { 180 - "integrity": "577b4252290af1097132812b339fffdd55fb0f4aeb98ff11bdbf67998aa17193", 181 - "dependencies": [ 182 - "jsr:@std/cli", 183 - "jsr:@std/encoding@^1.0.10", 184 - "jsr:@std/fmt", 185 - "jsr:@std/fs@^1.0.23", 186 - "jsr:@std/html", 187 - "jsr:@std/media-types", 188 - "jsr:@std/net", 189 - "jsr:@std/path@^1.1.4", 190 - "jsr:@std/streams" 191 - ] 192 - }, 193 93 "@std/internal@1.0.12": { 194 94 "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" 195 95 }, 196 - "@std/media-types@1.1.0": { 197 - "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" 198 - }, 199 - "@std/net@1.0.6": { 200 - "integrity": "110735f93e95bb9feb95790a8b1d1bf69ec0dc74f3f97a00a76ea5efea25500c" 201 - }, 202 96 "@std/path@1.1.4": { 203 97 "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", 204 98 "dependencies": [ 205 99 "jsr:@std/internal" 206 100 ] 207 101 }, 208 - "@std/streams@1.0.17": { 209 - "integrity": "7859f3d9deed83cf4b41f19223d4a67661b3d3819e9fc117698f493bf5992140" 210 - }, 211 102 "@std/testing@1.0.17": { 212 103 "integrity": "87bdc2700fa98249d48a17cd72413352d3d3680dcfbdb64947fd0982d6bbf681", 213 104 "dependencies": [ ··· 215 106 "jsr:@std/data-structures", 216 107 "jsr:@std/fs@^1.0.22", 217 108 "jsr:@std/internal", 218 - "jsr:@std/path@^1.1.4" 109 + "jsr:@std/path" 219 110 ] 220 - }, 221 - "@std/text@1.0.17": { 222 - "integrity": "4b2c4ef67ae5b6c1dfd447c81c83a43718f52e3c7e748d8b33f694aba9895f95" 223 - }, 224 - "@std/toml@1.0.11": { 225 - "integrity": "e084988b872ca4bad6aedfb7350f6eeed0e8ba88e9ee5e1590621c5b5bb8f715", 226 - "dependencies": [ 227 - "jsr:@std/collections@^1.1.3" 228 - ] 229 - }, 230 - "@std/yaml@1.0.12": { 231 - "integrity": "7deabca4545bcedd07c5f69ea53acea71b8b4c67562f224e17b90d75944cb20c" 232 111 } 233 112 }, 234 113 "npm": { ··· 239 118 "zod" 240 119 ] 241 120 }, 242 - "@esbuild/aix-ppc64@0.27.3": { 243 - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", 244 - "os": ["aix"], 245 - "cpu": ["ppc64"] 246 - }, 247 - "@esbuild/android-arm64@0.27.3": { 248 - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", 249 - "os": ["android"], 250 - "cpu": ["arm64"] 251 - }, 252 - "@esbuild/android-arm@0.27.3": { 253 - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", 254 - "os": ["android"], 255 - "cpu": ["arm"] 256 - }, 257 - "@esbuild/android-x64@0.27.3": { 258 - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", 259 - "os": ["android"], 260 - "cpu": ["x64"] 261 - }, 262 - "@esbuild/darwin-arm64@0.27.3": { 263 - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", 264 - "os": ["darwin"], 265 - "cpu": ["arm64"] 266 - }, 267 - "@esbuild/darwin-x64@0.27.3": { 268 - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", 269 - "os": ["darwin"], 270 - "cpu": ["x64"] 271 - }, 272 - "@esbuild/freebsd-arm64@0.27.3": { 273 - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", 274 - "os": ["freebsd"], 275 - "cpu": ["arm64"] 276 - }, 277 - "@esbuild/freebsd-x64@0.27.3": { 278 - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", 279 - "os": ["freebsd"], 280 - "cpu": ["x64"] 281 - }, 282 - "@esbuild/linux-arm64@0.27.3": { 283 - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", 284 - "os": ["linux"], 285 - "cpu": ["arm64"] 286 - }, 287 - "@esbuild/linux-arm@0.27.3": { 288 - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", 289 - "os": ["linux"], 290 - "cpu": ["arm"] 291 - }, 292 - "@esbuild/linux-ia32@0.27.3": { 293 - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", 294 - "os": ["linux"], 295 - "cpu": ["ia32"] 296 - }, 297 - "@esbuild/linux-loong64@0.27.3": { 298 - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", 299 - "os": ["linux"], 300 - "cpu": ["loong64"] 301 - }, 302 - "@esbuild/linux-mips64el@0.27.3": { 303 - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", 304 - "os": ["linux"], 305 - "cpu": ["mips64el"] 306 - }, 307 - "@esbuild/linux-ppc64@0.27.3": { 308 - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", 309 - "os": ["linux"], 310 - "cpu": ["ppc64"] 311 - }, 312 - "@esbuild/linux-riscv64@0.27.3": { 313 - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", 314 - "os": ["linux"], 315 - "cpu": ["riscv64"] 316 - }, 317 - "@esbuild/linux-s390x@0.27.3": { 318 - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", 319 - "os": ["linux"], 320 - "cpu": ["s390x"] 321 - }, 322 - "@esbuild/linux-x64@0.27.3": { 323 - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", 324 - "os": ["linux"], 325 - "cpu": ["x64"] 326 - }, 327 - "@esbuild/netbsd-arm64@0.27.3": { 328 - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", 329 - "os": ["netbsd"], 330 - "cpu": ["arm64"] 331 - }, 332 - "@esbuild/netbsd-x64@0.27.3": { 333 - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", 334 - "os": ["netbsd"], 335 - "cpu": ["x64"] 336 - }, 337 - "@esbuild/openbsd-arm64@0.27.3": { 338 - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", 339 - "os": ["openbsd"], 340 - "cpu": ["arm64"] 341 - }, 342 - "@esbuild/openbsd-x64@0.27.3": { 343 - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", 344 - "os": ["openbsd"], 345 - "cpu": ["x64"] 346 - }, 347 - "@esbuild/openharmony-arm64@0.27.3": { 348 - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", 349 - "os": ["openharmony"], 350 - "cpu": ["arm64"] 351 - }, 352 - "@esbuild/sunos-x64@0.27.3": { 353 - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", 354 - "os": ["sunos"], 355 - "cpu": ["x64"] 356 - }, 357 - "@esbuild/win32-arm64@0.27.3": { 358 - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", 359 - "os": ["win32"], 360 - "cpu": ["arm64"] 361 - }, 362 - "@esbuild/win32-ia32@0.27.3": { 363 - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", 364 - "os": ["win32"], 365 - "cpu": ["ia32"] 366 - }, 367 - "@esbuild/win32-x64@0.27.3": { 368 - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", 369 - "os": ["win32"], 370 - "cpu": ["x64"] 371 - }, 372 121 "@hono/zod-openapi@1.2.2_hono@4.12.3_zod@4.3.6": { 373 122 "integrity": "sha512-va6vsL23wCJ1d0Vd+vGL1XOt+wPwItxirYafuhlW9iC2MstYr2FvsI7mctb45eBTjZfkqB/3LYDJEppPjOEiHw==", 374 123 "dependencies": [ ··· 407 156 "@types/trusted-types@2.0.7": { 408 157 "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" 409 158 }, 410 - "boolbase@1.0.0": { 411 - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" 412 - }, 413 - "cheerio-select@2.1.0": { 414 - "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", 415 - "dependencies": [ 416 - "boolbase", 417 - "css-select", 418 - "css-what", 419 - "domelementtype", 420 - "domhandler", 421 - "domutils" 422 - ] 423 - }, 424 - "cheerio@1.2.0": { 425 - "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", 426 - "dependencies": [ 427 - "cheerio-select", 428 - "dom-serializer", 429 - "domhandler", 430 - "domutils", 431 - "encoding-sniffer", 432 - "htmlparser2", 433 - "parse5", 434 - "parse5-htmlparser2-tree-adapter", 435 - "parse5-parser-stream", 436 - "undici", 437 - "whatwg-mimetype" 438 - ] 439 - }, 440 - "css-select@5.2.2": { 441 - "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", 442 - "dependencies": [ 443 - "boolbase", 444 - "css-what", 445 - "domhandler", 446 - "domutils", 447 - "nth-check" 448 - ] 449 - }, 450 - "css-what@6.2.2": { 451 - "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==" 452 - }, 453 - "dom-serializer@2.0.0": { 454 - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", 455 - "dependencies": [ 456 - "domelementtype", 457 - "domhandler", 458 - "entities@4.5.0" 459 - ] 460 - }, 461 - "domelementtype@2.3.0": { 462 - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==" 463 - }, 464 - "domhandler@5.0.3": { 465 - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", 466 - "dependencies": [ 467 - "domelementtype" 468 - ] 469 - }, 470 - "domutils@3.2.2": { 471 - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", 472 - "dependencies": [ 473 - "dom-serializer", 474 - "domelementtype", 475 - "domhandler" 476 - ] 477 - }, 478 - "encoding-sniffer@0.2.1": { 479 - "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", 480 - "dependencies": [ 481 - "iconv-lite", 482 - "whatwg-encoding" 483 - ] 484 - }, 485 - "entities@4.5.0": { 486 - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" 487 - }, 488 - "entities@6.0.1": { 489 - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==" 490 - }, 491 - "entities@7.0.1": { 492 - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==" 493 - }, 494 - "esbuild@0.27.3": { 495 - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", 496 - "optionalDependencies": [ 497 - "@esbuild/aix-ppc64", 498 - "@esbuild/android-arm", 499 - "@esbuild/android-arm64", 500 - "@esbuild/android-x64", 501 - "@esbuild/darwin-arm64", 502 - "@esbuild/darwin-x64", 503 - "@esbuild/freebsd-arm64", 504 - "@esbuild/freebsd-x64", 505 - "@esbuild/linux-arm", 506 - "@esbuild/linux-arm64", 507 - "@esbuild/linux-ia32", 508 - "@esbuild/linux-loong64", 509 - "@esbuild/linux-mips64el", 510 - "@esbuild/linux-ppc64", 511 - "@esbuild/linux-riscv64", 512 - "@esbuild/linux-s390x", 513 - "@esbuild/linux-x64", 514 - "@esbuild/netbsd-arm64", 515 - "@esbuild/netbsd-x64", 516 - "@esbuild/openbsd-arm64", 517 - "@esbuild/openbsd-x64", 518 - "@esbuild/openharmony-arm64", 519 - "@esbuild/sunos-x64", 520 - "@esbuild/win32-arm64", 521 - "@esbuild/win32-ia32", 522 - "@esbuild/win32-x64" 523 - ], 524 - "scripts": true, 525 - "bin": true 526 - }, 527 159 "fetch-blob@3.2.0": { 528 160 "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", 529 161 "dependencies": [ ··· 537 169 "hono@4.12.3": { 538 170 "integrity": "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==" 539 171 }, 540 - "htmlparser2@10.1.0": { 541 - "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", 542 - "dependencies": [ 543 - "domelementtype", 544 - "domhandler", 545 - "domutils", 546 - "entities@7.0.1" 547 - ] 548 - }, 549 - "iconv-lite@0.6.3": { 550 - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", 551 - "dependencies": [ 552 - "safer-buffer" 553 - ] 554 - }, 555 172 "lit-element@4.2.2": { 556 173 "integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==", 557 174 "dependencies": [ ··· 584 201 "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", 585 202 "deprecated": true 586 203 }, 587 - "nth-check@2.1.1": { 588 - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", 589 - "dependencies": [ 590 - "boolbase" 591 - ] 592 - }, 593 204 "openapi3-ts@4.5.0": { 594 205 "integrity": "sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==", 595 206 "dependencies": [ 596 207 "yaml" 597 208 ] 598 209 }, 599 - "parse5-htmlparser2-tree-adapter@7.1.0": { 600 - "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", 601 - "dependencies": [ 602 - "domhandler", 603 - "parse5" 604 - ] 605 - }, 606 - "parse5-parser-stream@7.1.2": { 607 - "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", 608 - "dependencies": [ 609 - "parse5" 610 - ] 611 - }, 612 - "parse5@7.3.0": { 613 - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", 614 - "dependencies": [ 615 - "entities@6.0.1" 616 - ] 617 - }, 618 - "safer-buffer@2.1.2": { 619 - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" 620 - }, 621 210 "ts-fsrs@5.2.3": { 622 211 "integrity": "sha512-R3IjceC9WfnvUin6Nx+DwqEzh3Qil6Gg2yEHqvocUcC7Nbi+xDrFg/1fKaYBT0tJedDnDAguXMSX0hijhi859w==" 623 212 }, 624 - "undici@7.22.0": { 625 - "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==" 626 - }, 627 213 "web-streams-polyfill@3.3.3": { 628 214 "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==" 629 215 }, 630 - "whatwg-encoding@3.1.1": { 631 - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", 632 - "dependencies": [ 633 - "iconv-lite" 634 - ], 635 - "deprecated": true 636 - }, 637 - "whatwg-mimetype@4.0.0": { 638 - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==" 639 - }, 640 216 "yaml@2.8.2": { 641 217 "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", 642 218 "bin": true ··· 645 221 "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==" 646 222 } 647 223 }, 648 - "redirects": { 649 - "https://esm.sh/jsr/@bpev/civility@^0.0.7/workers": "https://esm.sh/jsr/@bpev/civility@0.0.7/workers", 650 - "https://esm.sh/jsr/@bpev/civility@^0.0.7/workers?worker": "https://esm.sh/jsr/@bpev/civility@0.0.7/workers?worker" 651 - }, 652 - "remote": { 653 - "https://esm.sh/@jsr/bpev__civility@0.0.7/denonext/workers.mjs": "862bb790ddff27a5acf2035e0ebc7b6ff9dfa2886753bc832234bb0873f518e3", 654 - "https://esm.sh/jsr/@bpev/civility@0.0.7/workers": "4cae407e5a3af8397e6600fb7678d107d01cf487bfd84ba8ddf88d455bbfb40f", 655 - "https://esm.sh/jsr/@bpev/civility@0.0.7/workers?worker": "0f1288607f51350185f70af66699c735c15bca9a31a43c90e8efe703ab095caf" 656 - }, 657 224 "workspace": { 658 225 "dependencies": [ 659 - "jsr:@bpev/civility@~0.1.1", 660 226 "jsr:@bpev/sync-link@^0.0.17", 227 + "jsr:@civility/store@0.2", 228 + "jsr:@civility/ui@0.2", 229 + "jsr:@civility/workers@0.2", 661 230 "jsr:@inro/simple-tools@~0.5.2", 662 231 "jsr:@std/assert@^1.0.19", 663 232 "jsr:@std/dotenv@~0.225.6",
+2 -2
www/index.ts
··· 1 - import { client } from '@bpev/civility/workers' 1 + import { client } from '@civility/workers' 2 + import { createLayoutRouter } from '@civility/ui' 2 3 import './routes/home.ts' 3 4 import './routes/library.ts' 4 5 import './routes/climb.ts' 5 6 import './routes/settings.ts' 6 7 import { globalStopwatch } from './routes/stopwatch.ts' 7 8 import { formatStopwatchShort } from './utils/format.ts' 8 - import { createLayoutRouter } from '@bpev/civility' 9 9 10 10 client.init() 11 11
+1 -1
www/models/app.ts
··· 1 - import State from '@inro/simple-tools/state' 1 + import State from '@civility/store/state' 2 2 import useJSON from '@bpev/sync-link/json' 3 3 import { 4 4 AppSettings,
+177 -137
www/routes/climb.ts
··· 1 - import { html, safe } from '@bpev/civility' 1 + import { html, LitElement, type TemplateResult } from 'lit' 2 2 import Hammer, { type HammerManager } from 'hammerjs' 3 3 import { 4 4 type Benchmark, ··· 13 13 14 14 export let activeClimbHeader: ClimbHeader | null = null 15 15 16 - export class ClimbHeader extends HTMLElement { 17 - connectedCallback() { 16 + export class ClimbHeader extends LitElement { 17 + private climb: Benchmark | null = null 18 + 19 + protected override createRenderRoot() { 20 + return this 21 + } 22 + 23 + override connectedCallback() { 24 + super.connectedCallback() 18 25 activeClimbHeader = this 19 26 } 20 27 21 - disconnectedCallback() { 28 + override disconnectedCallback() { 29 + super.disconnectedCallback() 22 30 if (activeClimbHeader === this) activeClimbHeader = null 23 31 } 24 32 25 - update(climb: Benchmark): void { 33 + setClimb(climb: Benchmark): void { 34 + this.climb = climb 35 + this.requestUpdate() 36 + } 37 + 38 + override render(): TemplateResult { 39 + if (!this.climb) { 40 + return html` 41 + 42 + ` 43 + } 44 + const climb = this.climb 26 45 const nav = app.getNav() 27 46 const backRoute = nav?.backRoute ?? '/' 28 47 29 - this.innerHTML = html` 30 - <button class="back" aria-label="Back"> 48 + return html` 49 + <button 50 + class="back" 51 + aria-label="Back" 52 + @click="${() => { 53 + globalThis.location.hash = backRoute 54 + }}" 55 + > 31 56 <img src="/static/icons/chevron-right.svg" alt="" aria-hidden="true"> 32 57 </button> 33 58 <div class="title-info"> ··· 35 60 <small class="subtitle"> 36 61 ${GRADE_FULL[climb.grade] ?? ''} ${sandbagLabel( 37 62 climb.sandbagScore, 38 - )}&#32;·&#32;${climb.setter} 63 + )} · ${climb.setter} 39 64 </small> 40 65 </div> 41 - <div class="meta">${safe(this.metaHtml(climb))}</div> 42 - `.toString() 43 - 44 - this.querySelector('.back')?.addEventListener('click', () => { 45 - globalThis.location.hash = backRoute 46 - }) 66 + <div class="meta">${this.renderMeta(climb)}</div> 67 + ` 47 68 } 48 69 49 - metaHtml(climb: Benchmark): string { 70 + private renderMeta(climb: Benchmark): TemplateResult { 50 71 const entry = app.getClimbLog(climb.id) 51 72 if (entry) { 52 73 const badge = entry.sent 53 - ? '<ui-badge variant="success">Sent</ui-badge>' 54 - : '<ui-badge>Project</ui-badge>' 74 + ? html` 75 + <ui-badge variant="success">Sent</ui-badge> 76 + ` 77 + : html` 78 + <ui-badge>Project</ui-badge> 79 + ` 55 80 const stars = entry.rating ? '★'.repeat(entry.rating) : '' 56 81 return html` 57 - <span>${safe(badge)} ${stars}</span> 58 - <span>${entry.totalAttempts}&#32;attempts</span> 59 - `.toString() 82 + <span>${badge} ${stars}</span> 83 + <span>${entry.totalAttempts} attempts</span> 84 + ` 60 85 } 61 - return ` 86 + return html` 62 87 <span>★ ${climb.avgUserStars.toFixed(1)}</span> 63 88 <span>${climb.repeats.toLocaleString()} sends</span> 64 89 ` 65 90 } 66 91 } 67 92 68 - export class ClimbPage extends HTMLElement { 93 + export class ClimbPage extends LitElement { 69 94 private hammer: HammerManager | null = null 95 + private loading = true 96 + private climb: Benchmark | null = null 97 + private dialogOpen = false 70 98 private currentDialogClimb: Benchmark | null = null 71 99 private dialogRating: number | null = null 100 + private prevClimbId: number | undefined 72 101 73 - async connectedCallback() { 102 + protected override createRenderRoot() { 103 + return this 104 + } 105 + 106 + override async connectedCallback() { 107 + super.connectedCallback() 74 108 const climbId = parseInt(this.getAttribute('climb-id') ?? '0', 10) 75 109 if (!climbId) { 76 110 globalThis.location.hash = '/' 77 111 return 78 112 } 79 113 80 - // Use cached climb from nav state if IDs match 81 114 const nav = app.getNav() 82 115 let climb = (nav?.climbId === climbId ? nav.climb : null) ?? null 83 116 ··· 96 129 return 97 130 } 98 131 99 - this.innerHTML = html` 100 - <div id="cp-body"></div> 101 - <dialog id="cp-dialog"> 102 - <div id="lb-dialog-box" class="lb-dialog"></div> 103 - </dialog> 104 - `.toString() 105 - 106 - // Dialog events — bound once for the component lifetime 107 - const dialog = this.querySelector<HTMLDialogElement>('#cp-dialog')! 108 - dialog.addEventListener('click', (e) => { 109 - if (e.target === dialog) this.hideLogDialog() 110 - }) 111 - dialog.addEventListener('cancel', (e) => { 112 - e.preventDefault() 113 - this.hideLogDialog() 114 - }) 115 - this.querySelector('#lb-dialog-box')?.addEventListener( 116 - 'click', 117 - (e) => this.handleDialogClick(e), 118 - ) 119 - 120 - this.renderContent(climb) 132 + this.loading = false 133 + this.climb = climb 134 + this.requestUpdate() 135 + activeClimbHeader?.setClimb(climb) 121 136 122 137 if (nav?.filteredIds) { 123 138 this.hammer = new Hammer(this, { touchAction: 'pan-y' }) ··· 127 142 } 128 143 } 129 144 130 - disconnectedCallback() { 145 + override disconnectedCallback() { 146 + super.disconnectedCallback() 131 147 this.hammer?.destroy() 132 148 this.hammer = null 133 149 } 134 150 151 + protected override updated() { 152 + // Canvas drawing — only when climb changes 153 + if (this.climb && this.climb.id !== this.prevClimbId) { 154 + this.prevClimbId = this.climb.id 155 + const config = BOARD_CONFIGS[this.climb.mbType] ?? BOARD_CONFIGS[0] 156 + const mainEl = document.querySelector<HTMLElement>('main') 157 + if (mainEl) mainEl.scrollTop = 0 158 + requestAnimationFrame(() => { 159 + if (!this.climb) return 160 + const canvas = this.querySelector<HTMLCanvasElement>('#bm-canvas') 161 + if (canvas) drawClimb(canvas, this.climb, config.rows) 162 + }) 163 + } 164 + 165 + // Dialog show/hide 166 + const dialog = this.querySelector<HTMLDialogElement>('#cp-dialog') 167 + if (dialog) { 168 + if (this.dialogOpen && !dialog.open) dialog.showModal() 169 + else if (!this.dialogOpen && dialog.open) dialog.close() 170 + } 171 + } 172 + 135 173 private navigate(direction: number): void { 136 174 const nav = app.getNav() 137 175 if (!nav?.filteredIds || nav.currentIndex === undefined) return ··· 147 185 globalThis.location.hash = `/climb/${nextId}` 148 186 } 149 187 150 - private renderContent(climb: Benchmark): void { 188 + override render(): TemplateResult { 189 + if (this.loading) { 190 + return html` 191 + <div class="empty-message"><ui-spinner size="lg"></ui-spinner></div> 192 + ` 193 + } 194 + if (!this.climb) { 195 + return html` 196 + 197 + ` 198 + } 199 + 200 + const climb = this.climb 151 201 const config = BOARD_CONFIGS[climb.mbType] ?? BOARD_CONFIGS[0] 152 202 const height = canvasHeight(config.rows) 153 203 154 - activeClimbHeader?.update(climb) 155 - 156 - const bodyEl = this.querySelector<HTMLElement>('#cp-body') 157 - if (!bodyEl) return 158 - 159 - const boardHtml = config.image 160 - ? html` 161 - <div class="board-wrap" style="aspect-ratio: ${CANVAS_WIDTH} / ${height}"> 204 + return html` 205 + <div id="cp-body"> 206 + ${config.image 207 + ? html` 208 + <div class="board-wrap" style="aspect-ratio: ${CANVAS_WIDTH} / ${height}"> 209 + <img 210 + src="${config.image}" 211 + alt="${config.label} board layout" 212 + loading="lazy" 213 + > 214 + <canvas 215 + id="bm-canvas" 216 + width="${CANVAS_WIDTH}" 217 + height="${height}" 218 + aria-label="Board diagram showing hold positions" 219 + ></canvas> 220 + </div> 221 + ` 222 + : html` 223 + <div class="board-placeholder"> 224 + <canvas 225 + id="bm-canvas" 226 + width="${CANVAS_WIDTH}" 227 + height="${height}" 228 + aria-label="Board diagram showing hold positions" 229 + ></canvas> 230 + </div> 231 + `} 232 + <a 233 + href="${youtubeUrl(climb)}" 234 + target="_blank" 235 + rel="noopener noreferrer" 236 + class="action" 237 + > 162 238 <img 163 - src="${config.image}" 164 - alt="${config.label} board layout" 165 - loading="lazy" 239 + src="/static/icons/youtube.svg" 240 + alt="" 241 + aria-hidden="true" 242 + width="18" 243 + height="18" 166 244 > 167 - <canvas 168 - id="bm-canvas" 169 - width="${CANVAS_WIDTH}" 170 - height="${height}" 171 - aria-label="Board diagram showing hold positions" 172 - ></canvas> 245 + Search Beta Videos 246 + </a> 247 + <div class="lb-log-section"> 248 + <button class="action" @click="${() => this.showLogDialog(climb)}"> 249 + ${app.getClimbLog(climb.id) ? 'Log Another Session' : 'Log Attempt'} 250 + </button> 173 251 </div> 174 - `.toString() 175 - : html` 176 - <div class="board-placeholder"> 177 - <canvas 178 - id="bm-canvas" 179 - width="${CANVAS_WIDTH}" 180 - height="${height}" 181 - aria-label="Board diagram showing hold positions" 182 - ></canvas> 183 - </div> 184 - `.toString() 185 - 186 - bodyEl.innerHTML = html` 187 - ${safe(boardHtml)} 188 - <a 189 - href="${youtubeUrl(climb)}" 190 - target="_blank" 191 - rel="noopener noreferrer" 192 - class="action" 252 + </div> 253 + <dialog 254 + id="cp-dialog" 255 + @click="${(e: Event) => { 256 + if (e.target === e.currentTarget) this.hideLogDialog() 257 + }}" 258 + @cancel="${(e: Event) => { 259 + e.preventDefault() 260 + this.hideLogDialog() 261 + }}" 193 262 > 194 - <img 195 - src="/static/icons/youtube.svg" 196 - alt="" 197 - aria-hidden="true" 198 - width="18" 199 - height="18" 263 + <div 264 + id="lb-dialog-box" 265 + class="lb-dialog" 266 + @click="${(e: Event) => this.handleDialogClick(e)}" 200 267 > 201 - Search Beta Videos 202 - </a> 203 - 204 - <div class="lb-log-section" id="lb-log-section"> 205 - ${safe(this.logSectionHtml(climb))} 206 - </div> 207 - `.toString() 208 - 209 - bodyEl.querySelector('#lb-log-btn') 210 - ?.addEventListener('click', () => this.showLogDialog(climb)) 211 - 212 - const mainEl = document.querySelector<HTMLElement>('main') 213 - if (mainEl) mainEl.scrollTop = 0 214 - 215 - requestAnimationFrame(() => { 216 - const canvas = this.querySelector<HTMLCanvasElement>('#bm-canvas') 217 - if (canvas) drawClimb(canvas, climb, config.rows) 218 - }) 219 - } 220 - 221 - private logSectionHtml(climb: Benchmark): string { 222 - return app.getClimbLog(climb.id) 223 - ? `<button class="action" id="lb-log-btn">Log Another Session</button>` 224 - : `<button class="action" id="lb-log-btn">Log Attempt</button>` 225 - } 226 - 227 - private refreshLogSection(climb: Benchmark): void { 228 - const section = this.querySelector<HTMLElement>('#lb-log-section') 229 - if (section) { 230 - section.innerHTML = this.logSectionHtml(climb) 231 - section.querySelector('#lb-log-btn') 232 - ?.addEventListener('click', () => this.showLogDialog(climb)) 233 - } 234 - activeClimbHeader?.update(climb) 268 + ${this.dialogOpen ? this.renderDialogContent() : ''} 269 + </div> 270 + </dialog> 271 + ` 235 272 } 236 273 237 - private showLogDialog(climb: Benchmark): void { 238 - this.currentDialogClimb = climb 239 - this.dialogRating = null 240 - const dialog = this.querySelector<HTMLDialogElement>('#cp-dialog') 241 - const box = this.querySelector<HTMLElement>('#lb-dialog-box') 242 - if (!dialog || !box) return 243 - 244 - box.innerHTML = html` 274 + private renderDialogContent(): TemplateResult { 275 + return html` 245 276 <div class="lb-dialog-header"> 246 277 <span class="lb-dialog-title">Log Session</span> 247 278 <button class="lb-dialog-close" id="lb-dialog-close" aria-label="Close"> ··· 267 298 </div> 268 299 </div> 269 300 <button class="lb-submit-btn" id="lb-submit" type="button">Save Session</button> 270 - `.toString() 271 - dialog.showModal() 301 + ` 302 + } 303 + 304 + private showLogDialog(climb: Benchmark): void { 305 + this.currentDialogClimb = climb 306 + this.dialogRating = null 307 + this.dialogOpen = true 308 + this.requestUpdate() 272 309 } 273 310 274 311 private hideLogDialog(): void { 275 - const dialog = this.querySelector<HTMLDialogElement>('#cp-dialog') 276 - dialog?.close() 312 + this.dialogOpen = false 277 313 this.currentDialogClimb = null 278 314 this.dialogRating = null 315 + this.requestUpdate() 279 316 } 280 317 281 318 private handleDialogClick(e: Event): void { ··· 328 365 }, 329 366 { attempts, sent, rating }, 330 367 ).then(() => { 331 - if (this.isConnected) this.refreshLogSection(climb) 368 + if (this.isConnected) { 369 + this.requestUpdate() 370 + activeClimbHeader?.requestUpdate() 371 + } 332 372 }) 333 373 } 334 374 }
+93 -77
www/routes/home.ts
··· 1 - import { html, safe } from '@bpev/civility' 1 + import { html, LitElement, type TemplateResult } from 'lit' 2 + import { unsafeHTML } from 'lit/directives/unsafe-html.js' 2 3 import { 3 4 type Benchmark, 4 5 BOARD_CONFIGS, ··· 23 24 24 25 export const emitter = new EventTarget() 25 26 26 - export class HomeFilters extends HTMLElement { 27 + export class HomeFilters extends LitElement { 27 28 private gradeScale: 'french' | 'v' = 'french' 28 29 29 - connectedCallback() { 30 + protected override createRenderRoot() { 31 + return this 32 + } 33 + 34 + override connectedCallback() { 35 + super.connectedCallback() 30 36 state.search = '' 31 37 state.gradeMin = app.settings.state.homeGradeMin 32 38 state.gradeMax = app.settings.state.homeGradeMax 33 39 state.logFilter = app.settings.state.homeFilter 34 40 state.shown = PAGE_SIZE 35 41 this.gradeScale = app.settings.state.gradeScale 36 - this.render() 37 42 this.addEventListener('input', this.#onInput) 38 43 this.addEventListener('change', this.#onChange) 39 44 this.addEventListener('click', this.#onClick) 40 45 app.settings.addEventListener(this.#onSettingsUpdate) 41 46 } 42 47 43 - disconnectedCallback() { 48 + override disconnectedCallback() { 49 + super.disconnectedCallback() 44 50 this.removeEventListener('input', this.#onInput) 45 51 this.removeEventListener('change', this.#onChange) 46 52 this.removeEventListener('click', this.#onClick) ··· 59 65 state.gradeMax = settings.homeGradeMax 60 66 state.gradeMin = settings.homeGradeMin 61 67 this.gradeScale = settings.gradeScale 62 - this.render() 68 + this.requestUpdate() 63 69 emitter.dispatchEvent(new Event('filter')) 64 70 } 65 71 ··· 114 120 if (!btn) return 115 121 state.logFilter = btn.dataset.logFilter as LogFilter 116 122 app.updateSettings({ homeFilter: state.logFilter }) 117 - const bar = this.querySelector('#bm-log-filter') 118 - if (bar) bar.innerHTML = this.logFilterHtml() 119 123 state.shown = PAGE_SIZE 124 + this.requestUpdate() 120 125 emitter.dispatchEvent(new Event('filter')) 121 126 } 122 127 123 - private render() { 124 - this.innerHTML = html` 128 + override render(): TemplateResult { 129 + return html` 125 130 <input 126 131 type="search" 127 132 id="bm-search" ··· 129 134 autocomplete="off" 130 135 > 131 136 <ui-button-group id="bm-log-filter"> 132 - ${safe(this.logFilterHtml())} 137 + ${unsafeHTML(this.logFilterHtml())} 133 138 </ui-button-group> 134 139 <div class="grade-filter"> 135 140 <label for="bm-grade-min">Grade</label> 136 141 <select id="bm-grade-min"> 137 - ${safe(this.gradeOptions('min'))} 142 + ${unsafeHTML(this.gradeOptions('min'))} 138 143 </select> 139 144 <span aria-hidden="true">–</span> 140 145 <select id="bm-grade-max"> 141 - ${safe(this.gradeOptions('max'))} 146 + ${unsafeHTML(this.gradeOptions('max'))} 142 147 </select> 143 148 </div> 144 - `.toString() 149 + ` 145 150 } 146 151 147 152 private logFilterHtml(): string { ··· 189 194 } 190 195 } 191 196 192 - export class HomePage extends HTMLElement { 197 + export class HomePage extends LitElement { 193 198 private benchmarks: Benchmark[] = [] 194 199 private filtered: Benchmark[] = [] 195 200 private mbType = 0 196 201 private gradeScale: 'french' | 'v' = 'french' 202 + private loading = true 203 + private error = false 197 204 198 - async connectedCallback() { 205 + protected override createRenderRoot() { 206 + return this 207 + } 208 + 209 + override async connectedCallback() { 210 + super.connectedCallback() 199 211 this.mbType = app.settings.state.mbType 200 212 this.gradeScale = app.settings.state.gradeScale 201 213 ··· 204 216 titleEl.textContent = BOARD_CONFIGS[this.mbType]?.label ?? 'Moonboard' 205 217 } 206 218 207 - this.innerHTML = ` 208 - <div id="bm-list" role="list"> 209 - <div class="empty-message"><ui-spinner size="lg"></ui-spinner></div> 210 - </div> 211 - ` 212 - 213 219 this.addEventListener('click', this.#handleClick) 214 220 emitter.addEventListener('filter', this.#onFilter) 215 221 app.settings.addEventListener(this.#onSettingsUpdate) ··· 223 229 a.sandbagScore - b.sandbagScore || 224 230 a.repeats - b.repeats 225 231 ) 232 + this.loading = false 226 233 this.applyFilters() 227 - this.renderList() 234 + this.requestUpdate() 228 235 } catch { 229 - const listEl = this.querySelector('#bm-list') 230 - if (listEl) { 231 - listEl.innerHTML = 232 - `<p class="empty-message">Failed to load benchmarks.</p>` 233 - } 236 + this.loading = false 237 + this.error = true 238 + this.requestUpdate() 234 239 } 235 240 } 236 241 237 - disconnectedCallback() { 242 + override disconnectedCallback() { 243 + super.disconnectedCallback() 238 244 this.removeEventListener('click', this.#handleClick) 239 245 emitter.removeEventListener('filter', this.#onFilter) 240 246 app.settings.removeEventListener(this.#onSettingsUpdate) ··· 259 265 a.repeats - b.repeats 260 266 ) 261 267 this.applyFilters() 262 - this.renderList() 268 + this.requestUpdate() 263 269 } 264 270 265 271 #onFilter = () => { 266 272 this.applyFilters() 267 - this.renderList() 273 + this.requestUpdate() 268 274 } 269 275 270 276 #handleClick = (e: Event) => { ··· 278 284 } 279 285 if (target.closest('#bm-load-more')) { 280 286 state.shown += PAGE_SIZE 281 - this.renderList() 287 + this.requestUpdate() 282 288 } 283 289 } 284 290 ··· 300 306 }) 301 307 } 302 308 303 - private renderList(): void { 304 - const listEl = this.querySelector('#bm-list') 305 - if (!listEl) return 309 + override render(): TemplateResult { 310 + return html` 311 + <div id="bm-list" role="list"> 312 + ${this.listContent()} 313 + </div> 314 + ` 315 + } 306 316 317 + private listContent(): TemplateResult { 318 + if (this.loading) { 319 + return html` 320 + <div class="empty-message"><ui-spinner size="lg"></ui-spinner></div> 321 + ` 322 + } 323 + if (this.error) { 324 + return html` 325 + <p class="empty-message">Failed to load benchmarks.</p> 326 + ` 327 + } 307 328 if (this.benchmarks.length === 0) { 308 - listEl.innerHTML = 309 - `<p class="empty-message">No benchmarks available for this board.</p>` 310 - return 329 + return html` 330 + <p class="empty-message">No benchmarks available for this board.</p> 331 + ` 311 332 } 312 - 313 333 if (this.filtered.length === 0) { 314 - listEl.innerHTML = 315 - `<p class="empty-message">No benchmarks match your filters.</p>` 316 - return 334 + return html` 335 + <p class="empty-message">No benchmarks match your filters.</p> 336 + ` 317 337 } 318 338 319 339 const visible = this.filtered.slice(0, state.shown) 320 340 const remaining = this.filtered.length - state.shown 321 341 const logbook = app.logbook 322 342 323 - listEl.innerHTML = html` 343 + return html` 324 344 <div class="bm-count"> 325 345 ${this.filtered.length 326 - .toLocaleString()}&#32;benchmark${this.filtered.length === 1 327 - ? '' 328 - : 's'} 346 + .toLocaleString()} benchmark${this.filtered.length === 1 ? '' : 's'} 329 347 </div> 330 - ${safe( 331 - visible.map((b) => { 332 - const log = logbook[b.id.toString()] 333 - const sentTag = log?.sent 334 - ? '<ui-badge variant="success">Sent</ui-badge>' 335 - : '' 336 - return html` 337 - <button data-bm-id="${b.id}" role="listitem"> 338 - <div class="bm-item-main"> 339 - <span class="bm-name">${b.name}</span> 340 - ${safe(sentTag)} 341 - <ui-badge class="grade"> 342 - ${gradeLabel(b.grade, this.gradeScale)} 343 - </ui-badge> 344 - </div> 345 - <div class="bm-item-meta"> 346 - <span class="bm-setter">${b.setter}</span> 347 - <span class="bm-stats"> 348 - ★ ${b.avgUserStars.toFixed(1)}&#32;·&#32;${b 349 - .repeats.toLocaleString()} 350 - </span> 351 - </div> 352 - </button> 353 - `.toString() 354 - }).join(''), 355 - )} ${safe( 356 - remaining > 0 357 - ? `<button id="bm-load-more">Load more (${remaining.toLocaleString()} remaining)</button>` 358 - : '', 359 - )} 360 - `.toString() 348 + ${visible.map((b) => { 349 + const log = logbook[b.id.toString()] 350 + const sentTag = log?.sent 351 + ? '<ui-badge variant="success">Sent</ui-badge>' 352 + : '' 353 + return html` 354 + <button data-bm-id="${b.id}" role="listitem"> 355 + <div class="bm-item-main"> 356 + <span class="bm-name">${b.name}</span> 357 + ${unsafeHTML(sentTag)} 358 + <ui-badge class="grade"> 359 + ${gradeLabel(b.grade, this.gradeScale)} 360 + </ui-badge> 361 + </div> 362 + <div class="bm-item-meta"> 363 + <span class="bm-setter">${b.setter}</span> 364 + <span class="bm-stats"> 365 + ★ ${b.avgUserStars.toFixed(1)} · ${b.repeats.toLocaleString()} 366 + </span> 367 + </div> 368 + </button> 369 + ` 370 + })} ${remaining > 0 371 + ? html` 372 + <button id="bm-load-more">Load more (${remaining 373 + .toLocaleString()} remaining)</button> 374 + ` 375 + : ''} 376 + ` 361 377 } 362 378 363 379 private openClimb(index: number): void {
+78 -57
www/routes/library.ts
··· 1 - import { html, safe } from '@bpev/civility' 1 + import { html, LitElement, type TemplateResult } from 'lit' 2 2 import app from '../models/app.ts' 3 3 import type { ClimbLogEntry } from '../models/schema.ts' 4 4 import { gradeLabel } from '../utils/benchmarks.ts' ··· 9 9 export const libState = { filter: 'all' as Filter } 10 10 export const libEmitter = new EventTarget() 11 11 12 - export class LibraryFilters extends HTMLElement { 13 - connectedCallback() { 12 + export class LibraryFilters extends LitElement { 13 + protected override createRenderRoot() { 14 + return this 15 + } 16 + 17 + override connectedCallback() { 18 + super.connectedCallback() 14 19 libState.filter = app.settings.state.libraryFilter 15 - this.render() 16 20 this.addEventListener('click', this.#handleClick) 17 21 app.settings.addEventListener(this.#onSettingsUpdate) 18 22 } 19 23 20 - disconnectedCallback() { 24 + override disconnectedCallback() { 25 + super.disconnectedCallback() 21 26 this.removeEventListener('click', this.#handleClick) 22 27 app.settings.removeEventListener(this.#onSettingsUpdate) 23 28 } ··· 26 31 const filter = app.settings.state.libraryFilter 27 32 if (filter === libState.filter) return 28 33 libState.filter = filter 29 - this.render() 34 + this.requestUpdate() 30 35 libEmitter.dispatchEvent(new Event('filter')) 31 36 } 32 37 33 - private render() { 34 - this.innerHTML = ` 35 - <ui-button-group> 36 - ${this.filterBarHtml()} 37 - </ui-button-group> 38 - ` 38 + #handleClick = (e: Event) => { 39 + const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-filter]') 40 + if (!btn) return 41 + libState.filter = btn.dataset.filter as Filter 42 + app.updateSettings({ libraryFilter: libState.filter }) 43 + this.requestUpdate() 44 + libEmitter.dispatchEvent(new Event('filter')) 39 45 } 40 46 41 - private filterBarHtml(): string { 47 + override render(): TemplateResult { 42 48 const opts: { value: Filter; label: string }[] = [ 43 49 { value: 'all', label: 'All' }, 44 50 { value: 'sent', label: 'Completed' }, 45 51 { value: 'unsent', label: 'Not Completed' }, 46 52 ] 47 - return opts 48 - .map( 49 - (o) => 50 - `<button aria-pressed="${ 51 - libState.filter === o.value 52 - }" data-filter="${o.value}">${o.label}</button>`, 53 - ) 54 - .join('') 55 - } 56 - 57 - #handleClick = (e: Event) => { 58 - const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-filter]') 59 - if (!btn) return 60 - libState.filter = btn.dataset.filter as Filter 61 - app.updateSettings({ libraryFilter: libState.filter }) 62 - const group = this.querySelector('ui-button-group') 63 - if (group) group.innerHTML = this.filterBarHtml() 64 - libEmitter.dispatchEvent(new Event('filter')) 53 + return html` 54 + <ui-button-group> 55 + ${opts.map((o) => 56 + html` 57 + <button 58 + aria-pressed="${libState.filter === o.value}" 59 + data-filter="${o.value}" 60 + > 61 + ${o.label} 62 + </button> 63 + ` 64 + )} 65 + </ui-button-group> 66 + ` 65 67 } 66 68 } 67 69 68 - export class LibraryPage extends HTMLElement { 70 + export class LibraryPage extends LitElement { 69 71 private gradeScale: 'french' | 'v' = 'french' 70 72 71 - connectedCallback() { 73 + protected override createRenderRoot() { 74 + return this 75 + } 76 + 77 + override connectedCallback() { 78 + super.connectedCallback() 72 79 this.gradeScale = app.settings.state.gradeScale 73 - this.innerHTML = `<div id="lb-list"></div>` 74 - this.renderList() 75 80 libEmitter.addEventListener('filter', this.#onFilter) 76 81 this.addEventListener('click', this.#handleClick) 77 82 app.store.addEventListener(this.#onStoreUpdate) 78 83 app.settings.addEventListener(this.#onSettingsUpdate) 79 84 } 80 85 81 - disconnectedCallback() { 86 + override disconnectedCallback() { 87 + super.disconnectedCallback() 82 88 libEmitter.removeEventListener('filter', this.#onFilter) 83 89 this.removeEventListener('click', this.#handleClick) 84 90 app.store.removeEventListener(this.#onStoreUpdate) ··· 86 92 } 87 93 88 94 #onFilter = () => { 89 - this.renderList() 95 + this.requestUpdate() 90 96 } 91 97 92 98 #onStoreUpdate = () => { 93 - this.renderList() 99 + this.requestUpdate() 94 100 } 95 101 96 102 #onSettingsUpdate = () => { 97 103 this.gradeScale = app.settings.state.gradeScale 98 - this.renderList() 104 + this.requestUpdate() 99 105 } 100 106 101 107 #handleClick = (e: Event) => { ··· 117 123 ) 118 124 } 119 125 120 - private renderList(): void { 121 - const listEl = this.querySelector('#lb-list') 122 - if (!listEl) return 123 - 126 + override render(): TemplateResult { 124 127 const all = this.sortedEntries() 125 128 const filtered = all.filter((e) => { 126 129 if (libState.filter === 'sent') return e.sent ··· 129 132 }) 130 133 131 134 if (all.length === 0) { 132 - listEl.innerHTML = 133 - `<p class="empty-message">No climbs logged yet.<br>Log attempts from a climb's detail view.</p>` 134 - return 135 + return html` 136 + <div id="lb-list"> 137 + <p class="empty-message"> 138 + No climbs logged yet.<br>Log attempts from a climb's detail view. 139 + </p> 140 + </div> 141 + ` 135 142 } 136 143 137 144 if (filtered.length === 0) { 138 - listEl.innerHTML = 139 - `<p class="empty-message">No climbs match this filter.</p>` 140 - return 145 + return html` 146 + <div id="lb-list"> 147 + <p class="empty-message">No climbs match this filter.</p> 148 + </div> 149 + ` 141 150 } 142 151 143 - listEl.innerHTML = filtered.map((e) => this.entryHtml(e)).join('') 152 + return html` 153 + <div id="lb-list"> 154 + ${filtered.map((e) => this.renderEntry(e))} 155 + </div> 156 + ` 144 157 } 145 158 146 - private entryHtml(e: ClimbLogEntry): string { 159 + private renderEntry(e: ClimbLogEntry): TemplateResult { 147 160 const grade = gradeLabel(e.grade, this.gradeScale) 148 161 const stars = starsHtml(e.rating) 149 162 const badge = e.sent 150 - ? '<ui-badge variant="success">Sent</ui-badge>' 151 - : '<ui-badge>Project</ui-badge>' 163 + ? html` 164 + <ui-badge variant="success">Sent</ui-badge> 165 + ` 166 + : html` 167 + <ui-badge>Project</ui-badge> 168 + ` 152 169 153 170 return html` 154 171 <button ··· 159 176 <div class="lb-entry-row"> 160 177 <span class="lb-entry-name">${e.name}</span> 161 178 <ui-badge class="grade">${grade}</ui-badge> 162 - ${safe(badge)} 179 + ${badge} 163 180 </div> 164 181 <div class="lb-entry-meta"> 165 - <span>${e.totalAttempts}&#32;attempt${e.totalAttempts === 1 182 + <span>${e.totalAttempts} attempt${e.totalAttempts === 1 166 183 ? '' 167 184 : 's'}</span> 168 - ${safe(stars ? `<span class="lb-entry-stars">${stars}</span>` : '')} 185 + ${stars 186 + ? html` 187 + <span class="lb-entry-stars">${stars}</span> 188 + ` 189 + : ''} 169 190 <span>${e.setter}</span> 170 191 <span class="lb-entry-date">${formatDate(e.lastAttempted)}</span> 171 192 </div> 172 193 </button> 173 - `.toString() 194 + ` 174 195 } 175 196 } 176 197
+54 -74
www/routes/settings.ts
··· 1 - import { html, safe } from '@bpev/civility' 1 + import { html, LitElement, type TemplateResult } from 'lit' 2 2 import app from '../models/app.ts' 3 3 4 4 const BOARD_OPTIONS = [ ··· 24 24 }, 25 25 ] 26 26 27 - export class SettingsPage extends HTMLElement { 27 + export class SettingsPage extends LitElement { 28 28 private selectedType = 0 29 29 private gradeScale = 'french' 30 30 31 - connectedCallback() { 31 + protected override createRenderRoot() { 32 + return this 33 + } 34 + 35 + override connectedCallback() { 36 + super.connectedCallback() 32 37 this.selectedType = app.settings.state.mbType 33 38 this.gradeScale = app.settings.state.gradeScale 34 - this.render() 35 - this.bindEvents() 36 39 app.settings.addEventListener(this.#onSettingsUpdate) 37 40 } 38 41 39 - disconnectedCallback() { 40 - this.removeEventListener('click', this.#handleClick) 42 + override disconnectedCallback() { 43 + super.disconnectedCallback() 41 44 app.settings.removeEventListener(this.#onSettingsUpdate) 42 45 } 43 46 44 47 #onSettingsUpdate = () => { 45 48 this.selectedType = app.settings.state.mbType 46 49 this.gradeScale = app.settings.state.gradeScale 47 - this.render() 48 - this.bindEvents() 50 + this.requestUpdate() 49 51 } 50 52 51 - private render(): void { 52 - this.innerHTML = html` 53 + override render(): TemplateResult { 54 + return html` 53 55 <section> 54 56 <h2>About</h2> 55 57 <ui-pwa-version></ui-pwa-version> ··· 59 61 <h2>Board Setup</h2> 60 62 <p>Select which Moonboard setup you are using.</p> 61 63 <div role="radiogroup" aria-label="Board setup"> 62 - ${safe( 63 - BOARD_OPTIONS.map((opt) => ` 64 - <label> 65 - <input 66 - type="radio" 67 - name="mbType" 68 - value="${opt.mbType}" 69 - ${this.selectedType === opt.mbType ? 'checked' : ''} 70 - > 71 - <span>${opt.label}</span> 72 - </label> 73 - `).join(''), 64 + ${BOARD_OPTIONS.map((opt) => 65 + html` 66 + <label> 67 + <input 68 + type="radio" 69 + name="mbType" 70 + value="${opt.mbType}" 71 + ?checked="${this.selectedType === opt.mbType}" 72 + @change="${() => app.updateSettings({ mbType: opt.mbType })}" 73 + > 74 + <span>${opt.label}</span> 75 + </label> 76 + ` 74 77 )} 75 78 </div> 76 79 </section> ··· 79 82 <h2>Grade Scale</h2> 80 83 <p>Choose how grades are displayed throughout the app.</p> 81 84 <div role="radiogroup" aria-label="Grade scale"> 82 - ${safe( 83 - GRADE_SCALE_OPTIONS.map((opt) => ` 84 - <label> 85 - <input 86 - type="radio" 87 - name="grade_scale" 88 - value="${opt.value}" 89 - ${this.gradeScale === opt.value ? 'checked' : ''} 90 - > 91 - <div> 92 - <span>${opt.label}</span> 93 - <small>${opt.example}</small> 94 - </div> 95 - </label> 96 - `).join(''), 85 + ${GRADE_SCALE_OPTIONS.map((opt) => 86 + html` 87 + <label> 88 + <input 89 + type="radio" 90 + name="grade_scale" 91 + value="${opt.value}" 92 + ?checked="${this.gradeScale === opt.value}" 93 + @change="${() => 94 + app.updateSettings({ 95 + gradeScale: opt.value as 'french' | 'v', 96 + })}" 97 + > 98 + <div> 99 + <span>${opt.label}</span> 100 + <small>${opt.example}</small> 101 + </div> 102 + </label> 103 + ` 97 104 )} 98 105 </div> 99 106 </section> ··· 102 109 <h2>Logbook Data</h2> 103 110 <p>Export your logbook to a file, or import a previously exported file.</p> 104 111 <div class="settings-data-actions"> 105 - <button class="action" id="settings-export">Export logbook</button> 106 - <button class="action" id="settings-import">Import logbook</button> 112 + <button class="action" id="settings-export" @click="${this 113 + .#handleExport}"> 114 + Export logbook 115 + </button> 116 + <button class="action" id="settings-import" @click="${this 117 + .#handleImport}"> 118 + Import logbook 119 + </button> 107 120 </div> 108 121 <p id="settings-data-status" class="settings-data-status" hidden></p> 109 122 </section> 110 - `.toString() 111 - } 112 - 113 - #handleClick = (e: Event) => { 114 - const target = e.target as HTMLElement 115 - if (target.closest('#settings-export')) { 116 - this.#handleExport() 117 - } else if (target.closest('#settings-import')) { 118 - this.#handleImport() 119 - } 123 + ` 120 124 } 121 125 122 126 #setStatus(msg: string, isError = false): void { ··· 151 155 } else { 152 156 this.#setStatus(result.error ?? 'Import failed.', true) 153 157 } 154 - } 155 - 156 - private bindEvents(): void { 157 - this.addEventListener('click', this.#handleClick) 158 - this.querySelectorAll<HTMLInputElement>('input[name="mbType"]').forEach( 159 - (input) => { 160 - input.addEventListener('change', (e) => { 161 - const value = parseInt((e.target as HTMLInputElement).value, 10) 162 - app.updateSettings({ mbType: value }) 163 - this.selectedType = value 164 - }) 165 - }, 166 - ) 167 - 168 - this.querySelectorAll<HTMLInputElement>('input[name="grade_scale"]') 169 - .forEach( 170 - (input) => { 171 - input.addEventListener('change', (e) => { 172 - const value = (e.target as HTMLInputElement).value as 'french' | 'v' 173 - app.updateSettings({ gradeScale: value }) 174 - this.gradeScale = value 175 - }) 176 - }, 177 - ) 178 158 } 179 159 } 180 160
+22 -14
www/routes/stopwatch.ts
··· 1 + import { html, LitElement, type TemplateResult } from 'lit' 1 2 import Stopwatch, { type StopwatchState } from '@inro/simple-tools/stopwatch' 2 3 import { formatStopwatchShort } from '../utils/format.ts' 3 4 import app from '../models/app.ts' ··· 87 88 globalStopwatch.start() 88 89 }) 89 90 90 - export class StopwatchPage extends HTMLElement { 91 + export class StopwatchPage extends LitElement { 91 92 private timeElement: HTMLElement | null = null 92 93 private startButton: HTMLButtonElement | null = null 93 94 private resetButton: HTMLButtonElement | null = null ··· 95 96 private lapsContainer: HTMLElement | null = null 96 97 private removeListener: (() => void) | null = null 97 98 98 - connectedCallback() { 99 - this.render() 100 - this.bindEvents() 99 + protected override createRenderRoot() { 100 + return this 101 + } 102 + 103 + override connectedCallback() { 104 + super.connectedCallback() 101 105 this.removeListener = globalStopwatch.addEventListener((state) => 102 106 this.updateUI(state) 103 107 ) 104 - this.updateUI(globalStopwatch.state) 105 108 } 106 109 107 - disconnectedCallback() { 110 + override disconnectedCallback() { 111 + super.disconnectedCallback() 108 112 this.removeListener?.() 109 113 this.removeListener = null 110 114 } 111 115 112 - private render(): void { 113 - this.innerHTML = ` 116 + protected override firstUpdated() { 117 + this.timeElement = this.querySelector('#stopwatch-time') 118 + this.startButton = this.querySelector('#start-button') 119 + this.resetButton = this.querySelector('#reset-button') 120 + this.lapButton = this.querySelector('#lap-button') 121 + this.lapsContainer = this.querySelector('#laps-container') 122 + this.bindEvents() 123 + this.updateUI(globalStopwatch.state) 124 + } 125 + 126 + override render(): TemplateResult { 127 + return html` 114 128 <div id="stopwatch-display"> 115 129 <div id="stopwatch-time">0:00</div> 116 130 </div> ··· 121 135 </div> 122 136 <div id="laps-container"></div> 123 137 ` 124 - 125 - this.timeElement = this.querySelector('#stopwatch-time') 126 - this.startButton = this.querySelector('#start-button') 127 - this.resetButton = this.querySelector('#reset-button') 128 - this.lapButton = this.querySelector('#lap-button') 129 - this.lapsContainer = this.querySelector('#laps-container') 130 138 } 131 139 132 140 private bindEvents(): void {
+1 -1
www/worker.ts
··· 4 4 withFetchStrategy, 5 5 withPrecache, 6 6 withUpdatePolling, 7 - } from '@bpev/civility/workers' 7 + } from '@civility/workers' 8 8 9 9 init([ 10 10 withPrecache([