[feat](web): serve the SPA and web actions on the single listener

Add the browser edge (contract rev 8.2/9): a Preact/Vite SPA under web/,
an embedded placeholder bundle, the /web/* auth and action handlers, and
the explicit browser-action allowlist. The SPA and the API share the one
plaintext :8080 listener; there is no second port and no peer edge, so
main.go also drops the mTLS listener and the identity mounts in the debug
compose fall away. /_manifest announces the web block only while the SPA is
mounted (honesty rule), always with the single service port.

The DSNs move to the shared database's scram-sha-256 credentials, matching
rev 9's removal of the PKI layer.

Committed web/bun.lock because web/package.json and vite.config.ts are new
in this commit. src/web/dist/index.html is a hand-written placeholder, not
a generated bundle: it keeps bare `go build`/`go vet` working from a clean
checkout, while /web/dist (the real build output) stays gitignored.
This commit is contained in:
2026-10-19 09:28:59 +01:00
parent c58e193038
commit a791232f18
46 changed files with 5392 additions and 61 deletions
+11
View File
@@ -0,0 +1,11 @@
# bun install/temp dirs (sandbox: default bun dirs are not writable)
.bun/
.buntmp/
# build output + deps
dist/
node_modules/
# editor/OS
.DS_Store
*.log
+377
View File
@@ -0,0 +1,377 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "keepnote-web",
"dependencies": {
"@preact/signals": "^2.0.1",
"preact": "^10.25.4",
},
"devDependencies": {
"@preact/preset-vite": "^2.9.4",
"@tailwindcss/vite": "^4.0.6",
"tailwindcss": "^4.0.6",
"typescript": "^5.7.3",
"vite": "^6.0.11",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/[email protected]", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
"@babel/core": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
"@babel/generator": ["@babel/[email protected]", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="],
"@babel/helper-annotate-as-pure": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="],
"@babel/helper-compilation-targets": ["@babel/[email protected]", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
"@babel/helper-globals": ["@babel/[email protected]", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
"@babel/helper-module-imports": ["@babel/[email protected]", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
"@babel/helper-module-transforms": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
"@babel/helper-plugin-utils": ["@babel/[email protected]", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
"@babel/helper-string-parser": ["@babel/[email protected]", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/[email protected]", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/helper-validator-option": ["@babel/[email protected]", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
"@babel/helpers": ["@babel/[email protected]", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.8" } }, "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg=="],
"@babel/parser": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="],
"@babel/plugin-syntax-jsx": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="],
"@babel/plugin-transform-react-jsx": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-module-imports": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-syntax-jsx": "^7.29.7", "@babel/types": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A=="],
"@babel/plugin-transform-react-jsx-development": ["@babel/[email protected]", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g=="],
"@babel/template": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.9", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ=="],
"@babel/types": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/[email protected]", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/[email protected]", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
"@preact/preset-vite": ["@preact/[email protected]", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.27.1", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@prefresh/vite": "^2.4.11", "@rollup/pluginutils": "^5.0.0", "babel-plugin-transform-hook-names": "^1.0.2", "debug": "^4.4.3", "magic-string": "^0.30.21", "picocolors": "^1.1.1", "vite-prerender-plugin": "^0.5.8", "zimmerframe": "^1.1.4" }, "peerDependencies": { "@babel/core": "7.x", "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x" } }, "sha512-ZZ5RIT2ZVXg8UxRA8VZpoT8CdpDG7RFIqOT4bELqNBp85+HKvQBbgmh3gsoW1UOQXx26TLe+ZRNKI7q281Kckw=="],
"@preact/signals": ["@preact/[email protected]", "", { "dependencies": { "@preact/signals-core": "^1.14.4" }, "peerDependencies": { "preact": ">= 10.25.0 || >=11.0.0-0" } }, "sha512-mttwFM98G/JiriTgMwU+UWygL6sfVmYEiNcPSKkUnv4PxPpZhmP2ugGC4qi360+ZIHyQ8NY05bfJT3b4RUIGlw=="],
"@preact/signals-core": ["@preact/[email protected]", "", {}, "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA=="],
"@prefresh/babel-plugin": ["@prefresh/[email protected]", "", { "peerDependencies": { "@babel/core": "^7.0.0 || ^8.0.0" } }, "sha512-GSwqHGVem7AjnOKAkmLNbAExqZZFddf9S3ySOwowGEsJR8vwNKJ7Z/262fov5wra8LkDVSmFw3CjMGRdLoCmRA=="],
"@prefresh/core": ["@prefresh/[email protected]", "", { "peerDependencies": { "preact": "^10.0.0 || ^11.0.0-0" } }, "sha512-Ml00PP8jeHaADxfQqxUs4+M5JS+T5J26UnvPVfsrmlpGw2I3e9XtM0DMeuzkmvg+Tn7VdHSMpLBrDPG0iHoDqQ=="],
"@prefresh/utils": ["@prefresh/[email protected]", "", {}, "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw=="],
"@prefresh/vite": ["@prefresh/[email protected]", "", { "dependencies": { "@babel/core": "^7.22.1", "@prefresh/babel-plugin": "^0.5.2", "@prefresh/core": "^1.5.0", "@prefresh/utils": "^1.2.0", "@rollup/pluginutils": "^4.2.1" }, "peerDependencies": { "preact": "^10.4.0 || ^11.0.0-0", "vite": ">=2.0.0" } }, "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA=="],
"@rollup/pluginutils": ["@rollup/[email protected]", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-MrfgRQSpD5hkkRg6XI16B6EDLb+6+xp8xje6bpdSwkYw+qxrBKbUVrRAQu9h82UT+QumSIVQ8HXsXjPd2dlhqw=="],
"@rollup/rollup-android-arm64": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-+uu2p/R9Tzn8GpoQyX1pAo5dmKT1DhXevfaCzqzmPFhnLjHLWw7uWkaVGlxmhwYoRiYYhsGfRcD5cTBpL7rgSw=="],
"@rollup/rollup-darwin-arm64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-17dys+YEmzwzzFVHQAVDONg5FALXY9kd99HFEpfk4gJVHYxoh5bGea3D4t1fXXnwkTcOe25wPyd6nWS0I8IdLQ=="],
"@rollup/rollup-darwin-x64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-wg0zQEKkEvFZf1UaaX1UELOoPs//OSKUG7D+yITuqOsAvza7LvHTv5Wu4nmMfzNG7EYI2jp2PJC7Id6OETmsYQ=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-CoJmWcpEMuwin+TZ/ev/H9uDyF/uxUKQSWXdmIPn/UBx/uGV9+4ebMp0FwnlhWR9PE1AdzYsKf3bZevhif38+A=="],
"@rollup/rollup-freebsd-x64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-PxZnLAaTUAL5xG0W2BXLeYPKrK2b3LIASV2/e/Kf7PZvSLI1iAQIm9B9aJD+YyFIT7hc6qaMV3+OOO35R28LfA=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-uPo3IvnONjhbFXwWgroBeFwOicuhT7ZwQrnzkzmOtBJzeQbkUJUE1SJDfDI31ijQ5+ZUIA6sPrdUMxTSnxUWAg=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-ywI8Jyuc4LyHYTjC0mw4yA1ulvZBkHusdpWnoiEBsmBuHvSnt90btTBGCZ+s/2y+YNNfVAEKy33nPo5UI9zTmg=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-tmuXJoQ/3ovSr637aykZMdQteU/CB2APAz2CrvsbibuoHZi1BS1Db13cVViBdEARGwXCLn1DSJ8wsjoThLJPGA=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-5FMjSYNcUOHx7oW8Qu9KB6jGPpvF/8sG7+BDHwU/Ihh1/6FMJ9ySmsFG4tJ1qbNRuP8OLzCBDso4sF0mQaLuFQ=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-K8ZJ+neLDbUkIrL8dqF/5LtuVCrKwo27kREgOlxjBEjlOXwHvDnHLU8Z/UKv/B49qi7e8hsSDOyoJwhwqhvX4w=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-NlVxsq7PFPv0DBKGuNWRDKixQYQCjM53zl04mxEcKMBmJbCBIUm1JdPfIWDRHyOOFzzYPmLQzfeEwh4716ayQg=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ARhRHJYs5eCFet5azfrHD97vOkD8OzNW2RSMlvo5CcBqXaTOLfp0OHDtUQh7/K5AL5fUYoFp2fgC9KDg4vVYMQ=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-atSgreM5C7jpxd9aEXN+6vJ5wTd05iYw+S3FiQpw7VsoEl8B90llbq35ARON+GOHj5QlVQEq2vOKWCHTob/BSQ=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-vmlmwBFGnjVWxSRoVabNkyYuscSmg52Pd0fM8jEbNT3Jmka4A6HcB84x4uAmuumznWfBVaxKOqU6jbEgomWKCA=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-hhJrP6S9WkbsbcHgUkvCBS5Cn8IdsG8XjlLZyxjnv9+NdoYfbbZE4TWJYr8apdaj1M5t5Y1id2StnEslxpNFyA=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-NNVCyN9pGrwFmQa4bf+Cnf5aVH3g6ZpnlOg1Cs/H9swIHT9bRixijfzUqyEIWm1WtiAuR1LJ+rJH6zpBm8kgQA=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-dR9nd6OKnvSS60Ifz/L7Mj5zzYyoqyRn2NwTuaOONKaa3xsVGXyp8YusMbvFyEOR94DxnwgcbgsFqkHd29ZSzQ=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-1tzsMjaqhsh3bAO6SJiJqh8hIr9sSPRd+i2vPPxJHod8/XYnf9TEnK0z/u3r1z1AkI347X0KbRSg9dWcxeGahw=="],
"@rollup/rollup-openbsd-x64": ["@rollup/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-01mWMQblKoXXsmk28KXtpbA96w7g/aOWUoV3AC16fdHdqkykGalqyyM+5u8M7uP5/MEMiiPv69Hv7iv/hEH/dA=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-trdnMPb/DNm4V/CgsaoGgr90HfQQ071wfK0KoIs4YpLvjBgVQGXhGabg2Z6nK4EcYNilrb9ZZPcsP6+zpJIdxQ=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-x2LxZlfdfsw99T6/qb9EqE/Qi84JWzibjyOJyhJsgFJy6CafaTiqIXCCnslCGrV5XWNh7fwrS3FbRENcuKnooA=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-/Qd/9aDQIo0+jmItgBmPIs8195YVbDxRRApn47GyufgbutCDOh7eP5vSKKlI4QNFeaI+wKQAkSMsPnF3EuMpaA=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-ngUJ/XvOWrqbd2IMsfXfh2xXFLTP3sY7sCKvxbGvYdBZ1KmXFd3ewDZ9n3/GNzAUgzRuhLT/UvJRivbNoAQRBw=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-IhV6ew/DuzUWwHaYmlh7MrPCV3lKFUq/amTEwysi4r/mwNPAEfAMh5dYWeTSPTeMy2pFHGMEWYtPkHzfX3tG5A=="],
"@tailwindcss/node": ["@tailwindcss/[email protected]", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
"@tailwindcss/oxide": ["@tailwindcss/[email protected]", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/[email protected]", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
"@tailwindcss/vite": ["@tailwindcss/[email protected]", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
"@types/estree": ["@types/[email protected]", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"babel-plugin-transform-hook-names": ["[email protected]", "", { "peerDependencies": { "@babel/core": "^7.12.10" } }, "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw=="],
"baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA=="],
"boolbase": ["[email protected]", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"browserslist": ["[email protected]", "", { "dependencies": { "baseline-browser-mapping": "^2.11.26", "caniuse-lite": "^1.0.30001813", "electron-to-chromium": "^1.5.439", "node-releases": "^2.0.57", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA=="],
"caniuse-lite": ["[email protected]", "", {}, "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q=="],
"convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"css-select": ["[email protected]", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
"css-what": ["[email protected]", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"detect-libc": ["[email protected]", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"dom-serializer": ["[email protected]", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["[email protected]", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
"domhandler": ["[email protected]", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"domutils": ["[email protected]", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"electron-to-chromium": ["[email protected]", "", {}, "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg=="],
"enhanced-resolve": ["[email protected]", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw=="],
"entities": ["[email protected]", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"escalade": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"estree-walker": ["[email protected]", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["[email protected]", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"graceful-fs": ["[email protected]", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"he": ["[email protected]", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
"jiti": ["[email protected]", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["[email protected]", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["[email protected]", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"kolorist": ["[email protected]", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="],
"lightningcss": ["[email protected]", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
"node-html-parser": ["[email protected]", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg=="],
"node-releases": ["[email protected]", "", {}, "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg=="],
"nth-check": ["[email protected]", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["[email protected]", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.19", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ=="],
"preact": ["[email protected]", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q=="],
"rollup": ["[email protected]", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.64.5", "@rollup/rollup-android-arm64": "4.64.5", "@rollup/rollup-darwin-arm64": "4.64.5", "@rollup/rollup-darwin-x64": "4.64.5", "@rollup/rollup-freebsd-arm64": "4.64.5", "@rollup/rollup-freebsd-x64": "4.64.5", "@rollup/rollup-linux-arm-gnueabihf": "4.64.5", "@rollup/rollup-linux-arm-musleabihf": "4.64.5", "@rollup/rollup-linux-arm64-gnu": "4.64.5", "@rollup/rollup-linux-arm64-musl": "4.64.5", "@rollup/rollup-linux-loong64-gnu": "4.64.5", "@rollup/rollup-linux-loong64-musl": "4.64.5", "@rollup/rollup-linux-ppc64-gnu": "4.64.5", "@rollup/rollup-linux-ppc64-musl": "4.64.5", "@rollup/rollup-linux-riscv64-gnu": "4.64.5", "@rollup/rollup-linux-riscv64-musl": "4.64.5", "@rollup/rollup-linux-s390x-gnu": "4.64.5", "@rollup/rollup-linux-x64-gnu": "4.64.5", "@rollup/rollup-linux-x64-musl": "4.64.5", "@rollup/rollup-openbsd-x64": "4.64.5", "@rollup/rollup-openharmony-arm64": "4.64.5", "@rollup/rollup-win32-arm64-msvc": "4.64.5", "@rollup/rollup-win32-ia32-msvc": "4.64.5", "@rollup/rollup-win32-x64-gnu": "4.64.5", "@rollup/rollup-win32-x64-msvc": "4.64.5", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-EAP5ZHcz2ijbgCWwPkpI7zh6bfsri2Ez5FtCy2dCp1k62duXzfG7DLGGh7RU2QQXWwavT+u4qpvDF/TWOiiGTw=="],
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"simple-code-frame": ["[email protected]", "", { "dependencies": { "kolorist": "^1.6.0" } }, "sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w=="],
"source-map": ["[email protected]", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"source-map-js": ["[email protected]", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
"stack-trace": ["[email protected]", "", {}, "sha512-H6D7134xi6qONvh7ZHKgviXf+rd3vhGBSvebPZCaUkd8zvQ+7PtDw6CljPTe4cXWNf2IKZGNqw6VJXSb9IgBpA=="],
"tailwindcss": ["[email protected]", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
"tapable": ["[email protected]", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"tinyglobby": ["[email protected]", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"update-browserslist-db": ["[email protected]", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A=="],
"vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q=="],
"vite-prerender-plugin": ["[email protected]", "", { "dependencies": { "kolorist": "^1.8.0", "magic-string": "0.x >= 0.26.0", "node-html-parser": "^6.1.12", "simple-code-frame": "^1.3.0", "source-map": "^0.7.4", "stack-trace": "^1.0.0-pre2" }, "peerDependencies": { "vite": "5.x || 6.x || 7.x || 8.x" } }, "sha512-X2w9AS1OhwEUec9HSryGAmEnEQJGhW6eFoiCVJv8BfOTVpM8SOjk75cEw/IC28APDHZudeIRbP8Sp7wfABq+Aw=="],
"yallist": ["[email protected]", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"zimmerframe": ["[email protected]", "", {}, "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA=="],
"@prefresh/vite/@rollup/pluginutils": ["@rollup/[email protected]", "", { "dependencies": { "estree-walker": "^2.0.1", "picomatch": "^2.2.2" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/[email protected]", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["[email protected]", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@prefresh/vite/@rollup/pluginutils/picomatch": ["[email protected]", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
}
}
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<meta name="description" content="KeepNote — your notes on Dcntrlized" />
<title>KeepNote</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
{
"name": "keepnote-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite",
"preview": "vite preview"
},
"dependencies": {
"@preact/signals": "^2.0.1",
"preact": "^10.25.4"
},
"devDependencies": {
"@preact/preset-vite": "^2.9.4",
"@tailwindcss/vite": "^4.0.6",
"tailwindcss": "^4.0.6",
"typescript": "^5.7.3",
"vite": "^6.0.11"
}
}
+15
View File
@@ -0,0 +1,15 @@
import type { ComponentChildren } from 'preact';
interface BannerProps {
kind: 'error' | 'warn' | 'info';
children: ComponentChildren;
role?: 'alert' | 'status';
}
export function Banner({ kind, children, role = 'alert' }: BannerProps) {
return (
<div className={`banner banner-${kind}`} role={role}>
{children}
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { useEffect, useRef } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
interface ConfirmDialogProps {
title: string;
body: ComponentChildren;
confirmLabel: string;
cancelLabel?: string;
busy: boolean;
busyLabel?: string;
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmDialog({
title,
body,
confirmLabel,
cancelLabel = 'Cancel',
busy,
busyLabel,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const titleId = useRef(`dlg-${Math.random().toString(36).slice(2, 8)}`).current;
const bodyId = `body-${titleId}`;
const cancelRef = useRef<HTMLButtonElement>(null);
const confirmRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
// focus the Cancel (default) button on open
cancelRef.current?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onCancel();
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// simple focus trap
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const focusables = [cancelRef.current, confirmRef.current].filter(Boolean) as HTMLElement[];
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, []);
return (
<div className="scrim">
<div
className="card"
style={{ width: '100%', maxWidth: 400, padding: 24 }}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={bodyId}
>
<h2 id={titleId} style={{ margin: '0 0 8px', fontSize: 20, lineHeight: 1.3 }}>
{title}
</h2>
<p id={bodyId} style={{ margin: '0 0 24px', color: 'var(--dn-ink-soft)' }}>
{body}
</p>
<div className="flex justify-end gap-3">
<button
ref={cancelRef}
type="button"
className="btn btn-ghost"
disabled={busy}
onClick={onCancel}
>
{cancelLabel}
</button>
<button
ref={confirmRef}
type="button"
className="btn btn-danger"
disabled={busy}
onClick={onConfirm}
>
{busy ? (
<>
<span className="spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />
{busyLabel ?? confirmLabel}
</>
) : (
confirmLabel
)}
</button>
</div>
</div>
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
import type { ComponentChildren } from 'preact';
import { Link } from '../router';
import { authState, boot, bootError, cachedProfileUsername } from '../lib/auth';
import { banner, clearBanner, profile } from '../lib/api';
import { pendingCount } from '../state/store';
import type { RouteName } from '../router';
function Wordmark({ compact = false }: { compact?: boolean }) {
return (
<span
className="font-bold"
style={{ fontSize: compact ? 20 : 22, color: 'var(--dn-accent)', letterSpacing: '-0.02em' }}
>
KeepNote
</span>
);
}
function SettingsIcon() {
return (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M12 15a3 3 0 100-6 3 3 0 000 6z"
stroke="currentColor"
strokeWidth="1.8"
/>
<path
d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 110 4h-.09a1.65 1.65 0 00-1.51 1z"
stroke="currentColor"
strokeWidth="1.6"
/>
</svg>
);
}
function ChevronLeft() {
return (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M15 18l-6-6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
interface ShellProps {
route: RouteName;
title?: string;
backTo?: string;
children: ComponentChildren;
}
export function Shell({ route, title, backTo, children }: ShellProps) {
const unknown = authState.value === 'unknown';
const tBanner = banner.value;
const pending = pendingCount();
const username = profile.value?.username ?? cachedProfileUsername();
const retryBoot = () => {
clearBanner();
boot();
};
const railActive = (r: RouteName) => (route === r ? 'true' : undefined);
return (
<div className="min-h-screen flex flex-col">
<a href="#main" className="skip-link">
Skip to content
</a>
{unknown && (
<div className="banner banner-error" role="alert">
<span>Can&apos;t reach the server.</span>
<button type="button" className="banner-action" onClick={retryBoot}>
Retry
</button>
</div>
)}
{tBanner && (
<div className={`banner banner-${tBanner.kind}`} role="status">
<span>{tBanner.text}</span>
<button type="button" className="banner-action" onClick={clearBanner}>
Dismiss
</button>
</div>
)}
<div className="flex flex-1 min-h-0">
{/* Persistent left rail (>=1024) */}
<nav className="rail hidden lg:flex" aria-label="Primary">
<div className="px-5 pt-5 pb-4">
<Wordmark />
</div>
<ul className="flex-1 px-3 space-y-1">
<li>
<Link
href="/notes"
aria-current={railActive('/notes')}
className="flex items-center gap-3 rounded-lg px-3 py-2.5 font-medium"
style={{ minHeight: 44, color: route === '/notes' ? 'var(--dn-accent)' : 'var(--dn-ink)' }}
>
<span style={{ fontSize: 18 }} aria-hidden="true">
📝
</span>
Notes
</Link>
</li>
<li>
<Link
href="/settings"
aria-current={railActive('/settings')}
className="flex items-center gap-3 rounded-lg px-3 py-2.5 font-medium"
style={{ minHeight: 44, color: route === '/settings' ? 'var(--dn-accent)' : 'var(--dn-ink)' }}
>
<SettingsIcon />
Settings
</Link>
</li>
</ul>
<div className="px-5 py-4 border-t" style={{ borderColor: 'var(--dn-line)' }}>
<div className="mono" style={{ color: 'var(--dn-muted)' }}>
{pending > 0 ? `${pending} pending change${pending === 1 ? '' : 's'}` : 'All synced'}
</div>
{username && (
<div className="mono truncate" style={{ color: 'var(--dn-muted)' }} title={username}>
{username}
</div>
)}
</div>
</nav>
<div className="flex-1 flex flex-col min-w-0">
{/* Top bar (<1024) */}
<header className="topbar lg:hidden">
{route === '/notes' && (
<>
<span className="flex-1">
<Wordmark compact />
</span>
<Link
href="/settings"
className="icon-btn"
aria-label="Settings"
title="Settings"
>
<SettingsIcon />
</Link>
</>
)}
{route === '/settings' && (
<>
<Link href={backTo ?? '/notes'} className="icon-btn" aria-label="Back">
<ChevronLeft />
</Link>
<span className="flex-1 font-semibold" style={{ fontSize: 20 }}>
{title ?? 'Settings'}
</span>
</>
)}
{(route === '/notes/new' || route === '/notes/:noteId') && (
<>
<Link href="/notes" className="icon-btn" aria-label="Close editor" title="Close editor">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</Link>
<span className="flex-1 font-medium truncate px-3" style={{ fontSize: 18 }}>
{title ?? ''}
</span>
</>
)}
</header>
<main id="main" className="flex-1 min-w-0 overflow-y-auto" tabIndex={-1}>
{children}
</main>
</div>
</div>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
export function Spinner() {
return <span className="spinner" aria-hidden="true" />;
}
export function LoadingRow({ text = 'Loading…' }: { text?: string }) {
return (
<div className="flex items-center gap-3 py-8" role="status">
<Spinner />
<span>{text}</span>
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { useRef, useState } from 'preact/hooks';
interface TagInputProps {
tags: string[];
onChange: (tags: string[]) => void;
}
function normalizeTag(raw: string): string | null {
let t = raw.trim();
if (t.startsWith('#')) t = t.slice(1).trim();
if (!t) return null;
return t.slice(0, 32);
}
export function TagInput({ tags, onChange }: TagInputProps) {
const [value, setValue] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const add = (raw: string) => {
const t = normalizeTag(raw);
if (!t) return;
if (!tags.includes(t)) onChange([...tags, t]);
setValue('');
};
const remove = (tag: string) => {
onChange(tags.filter((t) => t !== tag));
inputRef.current?.focus();
};
return (
<div>
<span className="field-label" id="tags-label">
Tags
</span>
<div className="flex flex-wrap items-center gap-2">
{tags.map((tag) => (
<span key={tag} className="tag-chip">
{tag}
<button
type="button"
className="tag-remove"
aria-label={`Remove tag ${tag}`}
onClick={() => remove(tag)}
>
<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
<path
d="M3 3l6 6M9 3l-6 6"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
</span>
))}
<input
ref={inputRef}
className="field"
style={{ maxWidth: 200, minWidth: 140, minHeight: 40, padding: '4px 12px' }}
type="text"
value={value}
maxLength={32}
placeholder="# Add tag"
aria-label="Add tag"
aria-labelledby="tags-label"
spellcheck={false}
onInput={(e) => setValue((e.target as HTMLInputElement).value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
add(value);
} else if (e.key === 'Backspace' && value === '' && tags.length > 0) {
e.preventDefault();
onChange(tags.slice(0, -1));
}
}}
onBlur={() => {
if (value.trim()) add(value);
}}
/>
</div>
</div>
);
}
+524
View File
@@ -0,0 +1,524 @@
@import "tailwindcss";
/* ============================================================================
KeepNote Web — frozen design tokens (planning/web/ux/keepnote.md §6)
The token layer is plain CSS custom properties on :root / [data-theme="dark"].
Tailwind is used only for layout utilities; semantic colours always resolve
through these variables.
========================================================================== */
:root {
color-scheme: light;
/* 6.1 colour tokens — light */
--dn-ground: #e9ecf1;
--dn-surface: #f7f8fa;
--dn-panel: #ffffff;
--dn-ink: #171a21;
--dn-ink-soft: #333b47;
--dn-muted: #5a6473;
--dn-line: #e0e4ea;
--dn-line-strong: #c3cad6;
--dn-accent: #b00b69;
--dn-accent-ink: #ffffff;
--dn-accent-on-soft: #b00b69;
--dn-accent-soft: rgba(176, 11, 105, 0.1);
--dn-ok: #1f7a4d;
--dn-warn: #96580a;
--dn-warn-soft: rgba(150, 88, 10, 0.12);
--dn-bad: #b3261e;
--dn-bad-ink: #ffffff;
--dn-bad-soft: rgba(179, 38, 30, 0.1);
/* 6.3 spacing rhythm */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
/* 6.3 radius */
--radius-control: 8px;
--radius-card: 12px;
--radius-sheet: 18px;
--radius-pill: 9999px;
/* 6.3 elevation / scrim */
--shadow-card: 0 0 0 1px var(--dn-line);
--shadow-lift: 0 2px 8px rgba(23, 26, 33, 0.14);
--scrim: rgba(23, 26, 33, 0.5);
}
[data-theme="dark"] {
color-scheme: dark;
--dn-ground: #101318;
--dn-surface: #171b22;
--dn-panel: #1f242d;
--dn-ink: #e8ecf2;
--dn-ink-soft: #c3cbd6;
--dn-muted: #98a2b3;
--dn-line: #2c333e;
--dn-line-strong: #4a5364;
--dn-accent: #e556a4;
--dn-accent-ink: #171a21;
--dn-accent-on-soft: #f08bc4;
--dn-accent-soft: rgba(229, 86, 164, 0.16);
--dn-ok: #41c98a;
--dn-warn: #e3a63c;
--dn-warn-soft: rgba(227, 166, 60, 0.14);
--dn-bad: #f2807a;
--dn-bad-ink: #171a21;
--dn-bad-soft: rgba(242, 128, 122, 0.14);
--shadow-card: 0 0 0 1px var(--dn-line);
--shadow-lift: 0 2px 8px rgba(0, 0, 0, 0.5);
--scrim: rgba(0, 0, 0, 0.6);
}
/* prefers-contrast: more — swap line/muted tokens (6.2/6.3) */
@media (prefers-contrast: more) {
:root {
--dn-line: var(--dn-line-strong);
--dn-muted: var(--dn-ink-soft);
}
[data-theme="dark"] {
--dn-line: var(--dn-line-strong);
--dn-muted: var(--dn-ink-soft);
}
}
/* prefers-reduced-motion — remove non-essential transitions (6.3) */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition-duration: 0.001ms !important;
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
}
}
/* ---------------------------------------------------------------------------
Base
------------------------------------------------------------------------ */
html,
body,
#app {
height: 100%;
}
body {
margin: 0;
background: var(--dn-ground);
color: var(--dn-ink);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
font-size: 15px;
line-height: 1.466;
-webkit-font-smoothing: antialiased;
}
/* Semantic aliases used across components */
.dn-surface {
background: var(--dn-surface);
color: var(--dn-ink);
}
.dn-panel {
background: var(--dn-panel);
color: var(--dn-ink);
}
.dn-accent-bg {
background: var(--dn-accent);
color: var(--dn-accent-ink);
}
.dn-bad-bg {
background: var(--dn-bad);
color: var(--dn-bad-ink);
}
.dn-chip {
background: var(--dn-accent-soft);
color: var(--dn-accent-on-soft);
}
.dn-warn-chip {
background: var(--dn-warn-soft);
color: var(--dn-ink);
}
/* ---------------------------------------------------------------------------
Focus ring: 2px accent, 2px offset, :focus-visible only
------------------------------------------------------------------------ */
:focus {
outline: none;
}
:focus-visible {
outline: 2px solid var(--dn-accent);
outline-offset: 2px;
}
/* Skip-to-content link — first tab stop */
.skip-link {
position: absolute;
left: -9999px;
top: 0;
z-index: 100;
padding: 8px 12px;
background: var(--dn-accent);
color: var(--dn-accent-ink);
border-radius: 0 0 8px 0;
font-weight: 600;
}
.skip-link:focus {
left: 0;
}
/* Visually hidden (a11y helper) */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ---------------------------------------------------------------------------
Buttons / inputs
------------------------------------------------------------------------ */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
padding: 0 16px;
border-radius: var(--radius-control);
font-size: 15px;
font-weight: 600;
border: 1px solid transparent;
cursor: pointer;
transition: background 150ms ease-out, color 150ms ease-out;
user-select: none;
}
.btn:disabled {
opacity: 0.6;
cursor: default;
}
.btn-primary {
background: var(--dn-accent);
color: var(--dn-accent-ink);
}
.btn-primary:not(:disabled):hover {
filter: brightness(1.04);
}
.btn-danger {
background: var(--dn-bad);
color: var(--dn-bad-ink);
}
.btn-danger:not(:disabled):hover {
filter: brightness(1.04);
}
.btn-ghost {
background: transparent;
color: var(--dn-ink);
border-color: var(--dn-line);
}
.btn-ghost:not(:disabled):hover {
background: var(--dn-surface);
}
.btn-link {
background: transparent;
color: var(--dn-accent);
min-height: 44px;
padding: 0 8px;
}
.btn-link:not(:disabled):hover {
text-decoration: underline;
}
/* Icon buttons ≥ 44×44 */
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: var(--radius-control);
border: none;
background: transparent;
color: var(--dn-ink);
cursor: pointer;
transition: background 150ms ease-out;
}
.icon-btn:hover {
background: var(--dn-surface);
}
.icon-btn:disabled {
opacity: 0.6;
cursor: default;
}
/* Text fields */
.field {
width: 100%;
min-height: 44px;
padding: 8px 12px;
background: var(--dn-surface);
color: var(--dn-ink);
border: 1px solid var(--dn-line);
border-radius: var(--radius-control);
font-size: 15px;
line-height: 1.4;
transition: border-color 150ms ease-out;
}
.field::placeholder {
color: var(--dn-muted);
}
.field:focus-visible {
border-color: var(--dn-accent);
}
.field-label {
display: block;
margin-bottom: 6px;
font-size: 13px;
font-weight: 600;
color: var(--dn-muted);
}
/* ---------------------------------------------------------------------------
Cards / panels
------------------------------------------------------------------------ */
.card {
background: var(--dn-panel);
color: var(--dn-ink);
border: 1px solid var(--dn-line);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
}
.note-card {
display: block;
padding: 16px;
background: var(--dn-panel);
color: var(--dn-ink);
border: 1px solid var(--dn-line);
border-radius: var(--radius-card);
text-decoration: none;
transition: border-color 150ms ease-out;
}
.note-card:hover {
border-color: var(--dn-accent);
}
/* ---------------------------------------------------------------------------
Banners
------------------------------------------------------------------------ */
.banner {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
font-size: 13px;
line-height: 1.4;
}
.banner-error {
background: var(--dn-bad-soft);
color: var(--dn-ink);
}
.banner-warn {
background: var(--dn-warn-soft);
color: var(--dn-ink);
}
.banner-info {
background: var(--dn-surface);
color: var(--dn-ink);
border-bottom: 1px solid var(--dn-line);
}
.banner .banner-action {
margin-left: auto;
min-height: 36px;
padding: 0 12px;
border-radius: var(--radius-control);
background: transparent;
color: var(--dn-accent);
font-weight: 600;
border: none;
cursor: pointer;
}
.banner .banner-action:hover {
text-decoration: underline;
}
/* ---------------------------------------------------------------------------
Spinner
------------------------------------------------------------------------ */
.spinner {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid var(--dn-line);
border-top-color: var(--dn-accent);
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ---------------------------------------------------------------------------
Login card
------------------------------------------------------------------------ */
.logo-tile {
width: 48px;
height: 48px;
border-radius: 12px;
background: var(--dn-accent);
color: var(--dn-accent-ink);
display: flex;
align-items: center;
justify-content: center;
}
/* ---------------------------------------------------------------------------
Tags / chips
------------------------------------------------------------------------ */
.tag-chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 240px;
padding: 2px 8px;
border-radius: var(--radius-pill);
background: var(--dn-accent-soft);
color: var(--dn-accent-on-soft);
font-size: 12px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.tag-chip .tag-remove {
width: 20px;
height: 20px;
flex: none;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
line-height: 1;
padding: 0;
}
/* Mono origin / ids */
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
font-variant-numeric: tabular-nums;
}
/* Rail — persistent only at >=1024px (UX §3.3). These rules are unlayered, so
they outrank Tailwind's layered `hidden lg:flex`; the breakpoint lives here
to keep one source of truth. */
.rail {
width: 240px;
flex: none;
display: none;
flex-direction: column;
border-right: 1px solid var(--dn-line);
background: var(--dn-surface);
}
@media (min-width: 1024px) {
.rail {
display: flex;
}
}
/* Top bar — compact/tablet only, hidden once the rail appears. */
.topbar {
height: 56px;
display: flex;
align-items: center;
padding: 0 16px;
border-bottom: 1px solid var(--dn-line);
background: var(--dn-surface);
}
@media (min-width: 1024px) {
.topbar {
display: none;
}
}
/* FAB */
.fab {
position: fixed;
right: 24px;
bottom: 24px;
width: 56px;
height: 56px;
border-radius: var(--radius-pill);
background: var(--dn-accent);
color: var(--dn-accent-ink);
border: none;
box-shadow: var(--shadow-lift);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
text-decoration: none;
}
.fab:hover {
filter: brightness(1.04);
}
/* Switch */
.switch {
position: relative;
width: 48px;
height: 28px;
border-radius: var(--radius-pill);
border: 1px solid var(--dn-line);
background: var(--dn-surface);
cursor: pointer;
flex: none;
transition: background 150ms ease-out;
}
.switch[aria-checked="true"] {
background: var(--dn-accent);
border-color: var(--dn-accent);
}
.switch .knob {
position: absolute;
top: 2px;
left: 2px;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--dn-ink);
transition: transform 150ms ease-out;
}
.switch[aria-checked="true"] .knob {
transform: translateX(20px);
background: var(--dn-accent-ink);
}
/* Dialog scrim */
.scrim {
position: fixed;
inset: 0;
background: var(--scrim);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 50;
}
+239
View File
@@ -0,0 +1,239 @@
// Global fetch wrapper for the frozen cookie edge (UX §4.6).
// - same-origin relative /web/* URLs only; never sends an auth header
// - on 401 from /web/action/* or /web/auth/me: ONE shared (single-flight)
// bodyless POST /web/auth/refresh, then retry the original once
// - refresh 401 (ERR_REFRESH_*) -> session cleared -> /login?next=...
// - refresh 502 -> keep session, transient banner (no retry loop, no redirect)
// - error extraction: JSON error.message -> code map -> plain-text map ->
// "Something went wrong (HTTP <status>)"
import { signal } from '@preact/signals';
import type { User } from '../types';
import { KEYS, readJSON, writeJSON } from './storage';
// --- Session / profile signals -------------------------------------------------
export const profile = signal<{ username: string; email: string } | null>(null);
// Set when the session is cleared mid-session so the app can redirect to login.
export const sessionCleared = signal(false);
// --- Transient connection banner ------------------------------------------------
export type BannerKind = 'error' | 'warn' | 'info';
export const banner = signal<{ kind: BannerKind; text: string } | null>(null);
let bannerTimer: number | undefined;
export function showBanner(kind: BannerKind, text: string, autoClearMs = 6000): void {
banner.value = { kind, text };
if (bannerTimer) window.clearTimeout(bannerTimer);
bannerTimer = window.setTimeout(() => {
if (banner.value?.text === text) banner.value = null;
}, autoClearMs);
}
export function clearBanner(): void {
banner.value = null;
}
// --- ApiError --------------------------------------------------------------------
export class ApiError extends Error {
readonly status: number | null;
readonly code: string | null;
readonly offline: boolean;
constructor(status: number | null, message: string, code?: string | null, offline = false) {
super(message);
this.status = status;
this.code = code ?? null;
this.offline = offline;
}
}
export function isOfflineError(e: unknown): e is ApiError {
return e instanceof ApiError && e.offline;
}
const CODE_MAP: Record<string, string> = {
ERR_CSRF: 'Request blocked — reload the page and try again',
ERR_NOT_FOUND: 'Not found',
ERR_METHOD_NOT_ALLOWED: 'Something went wrong (unsupported request)',
ERR_INVALID_REQUEST: 'The server rejected the request',
ERR_INTERNAL: 'Server error — try again',
ERR_AUTH_FAILED: 'Incorrect email or password',
ERR_EMAIL_TAKEN: 'An account with this email already exists',
ERR_USERNAME_TAKEN: 'That username is taken',
ERR_TOKEN_INVALID: 'Please sign in again',
ERR_TOKEN_EXPIRED: 'Please sign in again',
};
const TEXT_MAP: Record<string, string> = {
'note not found': 'Not found',
'missing or invalid note id': 'Not found',
'invalid payload': 'The server rejected the request',
'action not implemented': 'Something went wrong (unsupported request)',
'method not allowed': 'Something went wrong (unsupported request)',
};
export function profileFromUser(user: User): { username: string; email: string } {
return { username: user.username, email: user.email };
}
export function cacheProfile(profileValue: { username: string; email: string }): void {
writeJSON(KEYS.profile, profileValue);
}
// --- Error extraction --------------------------------------------------------------
export async function toApiError(res: Response): Promise<ApiError> {
const status = res.status;
let body: unknown;
try {
body = await res.json();
} catch {
body = undefined;
}
if (body && typeof body === 'object') {
const err = (body as { error?: unknown }).error;
if (err && typeof err === 'object') {
const e = err as { code?: unknown; message?: unknown };
if (typeof e.message === 'string' && e.message.length > 0) {
return new ApiError(status, e.message, typeof e.code === 'string' ? e.code : null);
}
if (typeof e.code === 'string') {
const code = e.code;
const msg =
CODE_MAP[code] ??
(status >= 500 ? CODE_MAP.ERR_INTERNAL : 'Something went wrong');
return new ApiError(status, msg, code);
}
}
}
// plain text
let text = '';
try {
text = await res.text();
} catch {
text = '';
}
const lower = text.trim().toLowerCase();
const mapped =
TEXT_MAP[lower] ??
(status >= 500 ? CODE_MAP.ERR_INTERNAL : `Something went wrong (HTTP ${status})`);
return new ApiError(status, mapped);
}
// --- Refresh (single-flight) --------------------------------------------------------
export type RefreshResult = 'ok' | 'cleared' | 'transient';
let refreshInFlight: Promise<RefreshResult> | null = null;
async function doRefresh(): Promise<RefreshResult> {
let res: Response;
try {
res = await fetch('/web/auth/refresh', { method: 'POST' });
} catch {
// network error / offline — keep session, do not clear
return 'transient';
}
if (res.status === 200) {
try {
const data = await res.json();
if (data && data.user) {
const p = profileFromUser(data.user);
profile.value = p;
cacheProfile(p);
}
} catch {
/* ignore body parse */
}
return 'ok';
}
if (res.status === 502) {
showBanner('warn', 'Connection problem — your session is still active.');
return 'transient';
}
// 401 (ERR_REFRESH_* / ERR_UNAUTHENTICATED) or other — cookies already cleared
return 'cleared';
}
export function singleFlightRefresh(): Promise<RefreshResult> {
if (refreshInFlight) return refreshInFlight;
refreshInFlight = doRefresh().finally(() => {
refreshInFlight = null;
});
return refreshInFlight;
}
export function handleSessionCleared(nextPath: string): void {
sessionCleared.value = true;
const target = `/login?next=${encodeURIComponent(nextPath)}`;
window.history.replaceState({}, '', target);
window.dispatchEvent(new PopStateEvent('popstate'));
}
// --- Main fetch wrapper -------------------------------------------------------------
export interface FetchOpts extends RequestInit {
refreshOn401?: boolean;
timeoutMs?: number;
}
export async function apiFetch(path: string, opts: FetchOpts = {}): Promise<Response> {
const { refreshOn401 = true, timeoutMs, ...init } = opts;
if (typeof navigator !== 'undefined' && !navigator.onLine) {
throw new ApiError(null, 'Network error', null, true);
}
let controller: AbortController | undefined;
let timer: number | undefined;
if (timeoutMs) {
controller = new AbortController();
timer = window.setTimeout(() => controller?.abort(), timeoutMs);
}
let res: Response;
try {
res = await fetch(path, { ...init, signal: controller?.signal });
} catch {
if (controller?.signal.aborted) throw new ApiError(null, 'Request timed out', null, true);
throw new ApiError(null, 'Network error', null, true);
} finally {
if (timer) window.clearTimeout(timer);
}
if (res.status === 401 && refreshOn401) {
const result = await singleFlightRefresh();
if (result === 'ok') {
try {
res = await fetch(path, init);
} catch {
throw new ApiError(null, 'Network error', null, true);
}
} else if (result === 'cleared') {
handleSessionCleared(path);
throw new ApiError(401, 'Please sign in again', 'ERR_UNAUTHENTICATED');
} else {
// transient — keep session, surface banner (already shown by refresh)
throw new ApiError(null, 'Connection problem — your session is still active.', null, true);
}
}
return res;
}
/** Perform a request and throw ApiError on any non-2xx (also used for 204). */
export async function apiRequest<T = Response>(
path: string,
init: RequestInit = {},
opts: FetchOpts = {}
): Promise<T> {
const res = await apiFetch(path, { ...init, ...opts });
if (!res.ok) throw await toApiError(res);
if (res.status === 204 || res.status === 200 && res.headers.get('content-length') === '0') {
return undefined as unknown as T;
}
return (await res.json()) as T;
}
export function jsonBody(body: unknown, method = 'POST'): RequestInit {
return {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
};
}
+110
View File
@@ -0,0 +1,110 @@
// Boot / auth gate (UX §3.2).
// GET /web/auth/me (8s) -> 200 authed | 401 -> single-flight refresh -> retry
// refresh 401 -> anonymous -> /login?next= | network/502 -> "unknown" state
// (shell + cached profile + "Can't reach the server." + Retry). Never
// auto-redirect to /login on unknown.
import { signal } from '@preact/signals';
import {
apiFetch,
ApiError,
profile,
profileFromUser,
cacheProfile,
singleFlightRefresh,
} from './api';
import type { AuthState, User } from '../types';
import { KEYS, readJSON, removeKey } from './storage';
export const authState = signal<AuthState>('loading');
// Message shown in the shell banner when state === 'unknown'.
export const bootError = signal<string | null>(null);
// Bump to re-run boot (Retry).
export const bootTick = signal(0);
export function cachedProfileUsername(): string {
const p = readJSON<{ username?: string } | null>(KEYS.profile, null);
return p?.username ?? '';
}
export function setAuthed(user: User): void {
authState.value = 'authed';
const p = profileFromUser(user);
profile.value = p;
cacheProfile(p);
}
export function setAnonymous(): void {
authState.value = 'anonymous';
// web.profile is kept for the boot shell (only logout clears it).
}
export async function boot(): Promise<void> {
authState.value = 'loading';
bootError.value = null;
try {
const res = await apiFetch('/web/auth/me', { refreshOn401: false, timeoutMs: 8000 });
if (res.status === 200) {
const data = (await res.json()) as { user?: User };
if (data?.user) {
setAuthed(data.user);
return;
}
authState.value = 'unknown';
bootError.value = "Can't reach the server.";
return;
}
if (res.status === 401) {
const result = await singleFlightRefresh();
if (result === 'ok') {
const res2 = await apiFetch('/web/auth/me', { refreshOn401: false, timeoutMs: 8000 });
if (res2.status === 200) {
const data = (await res2.json()) as { user?: User };
if (data?.user) {
setAuthed(data.user);
return;
}
}
authState.value = 'unknown';
bootError.value = "Can't reach the server.";
return;
}
if (result === 'cleared') {
setAnonymous();
return;
}
// transient
authState.value = 'unknown';
bootError.value = "Can't reach the server.";
return;
}
// 502 or any other non-200
authState.value = 'unknown';
bootError.value = "Can't reach the server.";
} catch (e) {
// network error / timeout / ApiError -> unknown
if (e instanceof ApiError) {
authState.value = 'unknown';
bootError.value = "Can't reach the server.";
return;
}
authState.value = 'unknown';
bootError.value = "Can't reach the server.";
}
}
/** Optimistic sign-out: clear profile, go to /login, fire-and-forget logout. */
export function logout(): void {
removeKey(KEYS.profile);
profile.value = null;
authState.value = 'anonymous';
const next = '/notes';
const target = `/login?next=${encodeURIComponent(next)}`;
window.history.replaceState({}, '', target);
window.dispatchEvent(new PopStateEvent('popstate'));
// bodyless fire-and-forget (edge always answers 200)
fetch('/web/auth/logout', { method: 'POST' }).catch(() => {
/* best effort */
});
}
+27
View File
@@ -0,0 +1,27 @@
// Per-route editor drafts (UX §3.2 / O3). Keyed by route ("new" or noteId) in
// web.drafts. Debounced persistence + restore-with-notice on return.
import type { Draft } from '../types';
import { KEYS, readJSON, writeJSON, removeKey } from './storage';
type DraftsMap = Record<string, Draft>;
export function getDraft(key: string): Draft | null {
const map = readJSON<DraftsMap>(KEYS.drafts, {});
return map[key] ?? null;
}
export function saveDraft(key: string, draft: Omit<Draft, 'savedAt'>): void {
const map = readJSON<DraftsMap>(KEYS.drafts, {});
map[key] = { ...draft, savedAt: Date.now() };
writeJSON(KEYS.drafts, map);
}
export function clearDraft(key: string): void {
const map = readJSON<DraftsMap>(KEYS.drafts, {});
if (map[key]) {
delete map[key];
writeJSON(KEYS.drafts, map);
}
if (Object.keys(map).length === 0) removeKey(KEYS.drafts);
}
+22
View File
@@ -0,0 +1,22 @@
import { useEffect, useState } from 'preact/hooks';
/** Re-render the caller on an interval (60s tick for relative times). */
export function useNow(intervalMs = 60000): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
setNow(Date.now());
const id = window.setInterval(() => setNow(Date.now()), intervalMs);
const onVis = () => {
if (!document.hidden) setNow(Date.now());
};
const onFocus = () => setNow(Date.now());
document.addEventListener('visibilitychange', onVis);
window.addEventListener('focus', onFocus);
return () => {
window.clearInterval(id);
document.removeEventListener('visibilitychange', onVis);
window.removeEventListener('focus', onFocus);
};
}, [intervalMs]);
return now;
}
+48
View File
@@ -0,0 +1,48 @@
// Relative-time formatting, ported verbatim from keepnote-client
// HomeScreen.tsx:15-31: "just now", Nm ago, Nh ago, Nd ago, then "Mon d".
const MONTHS = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
export function relativeTime(iso: string | undefined, now: number): string {
if (!iso) return '';
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return '';
const diff = Math.max(0, now - t);
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d ago`;
const d = new Date(t);
return `${MONTHS[d.getMonth()]} ${d.getDate()}`;
}
// Whitespace-collapse content and truncate at 140 chars + "…".
export function snippet(content: string): string {
const collapsed = content.replace(/\s+/g, ' ').trim();
if (!collapsed) return '';
if (collapsed.length <= 140) return collapsed;
return collapsed.slice(0, 140) + '…';
}
export function uid(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
}
return 'id-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
}
+55
View File
@@ -0,0 +1,55 @@
// localStorage persistence helpers + the frozen key table (UX §3.2).
// Keys: settings.theme, notes.cache, notes.sync.pending, notes.sync.lastSynced,
// web.profile, web.drafts. No tokens are ever stored (HttpOnly cookies only).
export const KEYS = {
theme: 'settings.theme',
cache: 'notes.cache',
pending: 'notes.sync.pending',
lastSynced: 'notes.sync.lastSynced',
profile: 'web.profile',
drafts: 'web.drafts',
} as const;
export function readJSON<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key);
if (raw == null) return fallback;
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
export function writeJSON(key: string, value: unknown): void {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
/* storage full/unavailable — keep the app functional in-memory */
}
}
export function readString(key: string, fallback: string): string {
try {
const v = localStorage.getItem(key);
return v == null ? fallback : v;
} catch {
return fallback;
}
}
export function writeString(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
/* ignore */
}
}
export function removeKey(key: string): void {
try {
localStorage.removeItem(key);
} catch {
/* ignore */
}
}
+28
View File
@@ -0,0 +1,28 @@
import { signal } from '@preact/signals';
import { KEYS, readString, writeString } from './storage';
export type Theme = 'light' | 'dark';
export const theme = signal<Theme>('light');
export function initTheme(): void {
const stored = readString(KEYS.theme, 'light');
const t: Theme = stored === 'dark' ? 'dark' : 'light';
theme.value = t;
applyTheme(t);
}
export function applyTheme(t: Theme): void {
document.documentElement.dataset.theme = t;
document.documentElement.style.colorScheme = t;
writeString(KEYS.theme, t);
}
export function setTheme(t: Theme): void {
theme.value = t;
applyTheme(t);
}
export function toggleTheme(): void {
setTheme(theme.value === 'dark' ? 'light' : 'dark');
}
+140
View File
@@ -0,0 +1,140 @@
import { render } from 'preact';
import { useEffect, useState } from 'preact/hooks';
// Design tokens + Tailwind layer. Must be imported for the bundle to emit CSS
// (and for the server's strict CSP to keep style-src 'self' happy).
import './index.css';
import {
currentPath,
currentSearch,
matchRoute,
navigate,
navigateReplace,
} from './router';
import { authState, boot } from './lib/auth';
import { initTheme } from './lib/theme';
import {
loadCache,
initSyncChannel,
initOnlineListeners,
} from './state/store';
import { sessionCleared } from './lib/api';
import { Shell } from './components/Shell';
import { Login } from './screens/Login';
import { NotesList } from './screens/NotesList';
import { Editor } from './screens/Editor';
import { Settings } from './screens/Settings';
import { Spinner } from './components/Spinner';
// Boot once before first render: theme + cached notes + cross-tab wiring,
// then run the auth gate (boot). The boot probe decides session state; it is
// never guessed from a cached value (UX §3.2).
initTheme();
loadCache();
initSyncChannel();
initOnlineListeners();
boot();
function Gate() {
const [, setTick] = useState(0);
const state = authState.value;
// Re-render on auth/session/path changes.
useEffect(() => {
const subs = [
authState.subscribe(() => setTick((t) => t + 1)),
sessionCleared.subscribe(() => setTick((t) => t + 1)),
];
const onPath = () => setTick((t) => t + 1);
window.addEventListener('popstate', onPath);
return () => {
subs.forEach((s) => s());
window.removeEventListener('popstate', onPath);
};
}, []);
const path = currentPath.value;
const search = currentSearch.value;
const route = matchRoute(path);
// Root "/" redirects based on the boot decision (replaces the history entry).
if (route?.name === '/') {
const target = state === 'authed' ? '/notes' : '/login';
navigateReplace(target);
return <LoadingShell />;
}
// Auth-less route: /login (also handles the session-cleared redirect target).
if (route?.name === '/login') {
if (state === 'authed') {
navigate('/notes');
return <LoadingShell />;
}
return <Login />;
}
// Session was cleared mid-session (401 after failed refresh): go to login.
if (sessionCleared.value) {
navigateReplace(`/login?next=${encodeURIComponent(path + search)}`);
return <LoadingShell />;
}
// Everything else is authed. While the boot probe is still in flight, show a
// loading shell — never a premature redirect.
if (state === 'loading') {
return <LoadingShell />;
}
if (state === 'anonymous') {
navigateReplace(`/login?next=${encodeURIComponent(path + search)}`);
return <LoadingShell />;
}
// state === 'authed' | 'unknown' — render the authed shell. 'unknown' renders
// the shell with the offline banner (Shell) and never redirects to /login.
if (!route) {
return (
<Shell route={null}>
<div className="max-w-[720px] mx-auto px-4 py-16 text-center">
<p style={{ fontSize: 20, fontWeight: 600, margin: '0 0 8px' }}>Not found</p>
<p style={{ color: 'var(--dn-muted)' }}>This page doesn't exist.</p>
</div>
</Shell>
);
}
const backTo = route.name === '/settings' ? '/notes' : undefined;
return (
<Shell route={route.name} backTo={backTo}>
<RouteView route={route} />
</Shell>
);
}
function RouteView({ route }: { route: NonNullable<ReturnType<typeof matchRoute>> }) {
switch (route.name) {
case '/notes':
return <NotesList />;
case '/notes/new':
return <Editor mode="create" />;
case '/notes/:noteId':
return <Editor mode="edit" noteId={route.params.noteId} />;
case '/settings':
return <Settings />;
default:
return null;
}
}
function LoadingShell() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="flex items-center gap-3" role="status">
<Spinner />
<span>Loading…</span>
</div>
</div>
);
}
render(<Gate />, document.getElementById('app') as HTMLElement);
+112
View File
@@ -0,0 +1,112 @@
// Tiny History-API router (no react-router). Real paths, pushState/replaceState,
// back/forward via popstate.
import { ComponentChildren } from 'preact';
import { signal } from '@preact/signals';
export const currentPath = signal<string>(window.location.pathname);
export const currentSearch = signal<string>(window.location.search);
// True once the app navigated client-side (used to decide /login autofocus —
// only focus on client-side navigation, never on cold page load).
export let didClientNavigate = false;
export type RouteName =
| '/'
| '/login'
| '/notes'
| '/notes/new'
| '/notes/:noteId'
| '/settings'
| null;
export interface Route {
name: Exclude<RouteName, null>;
params: Record<string, string>;
}
const ROUTES: { name: Exclude<RouteName, null>; pattern: string }[] = [
{ name: '/', pattern: '/' },
{ name: '/login', pattern: '/login' },
{ name: '/notes', pattern: '/notes' },
{ name: '/notes/new', pattern: '/notes/new' },
{ name: '/notes/:noteId', pattern: '/notes/:noteId' },
{ name: '/settings', pattern: '/settings' },
];
function compile(pattern: string): { regex: RegExp; keys: string[] } {
const keys: string[] = [];
const src = pattern.replace(/:[^/]+/g, (m) => {
keys.push(m.slice(1));
return '([^/]+)';
});
return { regex: new RegExp('^' + src + '$'), keys };
}
const COMPILED = ROUTES.map((r) => ({ ...r, ...compile(r.pattern) }));
export function matchRoute(path: string): Route | null {
for (const r of COMPILED) {
const m = path.match(r.regex);
if (m) {
const params: Record<string, string> = {};
r.keys.forEach((k, i) => {
params[k] = decodeURIComponent(m[i + 1]);
});
return { name: r.name, params };
}
}
return null;
}
function sync(): void {
currentPath.value = window.location.pathname;
currentSearch.value = window.location.search;
}
export function navigate(path: string): void {
didClientNavigate = true;
window.history.pushState({}, '', path);
sync();
}
export function navigateReplace(path: string): void {
didClientNavigate = true;
window.history.replaceState({}, '', path);
sync();
}
// Only accepts same-origin paths (must start with "/" and not "//").
export function sanitizeNext(next: string | null | undefined): string {
if (next && next.startsWith('/') && !next.startsWith('//')) return next;
return '/notes';
}
export function getQueryParam(search: string, key: string): string | null {
const params = new URLSearchParams(search);
return params.get(key);
}
export interface LinkProps
extends preact.JSX.HTMLAttributes<HTMLAnchorElement> {
href: string;
children?: ComponentChildren;
}
export function Link({ href, children, onClick, ...rest }: LinkProps) {
return (
<a
href={href}
{...rest}
onClick={(e) => {
if (onClick) onClick(e as preact.JSX.TargetedMouseEvent<HTMLAnchorElement>);
if (e.defaultPrevented) return;
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
navigate(href);
}}
>
{children}
</a>
);
}
+566
View File
@@ -0,0 +1,566 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { navigate } from '../router';
import { ApiError, apiFetch, isOfflineError } from '../lib/api';
import { TagInput } from '../components/TagInput';
import { ConfirmDialog } from '../components/ConfirmDialog';
import { Spinner, LoadingRow } from '../components/Spinner';
import { getDraft, saveDraft, clearDraft } from '../lib/drafts';
import type { Note } from '../types';
import {
findNote,
isOfflineNote,
optimisticInsert,
optimisticUpdate,
optimisticDelete,
enqueueCreate,
enqueueUpdate,
enqueueDelete,
saveCreateOnline,
saveUpdateOnline,
deleteNoteOnline,
createNotice,
} from '../state/store';
interface EditorProps {
mode: 'create' | 'edit';
noteId?: string;
}
type LoadState = 'resolving' | 'ready' | 'not-found' | 'load-error' | 'offline';
export function Editor({ mode, noteId }: EditorProps) {
const isCreate = mode === 'create';
const draftKey = isCreate ? 'new' : noteId ?? '';
const noteIdResolved = useMemo(
() => (isCreate ? null : noteId ?? null),
[isCreate, noteId]
);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [tags, setTags] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [validation, setValidation] = useState(false);
const [loadState, setLoadState] = useState<LoadState>(
isCreate ? 'ready' : 'resolving'
);
const [loadError, setLoadError] = useState<string | null>(null);
const [retryTick, setRetryTick] = useState(0);
const [confirmOpen, setConfirmOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
const [draftRestored, setDraftRestored] = useState(false);
const [isPendingNote, setIsPendingNote] = useState(false);
const titleRef = useRef<HTMLInputElement>(null);
const localId = () =>
'local-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
// --- Deep-link / edit resolution (UX §4.4) --------------------------------------
useEffect(() => {
if (isCreate) {
setLoadState('ready');
return;
}
const id = noteId ?? '';
// local-… id → cache-only editor; getNote is never called.
if (isOfflineNote(id)) {
const cached = findNote(id);
if (cached) {
setTitle(cached.title);
setContent(cached.content);
setTags(cached.tags ?? []);
setIsPendingNote(true);
setLoadState('ready');
} else {
setLoadState('offline');
}
return;
}
const cached = findNote(id);
if (cached) {
setTitle(cached.title);
setContent(cached.content);
setTags(cached.tags ?? []);
setLoadState('ready');
return;
}
// Not cached → deep link: GET /web/action/getNote?id= (via apiFetch so a
// 401 triggers the single-flight refresh + one retry, UX §4.6).
let cancelled = false;
(async () => {
setLoadState('resolving');
setLoadError(null);
try {
const res = await apiFetch(`/web/action/getNote?id=${encodeURIComponent(id)}`, {
refreshOn401: true,
timeoutMs: 8000,
});
if (cancelled) return;
if (res.status === 200) {
const note = await res.json();
note.tags = note.tags ?? [];
if (cancelled) return;
optimisticInsert(note);
setTitle(note.title);
setContent(note.content);
setTags(note.tags);
setLoadState('ready');
} else if (res.status === 400 || res.status === 404) {
// 400 (invalid id) or 404 → not-found, never retried in a loop
setLoadState('not-found');
} else {
setLoadState('load-error');
setLoadError(`Something went wrong (HTTP ${res.status})`);
}
} catch (e) {
if (cancelled) return;
if (e instanceof ApiError && e.status === 401) {
// session cleared → apiFetch already redirected to /login
setLoadState('load-error');
setLoadError('Please sign in again');
} else if (isOfflineError(e)) {
setLoadState('offline');
} else {
setLoadState('load-error');
setLoadError(e instanceof Error ? e.message : 'Something went wrong');
}
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isCreate, noteId, retryTick]);
// Auto-retry a deep-link offline load when the network returns (C16).
useEffect(() => {
if (loadState !== 'offline') return;
const onOnline = () => setRetryTick((t) => t + 1);
window.addEventListener('online', onOnline);
return () => window.removeEventListener('online', onOnline);
}, [loadState]);
// --- Restore draft (O3) + focus the title input ----------------------------------
useEffect(() => {
if (loadState !== 'ready') return;
const d = getDraft(draftKey);
if (d && !isPendingNote) {
setTitle(d.title);
setContent(d.content);
setTags(d.tags ?? []);
setDraftRestored(true);
}
titleRef.current?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loadState]);
// --- Debounced draft persistence + beforeunload (O3) -----------------------------
useEffect(() => {
if (loadState !== 'ready' || !dirty) return;
const id = window.setTimeout(() => {
saveDraft(draftKey, { title, content, tags });
}, 400);
return () => window.clearTimeout(id);
}, [title, content, tags, dirty, loadState, draftKey]);
useEffect(() => {
if (loadState !== 'ready' || !dirty) return;
const onBeforeUnload = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = '';
};
window.addEventListener('beforeunload', onBeforeUnload);
return () => window.removeEventListener('beforeunload', onBeforeUnload);
}, [loadState, dirty]);
// --- Keyboard: Ctrl/Cmd+S to save (unless a dialog is open) ----------------------
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
if (confirmOpen) return;
e.preventDefault();
doSave();
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [title, content, tags, loadState, confirmOpen, saving, noteIdResolved, isPendingNote]);
// Clear any create partial-success notice when opening the editor.
useEffect(() => {
createNotice.value = null;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const markDirty = (fn: () => void) => {
setDirty(true);
fn();
};
const markSaved = () => {
clearDraft(draftKey);
setDirty(false);
navigate('/notes');
};
const doSave = async () => {
if (saving || loadState !== 'ready') return;
if (!title.trim() && !content.trim()) {
setValidation(true);
return;
}
setValidation(false);
setSaveError(null);
setSaving(true);
try {
const finalTitle = title.trim() || 'Untitled';
if (isCreate) {
// Offline → enqueue ONE folded create op.
if (typeof navigator !== 'undefined' && !navigator.onLine) {
const id = localId();
optimisticInsert({
id,
title: finalTitle,
content,
tags,
modified: new Date().toISOString(),
created: new Date().toISOString(),
});
enqueueCreate(id, { title: finalTitle, content, tags });
markSaved();
return;
}
try {
const { note, partialSuccess } = await saveCreateOnline(finalTitle, content, tags);
if (partialSuccess) {
createNotice.value = { noteId: note.id, noteTitle: finalTitle };
}
markSaved();
} catch (e) {
if (isOfflineError(e)) {
const id = localId();
optimisticInsert({
id,
title: finalTitle,
content,
tags,
modified: new Date().toISOString(),
created: new Date().toISOString(),
});
enqueueCreate(id, { title: finalTitle, content, tags });
markSaved();
return;
}
// create failed non-network → nothing created, editor stays
setSaveError(errMessage(e, 'Failed to save note'));
}
return;
}
// Edit mode
const id = noteIdResolved;
if (!id) {
setSaveError('Failed to save note');
return;
}
const base = findNote(id);
const optimistic: Note = {
id,
title: finalTitle,
content,
tags,
modified: new Date().toISOString(),
created: base?.created ?? new Date().toISOString(),
};
const enqueueEdit = () => enqueueUpdate(id, { title: finalTitle, content, tags });
if (typeof navigator !== 'undefined' && !navigator.onLine) {
optimisticUpdate(optimistic);
enqueueEdit();
markSaved();
return;
}
try {
await saveUpdateOnline({ id, title: finalTitle, content, tags });
markSaved();
} catch (e) {
if (isOfflineError(e)) {
optimisticUpdate(optimistic);
enqueueEdit();
markSaved();
return;
}
// non-network → saveUpdateOnline already reverted the optimistic patch
setSaveError(errMessage(e, 'Failed to save note'));
}
} finally {
setSaving(false);
}
};
const doDelete = async () => {
if (deleting || !noteIdResolved) return;
setDeleting(true);
setDeleteError(null);
try {
if (typeof navigator !== 'undefined' && !navigator.onLine) {
enqueueDelete(noteIdResolved);
optimisticDelete(noteIdResolved);
markSaved();
return;
}
try {
await deleteNoteOnline(noteIdResolved);
} catch (e) {
if (isOfflineError(e)) {
enqueueDelete(noteIdResolved);
optimisticDelete(noteIdResolved);
markSaved();
return;
}
throw e;
}
optimisticDelete(noteIdResolved);
markSaved();
} catch (e) {
// non-network delete error → dialog closed, note intact, banner
setDeleteError(errMessage(e, 'Failed to delete note'));
setConfirmOpen(false);
} finally {
setDeleting(false);
}
};
// --- Render states -----------------------------------------------------------------
if (loadState === 'resolving') {
return (
<div className="max-w-[720px] mx-auto px-4 py-6">
<LoadingRow text="Loading…" />
</div>
);
}
if (loadState === 'not-found') {
return (
<div className="max-w-[720px] mx-auto px-4 py-16 text-center">
<p style={{ fontSize: 20, fontWeight: 600, margin: '0 0 8px' }}>Note not found</p>
<p style={{ color: 'var(--dn-muted)', margin: '0 0 24px' }}>
This note may have been deleted.
</p>
<button type="button" className="btn btn-ghost" onClick={() => navigate('/notes')}>
Back to notes
</button>
</div>
);
}
if (loadState === 'offline') {
return (
<div className="max-w-[720px] mx-auto px-4 py-16 text-center">
<p style={{ fontSize: 17, fontWeight: 600, margin: '0 0 8px' }}>
You&apos;re offline — this note isn&apos;t in your saved copy.
</p>
<button type="button" className="btn btn-ghost" onClick={() => navigate('/notes')}>
Back to notes
</button>
</div>
);
}
if (loadState === 'load-error') {
return (
<div className="max-w-[720px] mx-auto px-4 py-6">
<div className="banner banner-error" role="alert">
<span>{loadError ?? 'Something went wrong'}</span>
<button
type="button"
className="banner-action"
onClick={() => {
const id = noteId ?? '';
if (isOfflineNote(id)) return;
const cached = findNote(id);
if (cached) {
setTitle(cached.title);
setContent(cached.content);
setTags(cached.tags ?? []);
setLoadState('ready');
} else {
setRetryTick((t) => t + 1);
}
}}
>
Retry
</button>
</div>
</div>
);
}
return (
<div className="max-w-[720px] mx-auto px-4 sm:px-6 py-6">
{/* visually hidden h1 */}
<h1 className="sr-only">{isCreate ? 'New note' : 'Edit note'}</h1>
{draftRestored && (
<div className="banner banner-info mb-4" role="status">
<span>Restored your unsaved changes.</span>
<button
type="button"
className="banner-action"
onClick={() => {
clearDraft(draftKey);
setDraftRestored(false);
}}
>
Discard
</button>
</div>
)}
{isPendingNote && (
<div className="banner banner-warn mb-4" role="status">
Pending — not yet synced.
</div>
)}
{saveError && (
<div className="banner banner-error mb-4" role="alert">
<span>{saveError}</span>
<button type="button" className="banner-action" onClick={doSave}>
Retry
</button>
</div>
)}
{deleteError && (
<div className="banner banner-error mb-4" role="alert">
<span>{deleteError}</span>
<button
type="button"
className="banner-action"
onClick={() => {
setDeleteError(null);
setConfirmOpen(true);
}}
>
Retry
</button>
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault();
doSave();
}}
className="flex flex-col gap-5"
>
<div>
<label className="sr-only" htmlFor="ed-title">
Title
</label>
<input
ref={titleRef}
id="ed-title"
className="w-full"
style={{
fontSize: 24,
lineHeight: 1.3,
fontWeight: 700,
color: 'var(--dn-ink)',
background: 'transparent',
border: 'none',
padding: '4px 0',
outline: 'none',
}}
type="text"
value={title}
placeholder="Title"
maxLength={200}
onInput={(e) => markDirty(() => setTitle((e.target as HTMLInputElement).value))}
/>
</div>
<div>
<label className="sr-only" htmlFor="ed-content">
Note content
</label>
<textarea
id="ed-content"
className="w-full"
style={{
minHeight: 280,
fontSize: 15,
lineHeight: 1.5,
color: 'var(--dn-ink)',
background: 'transparent',
border: 'none',
resize: 'vertical',
outline: 'none',
}}
value={content}
placeholder="Write something…"
onInput={(e) => markDirty(() => setContent((e.target as HTMLInputElement).value))}
/>
</div>
<div>
<TagInput tags={tags} onChange={(t) => markDirty(() => setTags(t))} />
</div>
{validation && !title.trim() && !content.trim() && (
<p className="text-[13px]" role="alert" style={{ color: 'var(--dn-bad)', margin: 0 }}>
Enter a title or some text first
</p>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between pt-2">
<button
type="submit"
className="btn btn-primary"
disabled={saving}
aria-busy={saving || undefined}
style={{ minWidth: 120 }}
>
{saving && <Spinner />}
{saving ? 'Saving…' : 'Save'}
</button>
{!isCreate && (
<button
type="button"
className="btn btn-danger"
onClick={() => {
setDeleteError(null);
setConfirmOpen(true);
}}
>
Delete note
</button>
)}
</div>
</form>
{confirmOpen && (
<ConfirmDialog
title="Delete note"
body={<>Delete “{title.trim() || 'Untitled'}”? This cannot be undone.</>}
confirmLabel="Delete"
busy={deleting}
busyLabel="Deleting…"
onConfirm={doDelete}
onCancel={() => setConfirmOpen(false)}
/>
)}
</div>
);
}
function errMessage(e: unknown, fallback: string): string {
return e instanceof ApiError && e.message ? e.message : fallback;
}
+266
View File
@@ -0,0 +1,266 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { apiFetch, ApiError, jsonBody, toApiError } from '../lib/api';
import { setAuthed } from '../lib/auth';
import { currentSearch, didClientNavigate, getQueryParam, navigate, sanitizeNext } from '../router';
import { Spinner } from '../components/Spinner';
import type { User } from '../types';
type Probe = 'checking' | 'connected' | 'unreachable';
interface FieldError {
email?: string;
password?: string;
username?: string;
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function Login() {
const [register, setRegister] = useState(() => getQueryParam(currentSearch.value, 'mode') === 'register');
const [email, setEmail] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldError>({});
const [serverError, setServerError] = useState<string | null>(null);
const [probe, setProbe] = useState<Probe>('checking');
const emailRef = useRef<HTMLInputElement>(null);
const next = sanitizeNext(getQueryParam(currentSearch.value, 'next'));
useEffect(() => {
probeServer();
// autofocus Email only on client-side navigation, never cold page load
if (didClientNavigate) emailRef.current?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const probeServer = async () => {
setProbe('checking');
try {
await apiFetch('/web/auth/me', { refreshOn401: false });
setProbe('connected');
} catch {
setProbe('unreachable');
}
};
const toggleMode = () => {
const newMode = !register;
setRegister(newMode);
setFieldErrors({});
setServerError(null);
window.history.replaceState(
{},
'',
newMode ? '/login?mode=register' : '/login'
);
};
const validate = (): boolean => {
const errs: FieldError = {};
if (!EMAIL_RE.test(email.trim())) errs.email = 'Enter a valid email address';
if (!password) errs.password = 'Password is required';
if (register && !username.trim()) errs.username = 'Username is required';
setFieldErrors(errs);
return Object.keys(errs).length === 0;
};
const submit = async (e: Event) => {
e.preventDefault();
setServerError(null);
if (submitting) return;
if (!validate()) return;
setSubmitting(true);
const body = register
? { username: username.trim(), password, email: email.trim() }
: { email: email.trim(), password };
try {
const res = await apiFetch(register ? '/web/auth/register' : '/web/auth/login', {
refreshOn401: false,
...jsonBody(body),
});
if (res.ok) {
const data = (await res.json()) as { user?: User };
if (data?.user) {
setAuthed(data.user);
navigate(next);
return;
}
throw new ApiError(0, 'Something went wrong');
}
const err = await toApiError(res);
throw err;
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Something went wrong';
setServerError(msg);
} finally {
setSubmitting(false);
}
};
const origin = typeof window !== 'undefined' ? window.location.origin : '';
return (
<div className="min-h-screen flex flex-col items-center justify-center p-6">
<a href="#login-main" className="skip-link">
Skip to content
</a>
<main id="login-main" className="w-full max-w-[400px] card p-6 sm:p-8" tabIndex={-1}>
<div className="flex flex-col items-center text-center">
<div className="logo-tile mb-4" aria-hidden="true">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none">
<path
d="M4 20h4l10.5-10.5a2.83 2.83 0 10-4-4L4 16v4z"
stroke="currentColor"
strokeWidth="2"
strokeLinejoin="round"
/>
<path d="M13.5 6.5l4 4" stroke="currentColor" strokeWidth="2" />
</svg>
</div>
<h1 className="text-[24px] font-bold leading-[1.25]" style={{ margin: 0 }}>
KeepNote
</h1>
<p className="mt-1 mb-6" style={{ color: 'var(--dn-muted)', fontSize: 15 }}>
Your notes on Dcntrlized
</p>
</div>
<form onSubmit={submit} noValidate>
<div className="mb-4">
<label className="field-label" htmlFor="email">
Email
</label>
<input
ref={emailRef}
id="email"
className="field"
type="email"
value={email}
placeholder="[email protected]"
autoComplete="email"
inputMode="email"
autoCapitalize="none"
spellcheck={false}
aria-invalid={!!fieldErrors.email}
aria-describedby={fieldErrors.email ? 'email-error' : undefined}
onInput={(e) => setEmail((e.target as HTMLInputElement).value)}
/>
{fieldErrors.email && (
<p id="email-error" className="mt-1 text-[13px]" style={{ color: 'var(--dn-bad)' }} role="alert">
{fieldErrors.email}
</p>
)}
</div>
{register && (
<div className="mb-4">
<label className="field-label" htmlFor="username">
Username
</label>
<input
id="username"
className="field"
type="text"
value={username}
placeholder="johndoe"
autoComplete="username"
autoCapitalize="none"
spellcheck={false}
aria-invalid={!!fieldErrors.username}
aria-describedby={fieldErrors.username ? 'username-error' : undefined}
onInput={(e) => setUsername((e.target as HTMLInputElement).value)}
/>
{fieldErrors.username && (
<p id="username-error" className="mt-1 text-[13px]" style={{ color: 'var(--dn-bad)' }} role="alert">
{fieldErrors.username}
</p>
)}
</div>
)}
<div className="mb-5">
<label className="field-label" htmlFor="password">
Password
</label>
<input
id="password"
className="field"
type="password"
value={password}
placeholder="••••••••"
autoComplete={register ? 'new-password' : 'current-password'}
aria-invalid={!!fieldErrors.password}
aria-describedby={fieldErrors.password ? 'password-error' : undefined}
onInput={(e) => setPassword((e.target as HTMLInputElement).value)}
/>
{fieldErrors.password && (
<p id="password-error" className="mt-1 text-[13px]" style={{ color: 'var(--dn-bad)' }} role="alert">
{fieldErrors.password}
</p>
)}
</div>
{serverError && (
<div className="mb-4 banner banner-error" role="alert">
{serverError}
</div>
)}
<button type="submit" className="btn btn-primary w-full" disabled={submitting}>
{submitting && <Spinner />}
{register ? 'Create account' : 'Sign in'}
</button>
</form>
<button
type="button"
className="mt-3 w-full"
aria-pressed={register}
style={{ minHeight: 44, color: 'var(--dn-accent)', fontWeight: 600, background: 'transparent', border: 'none', cursor: 'pointer' }}
onClick={toggleMode}
>
{register ? 'Already have an account? Sign in' : 'New here? Create an account'}
</button>
</main>
<footer
className="w-full max-w-[400px] mt-6 flex items-center gap-2"
role="status"
style={{ color: 'var(--dn-muted)', fontSize: 12, minHeight: 44 }}
>
<span
aria-hidden="true"
style={{
width: 8,
height: 8,
borderRadius: '50%',
background:
probe === 'connected' ? 'var(--dn-ok)' : probe === 'unreachable' ? 'var(--dn-bad)' : 'var(--dn-muted)',
flex: 'none',
}}
/>
{probe === 'checking' && <span>Checking connection…</span>}
{probe === 'connected' && (
<span className="mono truncate" title={origin}>
Connected to {origin}
</span>
)}
{probe === 'unreachable' && (
<>
<span>Server unreachable</span>
<button
type="button"
className="banner-action"
style={{ marginLeft: 'auto', minHeight: 44 }}
onClick={probeServer}
>
Retry
</button>
</>
)}
</footer>
</div>
);
}
+282
View File
@@ -0,0 +1,282 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { Link, navigateReplace, getQueryParam, currentSearch } from '../router';
import { useNow } from '../lib/hooks';
import { relativeTime, snippet } from '../lib/relativeTime';
import { LoadingRow } from '../components/Spinner';
import {
notes,
notesLoading,
notesError,
offline,
refreshNotes,
pendingCount,
isOfflineNote,
syncNotice,
createNotice,
} from '../state/store';
import type { Note } from '../types';
export function NotesList() {
const now = useNow(60000);
const searchInputRef = useRef<HTMLInputElement>(null);
const h1Ref = useRef<HTMLHeadingElement>(null);
const [q, setQ] = useState(() => getQueryParam(currentSearch.value, 'q') ?? '');
const list = notes.value;
const loading = notesLoading.value;
const error = notesError.value;
const isOffline = offline.value;
const pending = pendingCount();
const notice = syncNotice.value;
// Re-render when the create partial-success notice changes.
const [cNotice, setCNotice] = useState(() => createNotice.value);
useEffect(() => {
const sub = createNotice.subscribe((v) => setCNotice(v));
return () => sub();
}, []);
// focus the visible h1 on route change
useEffect(() => {
h1Ref.current?.focus();
}, []);
// load notes on mount (if signed in) + refresh on focus
useEffect(() => {
refreshNotes();
const onFocus = () => {
if (!document.hidden) refreshNotes();
};
window.addEventListener('focus', onFocus);
return () => window.removeEventListener('focus', onFocus);
}, []);
// debounced ?q= replaceState sync (300ms)
useEffect(() => {
const id = window.setTimeout(() => {
if (q) navigateReplace(`/notes?q=${encodeURIComponent(q)}`);
else navigateReplace('/notes');
}, 300);
return () => window.clearTimeout(id);
}, [q]);
// "/" focuses search unless typing
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
const typing =
target &&
(target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable);
if (e.key === '/' && !typing) {
e.preventDefault();
searchInputRef.current?.focus();
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, []);
const filtered = useMemo(() => {
const query = q.trim().toLowerCase();
if (!query) return list;
return list.filter((n) => {
if ((n.title || '').toLowerCase().includes(query)) return true;
if ((n.content || '').toLowerCase().includes(query)) return true;
if (n.tags.some((t) => t.toLowerCase().includes(query))) return true;
return false;
});
}, [list, q]);
const noNotes = list.length === 0;
const noMatches = !noNotes && filtered.length === 0;
const clearSearch = () => {
setQ('');
searchInputRef.current?.focus();
navigateReplace('/notes');
};
return (
<div className="max-w-[720px] mx-auto px-4 sm:px-6 py-6">
<h1 ref={h1Ref} tabIndex={-1} className="text-[20px] leading-[1.4] font-bold mb-5" style={{ margin: 0, marginBottom: 20, outline: 'none' }}>
KeepNote
</h1>
{/* toolbar */}
<div className="flex items-center gap-2 mb-5">
<input
ref={searchInputRef}
className="field flex-1"
type="search"
value={q}
placeholder="Search notes"
aria-label="Search notes"
onInput={(e) => setQ((e.target as HTMLInputElement).value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setQ('');
(e.target as HTMLInputElement).blur();
navigateReplace('/notes');
}
}}
/>
<button
type="button"
className="icon-btn"
aria-label={loading ? 'Refreshing…' : 'Refresh notes'}
aria-busy={loading || undefined}
disabled={loading}
onClick={() => refreshNotes()}
>
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
style={loading ? { animation: 'spin 0.8s linear infinite' } : undefined}
>
<path
d="M21 12a9 9 0 11-2.64-6.36M21 3v6h-6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
{/* aria-live status region */}
<div className="sr-only" role="status" aria-live="polite">
{loading && noNotes ? 'Loading…' : noMatches ? 'No matches' : `${filtered.length} notes`}
</div>
{notice && (
<div className="banner banner-warn mb-4" role="status">
{notice}
</div>
)}
{cNotice && (
<div className="banner banner-warn mb-4" role="status">
<span>
Note saved — tags weren&apos;t added.
</span>
<Link href={`/notes/${cNotice.noteId}`} className="banner-action">
Edit note
</Link>
</div>
)}
{loading && noNotes && <LoadingRow />}
{!loading && noNotes && (
<div className="card p-8 text-center" style={{ padding: '48px 24px' }}>
<p className="font-semibold" style={{ fontSize: 17, margin: '0 0 4px' }}>
No notes yet
</p>
<p style={{ color: 'var(--dn-muted)', margin: '0 0 20px' }}>Create your first note</p>
<Link href="/notes/new" className="btn btn-primary">
New note
</Link>
</div>
)}
{!loading && noMatches && (
<div className="card p-8 text-center" style={{ padding: '48px 24px' }}>
<p className="font-semibold" style={{ fontSize: 17, margin: '0 0 4px' }}>
No matches
</p>
<p style={{ color: 'var(--dn-muted)', margin: '0 0 20px' }}>Try a different search</p>
<button type="button" className="btn btn-ghost" onClick={clearSearch}>
Clear search
</button>
</div>
)}
{error && (
<div className="banner banner-error mb-4" role="alert">
<span>{error}</span>
<button type="button" className="banner-action" onClick={() => refreshNotes()}>
Retry
</button>
</div>
)}
{isOffline && !error && (
<div className="banner banner-warn mb-4" role="status">
<span>
Offline — showing saved notes. Changes will sync when you reconnect.
{pending > 0 && (
<>
{' '}
<span className="dn-warn-chip" style={{ padding: '2px 10px', borderRadius: 999, display: 'inline-block' }}>
{pending} pending change{pending === 1 ? '' : 's'}
</span>
</>
)}
</span>
</div>
)}
{!loading && filtered.length > 0 && (
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-1 list-none p-0 m-0" style={{ gap: 16 }}>
{filtered.map((note) => (
<li key={note.id}>
<NoteCard note={note} now={now} />
</li>
))}
</ul>
)}
<Link href="/notes/new" className="fab" aria-label="New note" title="New note">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</Link>
</div>
);
}
function NoteCard({ note, now }: { note: Note; now: number }) {
const pending = isOfflineNote(note.id);
const title = note.title.trim() || 'Untitled';
const snip = snippet(note.content);
const time = relativeTime(note.modified || note.created, now);
return (
<Link href={`/notes/${note.id}`} className="note-card h-full flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-semibold truncate" style={{ fontSize: 15, color: 'var(--dn-ink)' }}>
{title}
</span>
{pending && (
<span className="dn-warn-chip" style={{ padding: '1px 8px', borderRadius: 999, fontSize: 12, flex: 'none' }}>
Pending
</span>
)}
</div>
{snip && (
<span className="line-clamp-2" style={{ color: 'var(--dn-ink-soft)', fontSize: 13, lineHeight: 1.45 }}>
{snip}
</span>
)}
<span className="mt-auto" style={{ color: 'var(--dn-muted)', fontSize: 12 }}>
<time dateTime={note.modified || note.created}>{time}</time>
</span>
{note.tags.length > 0 && (
<span className="flex flex-wrap gap-1.5 mt-1">
{note.tags.map((tag) => (
<span key={tag} className="tag-chip">
{tag}
</span>
))}
</span>
)}
</Link>
);
}
+188
View File
@@ -0,0 +1,188 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { apiFetch, profile } from '../lib/api';
import { logout } from '../lib/auth';
import { theme, toggleTheme } from '../lib/theme';
import { pendingCount, lastSynced, syncing, syncNow, syncNotice } from '../state/store';
type Probe = 'checking' | 'connected' | 'unreachable';
export function Settings() {
const [probe, setProbe] = useState<Probe>('checking');
const [localPending, setLocalPending] = useState(0);
const [localSyncing, setLocalSyncing] = useState(false);
const [lastSync, setLastSync] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const h1Ref = useRef<HTMLHeadingElement>(null);
const username = profile.value?.username || '—';
const probeServer = async () => {
setProbe('checking');
try {
await apiFetch('/web/auth/me', { refreshOn401: false });
setProbe('connected');
} catch {
setProbe('unreachable');
}
};
useEffect(() => {
probeServer();
setLocalPending(pendingCount());
setLocalSyncing(syncing.value);
setLastSync(lastSynced.value);
setNotice(syncNotice.value);
h1Ref.current?.focus();
const subs = [
syncing.subscribe((v) => setLocalSyncing(v)),
lastSynced.subscribe((v) => setLastSync(v)),
syncNotice.subscribe((v) => setNotice(v)),
];
const onStorage = () => setLocalPending(pendingCount());
window.addEventListener('storage', onStorage);
return () => {
subs.forEach((s) => s());
window.removeEventListener('storage', onStorage);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const doSyncNow = async () => {
setNotice(null);
const result = await syncNow();
setLocalPending(pendingCount());
setLastSync(lastSynced.value);
setNotice(syncNotice.value);
};
const doLogout = () => {
logout();
};
const origin = typeof window !== 'undefined' ? window.location.origin : '';
const lastSyncText = lastSync ? new Date(lastSync).toLocaleString() : 'Never';
return (
<div className="max-w-[640px] mx-auto px-4 sm:px-6 py-6">
<h1 ref={h1Ref} tabIndex={-1} className="text-[20px] font-bold mb-5" style={{ outline: 'none' }}>
Settings
</h1>
{notice && (
<div className="banner banner-warn mb-4" role="status">
<span>{notice}</span>
<button type="button" className="banner-action" onClick={() => setNotice(null)}>
Dismiss
</button>
</div>
)}
<section className="card p-5 mb-4" aria-labelledby="s-conn">
<h2 id="s-conn" className="text-[13px] uppercase font-semibold mb-3" style={{ color: 'var(--dn-muted)', letterSpacing: '0.04em' }}>
Connection
</h2>
<div className="flex items-center gap-2" style={{ minHeight: 44 }}>
<span
aria-hidden="true"
style={{
width: 8,
height: 8,
borderRadius: '50%',
background:
probe === 'connected'
? 'var(--dn-ok)'
: probe === 'unreachable'
? 'var(--dn-bad)'
: 'var(--dn-muted)',
flex: 'none',
}}
/>
{probe === 'checking' && <span>Checking connection…</span>}
{probe === 'connected' && (
<span className="mono truncate" title={origin}>
Connected to {origin}
</span>
)}
{probe === 'unreachable' && (
<>
<span>Server unreachable</span>
<button type="button" className="banner-action" style={{ minHeight: 44 }} onClick={probeServer}>
Retry
</button>
</>
)}
</div>
</section>
<section className="card p-5 mb-4" aria-labelledby="s-appearance">
<h2 id="s-appearance" className="text-[13px] uppercase font-semibold mb-3" style={{ color: 'var(--dn-muted)', letterSpacing: '0.04em' }}>
Appearance
</h2>
<div className="flex items-center justify-between" style={{ minHeight: 44 }}>
<span className="font-medium">Dark mode</span>
<button
type="button"
role="switch"
aria-checked={theme.value === 'dark'}
className="switch"
onClick={toggleTheme}
>
<span className="knob" />
</button>
</div>
</section>
<section className="card p-5 mb-4" aria-labelledby="s-account">
<h2 id="s-account" className="text-[13px] uppercase font-semibold mb-3" style={{ color: 'var(--dn-muted)', letterSpacing: '0.04em' }}>
Account
</h2>
<div className="flex items-center justify-between gap-4" style={{ minHeight: 44 }}>
<div>
<div className="text-[13px]" style={{ color: 'var(--dn-muted)' }}>
Signed in as
</div>
<div className="font-semibold truncate" style={{ maxWidth: 220 }}>
{username}
</div>
</div>
<button type="button" className="btn btn-danger" onClick={doLogout}>
Log out
</button>
</div>
</section>
<section className="card p-5 mb-4" aria-labelledby="s-sync">
<h2 id="s-sync" className="text-[13px] uppercase font-semibold mb-3" style={{ color: 'var(--dn-muted)', letterSpacing: '0.04em' }}>
Sync
</h2>
<div className="flex items-center justify-between gap-4" style={{ minHeight: 44 }}>
<div>
<div className="text-[13px]" style={{ color: 'var(--dn-muted)' }}>
Pending changes
</div>
<div aria-live="polite" className="font-semibold">
{localPending}
</div>
<div className="text-[13px] mt-2" style={{ color: 'var(--dn-muted)' }}>
Last synced
</div>
<div>{lastSyncText}</div>
</div>
<button
type="button"
className="btn btn-ghost"
disabled={localSyncing}
onClick={doSyncNow}
>
{localSyncing ? 'Syncing…' : 'Sync now'}
</button>
</div>
</section>
<p className="text-center" style={{ color: 'var(--dn-muted)', fontSize: 13, marginTop: 24 }}>
KeepNote for Dcntrlized
</p>
</div>
);
}
+435
View File
@@ -0,0 +1,435 @@
// Global notes state + outbox (UX §4.2-§4.4).
// Notes cache + pending ops persist in localStorage; every commit re-reads
// storage before writing (multi-tab safe), and a storage listener +
// BroadcastChannel("keepnote.sync") rehydrates foreign changes. Flush is
// cross-tab single-flight via the Web Locks API.
import { signal } from '@preact/signals';
import type { Note, PendingOp } from '../types';
import {
apiFetch,
apiRequest,
ApiError,
isOfflineError,
jsonBody,
toApiError,
} from '../lib/api';
import { KEYS, readJSON, writeJSON, writeString, readString } from '../lib/storage';
import { uid } from '../lib/relativeTime';
export const notes = signal<Note[]>([]);
export const notesLoaded = signal(false);
export const notesLoading = signal(false);
export const notesError = signal<string | null>(null);
export const offline = signal(false);
export const pendingOps = signal<PendingOp[]>([]);
export const lastSynced = signal<string | null>(null);
export const syncing = signal(false);
// Transient sync outcome message surfaced on /settings.
export const syncNotice = signal<string | null>(null);
// Partial-success notice from a create whose chained tag-update failed.
export const createNotice = signal<{ noteId: string; noteTitle: string } | null>(null);
const BROADCAST_CHANNEL = 'keepnote.sync';
export function loadCache(): void {
notes.value = readJSON<Note[]>(KEYS.cache, []);
pendingOps.value = readJSON<PendingOp[]>(KEYS.pending, []);
lastSynced.value = readString(KEYS.lastSynced, '') || null;
notesLoaded.value = true;
}
export function pendingCount(): number {
return pendingOps.value.length;
}
export function isOfflineNote(id: string): boolean {
return id.startsWith('local-');
}
export function findNote(id: string): Note | undefined {
return notes.value.find((n) => n.id === id);
}
// --- low-level commit helpers (re-read + merge for cross-tab) --------------------
export function commitNotes(list: Note[]): void {
notes.value = list;
writeJSON(KEYS.cache, list);
broadcast({ type: 'cache' });
}
export function commitPending(list: PendingOp[]): void {
pendingOps.value = list;
writeJSON(KEYS.pending, list);
broadcast({ type: 'pending', count: list.length });
}
// --- optimistic helpers -------------------------------------------------------------
export function optimisticInsert(note: Note): void {
const list = readJSON<Note[]>(KEYS.cache, []);
const idx = list.findIndex((n) => n.id === note.id);
if (idx >= 0) list[idx] = note;
else list.unshift(note);
commitNotes(list);
}
export function optimisticUpdate(note: Note): void {
const list = readJSON<Note[]>(KEYS.cache, []);
const idx = list.findIndex((n) => n.id === note.id);
if (idx >= 0) list[idx] = note;
else list.unshift(note);
commitNotes(list);
}
export function optimisticDelete(id: string): void {
const list = readJSON<Note[]>(KEYS.cache, []).filter((n) => n.id !== id);
commitNotes(list);
}
// --- outbox enqueue (UX §4.3) ----------------------------------------------------------
export function enqueueCreate(localId: string, payload: { title: string; content: string; tags?: string[] }): void {
const ops = readJSON<PendingOp[]>(KEYS.pending, []);
ops.push({ id: uid(), type: 'create', localId, payload });
commitPending(ops);
}
export function enqueueUpdate(noteId: string, payload: { title: string; content: string; tags: string[] }): void {
const ops = readJSON<PendingOp[]>(KEYS.pending, []);
const createIdx = ops.findIndex((o) => o.type === 'create' && o.localId === noteId);
if (createIdx >= 0) {
const op = ops[createIdx];
if (op.type === 'create') {
ops[createIdx] = {
...op,
payload: { title: payload.title, content: payload.content, tags: payload.tags },
};
commitPending(ops);
return;
}
}
const filtered = ops.filter((o) => !(o.type === 'update' && o.noteId === noteId));
filtered.push({ id: uid(), type: 'update', noteId, payload: { id: noteId, ...payload } });
commitPending(filtered);
}
export function enqueueDelete(noteId: string): void {
const ops = readJSON<PendingOp[]>(KEYS.pending, []);
const createIdx = ops.findIndex((o) => o.type === 'create' && o.localId === noteId);
if (createIdx >= 0) {
// deleting a pending create removes the queued op
ops.splice(createIdx, 1);
commitPending(ops);
return;
}
const filtered = ops.filter((o) => !(o.type === 'update' && o.noteId === noteId));
filtered.push({ id: uid(), type: 'delete', noteId });
commitPending(filtered);
}
// --- online save / delete ----------------------------------------------------------------
export async function saveCreateOnline(
title: string,
content: string,
tags: string[]
): Promise<{ note: Note; partialSuccess: boolean }> {
const note = await apiRequest<Note>('/web/action/writeNote', jsonBody({ payload: { title, content } }));
note.tags = note.tags ?? [];
if (tags.length > 0) {
try {
const updated = await apiRequest<Note>(
'/web/action/updateNote',
jsonBody({ payload: { id: note.id, title, content, tags } }, 'PUT')
);
updated.tags = updated.tags ?? [];
optimisticInsert(updated);
return { note: updated, partialSuccess: false };
} catch (e) {
// chained update failed after a successful create
if (isOfflineError(e)) {
// network: keep the real note, enqueue an update so tags sync later
optimisticInsert(note);
enqueueUpdate(note.id, { title, content, tags });
return { note, partialSuccess: true };
}
// non-network: note WAS created -> partial success, never duplicate
optimisticInsert(note);
return { note, partialSuccess: true };
}
}
optimisticInsert(note);
return { note, partialSuccess: false };
}
export async function saveUpdateOnline(payload: {
id: string;
title: string;
content: string;
tags: string[];
}): Promise<Note> {
// optimistic patch first; reverted by caller on server error
optimisticUpdate({
id: payload.id,
title: payload.title,
content: payload.content,
tags: payload.tags,
modified: new Date().toISOString(),
created: findNote(payload.id)?.created ?? new Date().toISOString(),
});
try {
const updated = await apiRequest<Note>(
'/web/action/updateNote',
jsonBody({ payload }, 'PUT')
);
updated.tags = updated.tags ?? [];
optimisticUpdate(updated);
return updated;
} catch (e) {
// revert optimistic patch on non-network error
if (!isOfflineError(e)) {
const cached = readJSON<Note[]>(KEYS.cache, []);
const original = cached.find((n) => n.id === payload.id);
if (original) optimisticUpdate(original);
}
throw e;
}
}
export async function deleteNoteOnline(id: string): Promise<void> {
const res = await apiFetch('/web/action/deleteNote', jsonBody({ payload: { id } }, 'DELETE'));
if (!res.ok) {
const err = await toApiError(res);
// 404 = already deleted -> treat as success
if (err.status === 404) return;
throw err;
}
}
// --- refresh list --------------------------------------------------------------------------
export async function refreshNotes(): Promise<void> {
notesLoading.value = true;
notesError.value = null;
try {
const res = await apiFetch('/web/action/getNotes');
if (res.status === 200) {
const list = (await res.json()) as Note[];
for (const n of list) n.tags = n.tags ?? [];
notes.value = list;
writeJSON(KEYS.cache, list);
offline.value = false;
} else {
throw await toApiError(res);
}
} catch (e) {
if (isOfflineError(e)) {
offline.value = true;
// keep the cached list
} else {
notesError.value = e instanceof Error ? e.message : 'Something went wrong';
}
} finally {
notesLoading.value = false;
}
}
// --- flush (cross-tab single-flight via Web Locks) --------------------------------------
async function flushOp(op: PendingOp): Promise<void> {
if (op.type === 'create') {
const created = await apiRequest<Note>(
'/web/action/writeNote',
jsonBody({ payload: { title: op.payload.title, content: op.payload.content } })
);
created.tags = created.tags ?? [];
// replace the local- entry with the server note (id swap happens here, once)
const list = readJSON<Note[]>(KEYS.cache, []);
const idx = list.findIndex((n) => n.id === op.localId);
const merged: Note = {
...created,
title: op.payload.title,
content: op.payload.content,
tags: op.payload.tags ?? [],
};
if (idx >= 0) list[idx] = merged;
else list.unshift(merged);
commitNotes(list);
if (op.payload.tags && op.payload.tags.length > 0) {
try {
await apiRequest<Note>(
'/web/action/updateNote',
jsonBody(
{
payload: {
id: created.id,
title: op.payload.title,
content: op.payload.content,
tags: op.payload.tags,
},
},
'PUT'
)
);
} catch (e) {
if (isOfflineError(e)) throw e; // network -> pause flush
syncNotice.value = 'Note synced without tags';
}
}
return;
}
if (op.type === 'update') {
const updated = await apiRequest<Note>(
'/web/action/updateNote',
jsonBody({ payload: op.payload }, 'PUT')
);
updated.tags = updated.tags ?? [];
optimisticUpdate({ ...updated, title: op.payload.title, content: op.payload.content, tags: op.payload.tags });
return;
}
// delete
const res = await apiFetch('/web/action/deleteNote', jsonBody({ payload: { id: op.noteId } }, 'DELETE'));
if (!res.ok) {
const err = await toApiError(res);
if (err.offline) throw err;
if (err.status === 404) return; // already gone -> success
throw err;
}
}
type FlushResult = 'success' | 'offline' | 'partial';
async function doFlush(): Promise<FlushResult> {
let ops = readJSON<PendingOp[]>(KEYS.pending, []);
let dropped = 0;
while (ops.length > 0) {
const op = ops[0];
try {
await flushOp(op);
} catch (e) {
if (isOfflineError(e)) {
return 'offline'; // pause flush, keep the queue
}
// non-network rejection -> drop the op
ops = ops.slice(1);
commitPending(ops);
dropped++;
continue;
}
ops = ops.slice(1);
commitPending(ops);
}
if (dropped > 0) {
syncNotice.value = `${dropped} change(s) couldn't be synced and were discarded`;
return 'partial';
}
return 'success';
}
export async function flushOutbox(): Promise<FlushResult> {
if (syncing.value) return 'success';
syncing.value = true;
try {
const locks = navigator.locks;
if (locks && typeof locks.request === 'function') {
return await new Promise<FlushResult>((resolve) => {
locks
.request('keepnote.flush', { ifAvailable: true }, async (signal) => {
// signal is null when the lock could not be granted (another tab is
// flushing); lock.aborted means it was released early. In both cases
// skip — another tab owns the flush.
const lock = signal as { aborted?: boolean } | null;
if (!lock || lock.aborted) {
resolve('success');
return;
}
resolve(await doFlush());
})
.catch(() => resolve('success'));
});
}
// fallback: localStorage lock key with expiry
const lockKey = 'keepnote.flush.lock';
const held = readString(lockKey, '');
if (held && Date.now() - Number(held) < 15000) return 'success';
writeString(lockKey, String(Date.now()));
try {
return await doFlush();
} finally {
try {
localStorage.removeItem(lockKey);
} catch {
/* ignore */
}
}
} finally {
syncing.value = false;
}
}
// "Sync now" on /settings: flush then refresh; lastSynced advances only on a
// network-clean sync (C15).
export async function syncNow(): Promise<FlushResult> {
syncing.value = true;
try {
const flushResult = await flushOutbox();
let refreshNetworkOk = true;
try {
await refreshNotes();
} catch {
refreshNetworkOk = false;
}
const stillOffline = !navigator.onLine;
if (flushResult === 'offline' || !refreshNetworkOk || stillOffline) {
syncNotice.value =
"Couldn't sync — you're offline. Your changes are still saved on this device.";
return 'offline';
}
lastSynced.value = new Date().toISOString();
writeString(KEYS.lastSynced, lastSynced.value);
return flushResult;
} finally {
syncing.value = false;
}
}
// --- cross-tab wiring ---------------------------------------------------------------------
function broadcast(msg: { type: 'cache' } | { type: 'pending'; count: number }): void {
try {
if (typeof BroadcastChannel !== 'undefined') {
new BroadcastChannel(BROADCAST_CHANNEL).postMessage(msg);
}
} catch {
/* ignore */
}
}
export function initSyncChannel(): void {
window.addEventListener('storage', (e) => {
if (e.key === KEYS.pending) pendingOps.value = readJSON<PendingOp[]>(KEYS.pending, []);
else if (e.key === KEYS.cache) notes.value = readJSON<Note[]>(KEYS.cache, []);
else if (e.key === KEYS.lastSynced) {
lastSynced.value = readString(KEYS.lastSynced, '') || null;
}
});
if (typeof BroadcastChannel !== 'undefined') {
const bc = new BroadcastChannel(BROADCAST_CHANNEL);
bc.addEventListener('message', (ev) => {
const msg = ev.data as { type?: string };
if (msg?.type === 'pending') pendingOps.value = readJSON<PendingOp[]>(KEYS.pending, []);
else if (msg?.type === 'cache') notes.value = readJSON<Note[]>(KEYS.cache, []);
});
}
}
export function initOnlineListeners(): void {
const onOnline = () => {
offline.value = false;
if (pendingCount() > 0) {
flushOutbox().then(() => refreshNotes());
} else {
refreshNotes();
}
};
const onOffline = () => {
offline.value = true;
};
window.addEventListener('online', onOnline);
window.addEventListener('offline', onOffline);
}
+43
View File
@@ -0,0 +1,43 @@
export interface User {
id: string;
username: string;
email: string;
}
export interface Note {
id: string;
title: string;
content: string;
tags: string[];
modified: string;
created: string;
}
export type PendingOp =
| {
id: string;
type: 'create';
localId: string;
payload: { title: string; content: string; tags?: string[] };
}
| {
id: string;
type: 'update';
noteId: string;
payload: { id: string; title: string; content: string; tags: string[] };
}
| { id: string; type: 'delete'; noteId: string };
export type AuthState = 'loading' | 'authed' | 'anonymous' | 'unknown';
export interface Profile {
username?: string;
email?: string;
}
export interface Draft {
title: string;
content: string;
tags: string[];
savedAt: number;
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"jsxImportSource": "preact",
"strict": true,
"noFallthroughCasesInSwitch": true,
"types": ["preact"]
},
"include": ["src", "vite.config.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';
import tailwindcss from '@tailwindcss/vite';
// Default base "/" emits relative-free absolute /assets/... URLs (CSP-safe,
// external module script). dist/ is the embed target for the KeepNote edge.
export default defineConfig({
plugins: [preact(), tailwindcss()],
build: {
outDir: 'dist',
assetsDir: 'assets',
target: 'es2020',
sourcemap: false,
},
});