diff options
| -rw-r--r-- | .thicket/tickets.jsonl | 6 | ||||
| -rw-r--r-- | README.md | 8 | ||||
| -rw-r--r-- | frontend-vanilla/.gitignore | 1 | ||||
| -rw-r--r-- | frontend-vanilla/package-lock.json | 214 | ||||
| -rw-r--r-- | frontend-vanilla/package.json | 3 | ||||
| -rw-r--r-- | frontend-vanilla/src/api.test.ts | 45 | ||||
| -rw-r--r-- | frontend-vanilla/src/components/FeedItem.test.ts | 38 | ||||
| -rw-r--r-- | frontend-vanilla/src/components/FeedItem.ts | 38 | ||||
| -rw-r--r-- | frontend-vanilla/src/main.test.ts | 249 | ||||
| -rw-r--r-- | frontend-vanilla/src/main.ts | 461 | ||||
| -rw-r--r-- | frontend-vanilla/src/router.test.ts | 56 | ||||
| -rw-r--r-- | frontend-vanilla/src/store.test.ts | 39 | ||||
| -rw-r--r-- | frontend-vanilla/src/style.css | 382 | ||||
| -rw-r--r-- | web/dist/v3/assets/index-CPnxXrEk.css | 1 | ||||
| -rw-r--r-- | web/dist/v3/assets/index-DWEqmxLr.css | 1 | ||||
| -rw-r--r-- | web/dist/v3/assets/index-FNdWoCuA.js | 102 | ||||
| -rw-r--r-- | web/dist/v3/assets/index-eg2Tn_DA.js | 102 | ||||
| -rw-r--r-- | web/dist/v3/index.html | 4 |
18 files changed, 1216 insertions, 534 deletions
diff --git a/.thicket/tickets.jsonl b/.thicket/tickets.jsonl index 531062d..e1aa1d7 100644 --- a/.thicket/tickets.jsonl +++ b/.thicket/tickets.jsonl @@ -49,6 +49,7 @@ {"id":"NK-ca9t70","title":"Vanilla JS: Add Feed UI","description":"Add UI to add a new feed by URL in vanilla JS prototype.","type":"feature","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-14T04:47:41.764330544Z","updated":"2026-02-14T04:47:41.764330544Z"} {"id":"NK-chns2b","title":"reach parity between vanilla js and react v2 ui","description":"Continue implementing the vanilla js one with minimal overhad/depdnencies to be fast and lean. Make sure there are tests and rely on the v2 ui and legacy version as references.","type":"epic","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-14T04:45:06.813453353Z","updated":"2026-02-14T04:45:06.813453353Z"} {"id":"NK-ck4co9","title":"Refactor E2E tests to use page objects","description":"The E2E tests are getting complex. Refactor them to use the Page Object Model pattern for better maintainability.","type":"task","status":"open","priority":4,"labels":null,"assignee":"","created":"2026-02-15T02:21:34.96843041Z","updated":"2026-02-15T19:14:31.660189629Z"} +{"id":"NK-cv567g","title":"Vanilla JS (v3): Remove inline JS and fix CSP errors","description":"","type":"bug","status":"open","priority":1,"labels":null,"assignee":"","created":"2026-02-16T02:42:53.916053881Z","updated":"2026-02-16T02:42:53.916053881Z"} {"id":"NK-d2be57","title":"Persist sidebar state across reloads","description":"Currently sidebar state resets on reload. It should persist in localStorage like the theme.","type":"feature","status":"open","priority":3,"labels":null,"assignee":"","created":"2026-02-15T22:23:06.847360465Z","updated":"2026-02-15T22:23:06.847360465Z"} {"id":"NK-d4c8jv","title":"Vanilla JS Parity: Read/Star/Filter","description":"Implement read/unread toggle, star/unstar, and special filters (All, Unread, Starred) in vanilla JS prototype.","type":"feature","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-14T04:46:32.113504545Z","updated":"2026-02-14T04:47:46.412290355Z"} {"id":"NK-dbcl6t","title":"Create Python Compliance Suite","description":"","type":"task","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-15T00:21:53.997204693Z","updated":"2026-02-15T00:44:41.52830766Z"} @@ -76,7 +77,7 @@ {"id":"NK-hidz4w","title":"Add Local Git Hooks","description":"Create a script/make target to install a pre-push hook that runs 'make check'. This enforces quality gates locally, keeping the CI clean.","type":"task","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-15T16:23:36.906173277Z","updated":"2026-02-15T17:25:28.129358055Z"} {"id":"NK-hj6f9p","title":"Investigate E2E Tag View flakiness","description":"The E2E test for /v2/tag/Tech was temporarily disabled because it was timing out/failing. Investigate if it's a race condition or a routing issue.","type":"bug","status":"open","priority":4,"labels":null,"assignee":"","created":"2026-02-15T01:04:54.404114014Z","updated":"2026-02-15T19:14:17.974207248Z"} {"id":"NK-hspao2","title":"Vanilla JS: Implement Test Infrastructure","description":"Setup testing infrastructure for vanilla JS prototype to ensure 80% coverage. Refactor app.js for testability and add unit tests.","type":"task","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-14T05:13:11.587767054Z","updated":"2026-02-14T05:13:11.587767054Z"} -{"id":"NK-htk1zc","title":"Vanilla JS (v3): Settings, Search, and Keyboard Shortcuts","description":"","type":"feature","status":"open","priority":2,"labels":null,"assignee":"","created":"2026-02-16T02:02:01.850554958Z","updated":"2026-02-16T02:02:01.850554958Z"} +{"id":"NK-htk1zc","title":"Vanilla JS (v3): Settings, Search, and Keyboard Shortcuts","description":"","type":"feature","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-16T02:02:01.850554958Z","updated":"2026-02-16T02:05:38.831401937Z"} {"id":"NK-hy162w","title":"URLs in UI are api/feed output, not loadable HTML","description":"After clicking in the sidebar, you get to a URL like http://localhost:9001/feed/38?filter=all but if you hit \"reload\" in the browser that retuns a blob of JSON to the browser! Oops. Maybe just don't change the user visible URL at all. If we do change the URLs, maybe just use #/feed/38/filter=all or something similar that is just client side for the JS.","type":"bug","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-15T22:00:10.335296177Z","updated":"2026-02-15T22:22:41.252479327Z"} {"id":"NK-hyej38","title":"[ui] when a left menu item is \"active\" make it bold","description":"The \"default\" is UNREAD - this should be in the \"bold\" state when you're seeing that. When you filter out to \"ALL\" that should instead be bold. Same with the individual feeds if one is selected. And Starred.","type":"epic","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-14T00:47:25.74838134Z","updated":"2026-02-14T01:25:07.267016355Z"} {"id":"NK-iklxn4","title":"infinite scroll on ipad/mobile","description":"On a mobile device, the infinite scrooll didn't seem to be working properly and triggering as I scrolled to the bottom. Are the right triggers set up for mobile browsers as well as desktop -- this was on an ipad.","type":"bug","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-15T16:12:01.013697894Z","updated":"2026-02-15T16:35:49.542636756Z"} @@ -89,6 +90,7 @@ {"id":"NK-k4y597","title":"[feature] light/dark/black toggle","description":"Add in a simple [light | dark | black] theme toggler like in the legacy version.","type":"feature","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-14T00:45:55.312953906Z","updated":"2026-02-14T01:29:20.073659889Z"} {"id":"NK-kqt9oc","title":"docker support","description":"add support so people can self-host this in docker and (maybe) test it yourself. maybe keep it in a docker directory with separate docs etc.","type":"epic","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-13T20:19:10.70328135Z","updated":"2026-02-14T01:03:35.363466842Z"} {"id":"NK-kra45a","title":"enhance github ci/cq","description":"Make sure we have the right CI/CQ things in the github workflow. Can it test our docker compose for us too maybe","type":"feature","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-15T00:08:25.732991582Z","updated":"2026-02-15T01:04:54.350079542Z"} +{"id":"NK-lo7l8g","title":"Vanilla JS (v3): Achieve 80% test coverage","description":"","type":"task","status":"open","priority":2,"labels":null,"assignee":"","created":"2026-02-16T02:43:17.405569291Z","updated":"2026-02-16T02:43:17.405569291Z"} {"id":"NK-lrew5z","title":"UI Styling: Global Typography \u0026 Layout (Fixed Width)","description":"","type":"","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-13T18:04:57.254341682Z","updated":"2026-02-13T18:11:31.436752093Z"} {"id":"NK-lrihov","title":"Re-enable and fix errcheck lints","description":"Re-enable 'errcheck' in .golangci.yml and fix the remaining ~140 issues, mostly related to unchecked Writes and DB operations.","type":"cleanup","status":"open","priority":4,"labels":null,"assignee":"","created":"2026-02-15T16:41:08.395284346Z","updated":"2026-02-15T16:41:08.395284346Z"} {"id":"NK-m8bya7","title":"Fix and Re-enable Playwright E2E Tests","description":"E2E tests were crashing the VM and timing out. Disabled them in package.json. Need to investigate resource usage and re-enable.","type":"bug","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-14T03:08:18.501189975Z","updated":"2026-02-14T04:00:03.995357386Z"} @@ -104,6 +106,8 @@ {"id":"NK-op5594","title":"Ensure 80% Frontend Test Coverage","description":"Configure coverage reporting in vitest and ensure the frontend codebase maintains at least 80% test coverage.","type":"task","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-13T05:46:24.13314466Z","updated":"2026-02-13T05:50:46.728239299Z"} {"id":"NK-p0nfoi","title":"Multi-select feeds in sidebar","description":"Enable multi-selection of feeds in the sidebar to view combined streams from several feeds at once. Keep current additive filtering logic.","type":"feature","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-15T16:49:31.008650778Z","updated":"2026-02-15T22:09:56.285995223Z"} {"id":"NK-p89hyt","title":"make new v2 UI the default and serve at /","description":"After we move the old UI to be served at v1, serve the new UI at /\n\nWe can keep serving it at v2/ as well if we want.","type":"task","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-14T16:42:20.13241547Z","updated":"2026-02-14T17:38:26.362895517Z"} +{"id":"NK-p9uqpd","title":"Vanilla JS (v3): Redesign to 2-pane glassmorphism (parity with v2)","description":"","type":"feature","status":"open","priority":1,"labels":null,"assignee":"","created":"2026-02-16T02:43:04.953803234Z","updated":"2026-02-16T02:43:04.953803234Z"} +{"id":"NK-p9z0i0","title":"Vanilla JS (v3): Redesign to 2-pane glassmorphism (parity with v2)","description":"","type":"feature","status":"open","priority":1,"labels":null,"assignee":"","created":"2026-02-16T02:43:11.207279424Z","updated":"2026-02-16T02:43:11.207279424Z"} {"id":"NK-pmznme","title":"Implement ClientLogin and Token endpoints","description":"","type":"task","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-15T00:21:47.832112417Z","updated":"2026-02-15T00:44:41.338958256Z"} {"id":"NK-pr3re0","title":"Implement Stream Contents endpoint","description":"","type":"task","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-15T00:21:53.326022558Z","updated":"2026-02-15T00:44:41.477972444Z"} {"id":"NK-pumdm4","title":"get rid of the \"selected\" highlight thing","description":"the legacy version doesn't do that and i find it distracting, j/k just move things up/down","type":"bug","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-13T20:27:51.434041661Z","updated":"2026-02-13T22:37:06.185341246Z"} @@ -157,7 +157,13 @@ You can do most of what you need to do with `neko` from the web interface, which $ neko -`neko` web interface should now be available at `127.0.0.1:4994` -- opening a browser up to that should show you the interface. +`neko` web interface is available by default at `127.0.0.1:4994`. + +Neko currently bundles three versions of the web interface for different preferences: + +* **v2 (Modern React)**: Available at `/` (default) and `/v2/`. This is the primary modern interface. +* **v3 (Performance Vanilla)**: Available at `/v3/`. A high-performance, zero-dependency version built for speed and simplicity. +* **v1 (Legacy Backbone)**: Available at `/v1/`. The original classic interface. You can specify a different port using the `--http` option. diff --git a/frontend-vanilla/.gitignore b/frontend-vanilla/.gitignore index a547bf3..1eb4319 100644 --- a/frontend-vanilla/.gitignore +++ b/frontend-vanilla/.gitignore @@ -22,3 +22,4 @@ dist-ssr *.njsproj *.sln *.sw? +coverage diff --git a/frontend-vanilla/package-lock.json b/frontend-vanilla/package-lock.json index 8a82676..42cf825 100644 --- a/frontend-vanilla/package-lock.json +++ b/frontend-vanilla/package-lock.json @@ -10,6 +10,7 @@ "devDependencies": { "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", + "@vitest/coverage-v8": "^4.0.18", "jsdom": "^28.1.0", "typescript": "~5.9.3", "vite": "^7.3.1", @@ -74,6 +75,15 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", @@ -83,6 +93,21 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/runtime": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", @@ -92,6 +117,28 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -663,12 +710,31 @@ } } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", @@ -1072,6 +1138,36 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", + "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", + "dev": true, + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.18", + "ast-v8-to-istanbul": "^0.3.10", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.18", + "vitest": "4.0.18" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.0.18", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", @@ -1224,6 +1320,23 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz", + "integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -1435,6 +1548,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -1447,6 +1569,12 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -1488,6 +1616,42 @@ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1561,6 +1725,32 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mdn-data": { "version": "2.12.2", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", @@ -1781,6 +1971,18 @@ "node": ">=v12.22.7" } }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1820,6 +2022,18 @@ "node": ">=8" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", diff --git a/frontend-vanilla/package.json b/frontend-vanilla/package.json index 65e19b2..126c675 100644 --- a/frontend-vanilla/package.json +++ b/frontend-vanilla/package.json @@ -12,9 +12,10 @@ "devDependencies": { "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", + "@vitest/coverage-v8": "^4.0.18", "jsdom": "^28.1.0", "typescript": "~5.9.3", "vite": "^7.3.1", "vitest": "^4.0.18" } -}
\ No newline at end of file +} diff --git a/frontend-vanilla/src/api.test.ts b/frontend-vanilla/src/api.test.ts new file mode 100644 index 0000000..9128ef3 --- /dev/null +++ b/frontend-vanilla/src/api.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { apiFetch, getCookie } from './api'; + +describe('api', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + document.cookie = ''; + }); + + it('getCookie should return cookie value', () => { + document.cookie = 'foo=bar'; + document.cookie = 'csrf_token=test-token'; + expect(getCookie('csrf_token')).toBe('test-token'); + expect(getCookie('foo')).toBe('bar'); + expect(getCookie('baz')).toBeUndefined(); + }); + + it('apiFetch should include CSRF token for POST requests', async () => { + document.cookie = 'csrf_token=test-token'; + const mockFetch = vi.mocked(fetch); + mockFetch.mockResolvedValueOnce(new Response()); + + await apiFetch('/test', { method: 'POST' }); + + expect(mockFetch).toHaveBeenCalledWith('/test', expect.objectContaining({ + method: 'POST', + headers: expect.any(Headers), + credentials: 'include' + })); + + const headers = mockFetch.mock.calls[0][1]?.headers as Headers; + expect(headers.get('X-CSRF-Token')).toBe('test-token'); + }); + + it('apiFetch should not include CSRF token for GET requests', async () => { + document.cookie = 'csrf_token=test-token'; + const mockFetch = vi.mocked(fetch); + mockFetch.mockResolvedValueOnce(new Response()); + + await apiFetch('/test'); + + const headers = mockFetch.mock.calls[0][1]?.headers as Headers; + expect(headers.get('X-CSRF-Token')).toBeNull(); + }); +}); diff --git a/frontend-vanilla/src/components/FeedItem.test.ts b/frontend-vanilla/src/components/FeedItem.test.ts index 708a871..e6c0b62 100644 --- a/frontend-vanilla/src/components/FeedItem.test.ts +++ b/frontend-vanilla/src/components/FeedItem.test.ts @@ -1,23 +1,39 @@ import { describe, it, expect } from 'vitest'; import { createFeedItem } from './FeedItem'; +import type { Item } from '../types'; describe('FeedItem Component', () => { - const mockFeed = { _id: 1, title: 'My Feed', url: 'http://test', web_url: 'http://test', category: 'tag' }; + const mockItem: Item = { + _id: 1, + title: 'Item Title', + url: 'http://test', + publish_date: '2023-01-01', + read: false, + starred: false, + feed_title: 'Feed Title', + description: 'Desc' + } as any; - it('should render a feed item correctly', () => { - const html = createFeedItem(mockFeed, false); - expect(html).toContain('My Feed'); + it('should render an item correctly', () => { + const html = createFeedItem(mockItem); + expect(html).toContain('Item Title'); expect(html).toContain('data-id="1"'); - expect(html).not.toContain('active'); + expect(html).toContain('unread'); }); - it('should apply active class when isActive is true', () => { - const html = createFeedItem(mockFeed, true); - expect(html).toContain('active'); + it('should show read state', () => { + const html = createFeedItem({ ...mockItem, read: true }); + expect(html).toContain('read'); + expect(html).not.toContain('unread'); }); - it('should fallback to URL if title is missing', () => { - const html = createFeedItem({ ...mockFeed, title: '' }, false); - expect(html).toContain('http://test'); + it('should show starred state', () => { + const html = createFeedItem({ ...mockItem, starred: true }); + expect(html).toContain('is-starred'); + }); + + it('should fallback to (No Title) if title is missing', () => { + const html = createFeedItem({ ...mockItem, title: '' }); + expect(html).toContain('(No Title)'); }); }); diff --git a/frontend-vanilla/src/components/FeedItem.ts b/frontend-vanilla/src/components/FeedItem.ts index 3bf72c2..e58aac8 100644 --- a/frontend-vanilla/src/components/FeedItem.ts +++ b/frontend-vanilla/src/components/FeedItem.ts @@ -1,11 +1,35 @@ -import type { Feed } from '../types'; +import type { Item } from '../types'; -export function createFeedItem(feed: Feed, isActive: boolean): string { - return ` - <li class="feed-item ${isActive ? 'active' : ''}" data-id="${feed._id}"> - <a href="/v3/feed/${feed._id}" class="feed-link" onclick="event.preventDefault(); window.app.navigate('/feed/${feed._id}')"> - ${feed.title || feed.url} - </a> +export function createFeedItem(item: Item): string { + const date = new Date(item.publish_date).toLocaleDateString(); + return ` + <li class="feed-item ${item.read ? 'read' : 'unread'}" data-id="${item._id}"> + <div class="item-header"> + <a href="${item.url}" target="_blank" rel="noopener noreferrer" class="item-title" data-action="open"> + ${item.title || '(No Title)'} + </a> + <button class="star-btn ${item.starred ? 'is-starred' : 'is-unstarred'}" title="${item.starred ? 'Unstar' : 'Star'}" data-action="toggle-star"> + ★ + </button> + </div> + <div class="dateline"> + <a href="${item.url}" target="_blank" rel="noopener noreferrer"> + ${date} + ${item.feed_title ? ` - ${item.feed_title}` : ''} + </a> + <div class="item-actions" style="display: inline-block; float: right;"> + ${!item.full_content ? ` + <button class="scrape-btn" title="Load Full Content" data-action="scrape"> + text + </button> + ` : ''} + </div> + </div> + ${(item.full_content || item.description) ? ` + <div class="item-description"> + ${item.full_content || item.description} + </div> + ` : ''} </li> `; } diff --git a/frontend-vanilla/src/main.test.ts b/frontend-vanilla/src/main.test.ts new file mode 100644 index 0000000..be5a076 --- /dev/null +++ b/frontend-vanilla/src/main.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { store } from './store'; +import { router } from './router'; +import { + renderLayout, + renderFeeds, + renderTags, + renderFilters, + renderItems, + renderSettings, + fetchFeeds, + fetchTags, + fetchItems, + init, + logout +} from './main'; +import { apiFetch } from './api'; + +// Mock api +vi.mock('./api', () => ({ + apiFetch: vi.fn() +})); + +// Mock IntersectionObserver +const mockObserver = vi.fn(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})); +vi.stubGlobal('IntersectionObserver', mockObserver); + +describe('main application logic', () => { + beforeEach(() => { + document.body.innerHTML = '<div id="app"></div>'; + vi.stubGlobal('location', { + href: 'http://localhost/v3/', + pathname: '/v3/', + search: '', + assign: vi.fn(), + replace: vi.fn() + }); + vi.stubGlobal('history', { + pushState: vi.fn() + }); + // Mock scrollIntoView which is missing in JSDOM + Element.prototype.scrollIntoView = vi.fn(); + vi.clearAllMocks(); + // Reset store + store.setFeeds([]); + store.setTags([]); + store.setItems([]); + + // Setup default auth response + vi.mocked(apiFetch).mockResolvedValue({ + ok: true, + status: 200, + json: async () => [] + } as Response); + }); + + it('renderLayout should create sidebar and main content', () => { + renderLayout(); + expect(document.getElementById('sidebar')).not.toBeNull(); + expect(document.getElementById('content-area')).not.toBeNull(); + expect(document.getElementById('logo-link')).not.toBeNull(); + }); + + it('renderFeeds should populate feed list', () => { + renderLayout(); + store.setFeeds([{ _id: 1, title: 'Test Feed', url: 'test', web_url: 'test', category: 'tag' }]); + renderFeeds(); + const feedList = document.getElementById('feed-list'); + expect(feedList?.innerHTML).toContain('Test Feed'); + }); + + it('renderTags should populate tag list', () => { + renderLayout(); + store.setTags([{ title: 'Test Tag' } as any]); + renderTags(); + const tagList = document.getElementById('tag-list'); + expect(tagList?.innerHTML).toContain('Test Tag'); + }); + + it('renderFilters should update active filter', () => { + renderLayout(); + store.setFilter('starred'); + renderFilters(); + const starredFilter = document.querySelector('[data-filter="starred"]'); + expect(starredFilter?.classList.contains('active')).toBe(true); + }); + + it('renderItems should populate content area', () => { + renderLayout(); + store.setItems([{ _id: 1, title: 'Item 1', url: 'test', publish_date: '2023-01-01' } as any]); + renderItems(); + const contentArea = document.getElementById('content-area'); + expect(contentArea?.innerHTML).toContain('Item 1'); + }); + + it('renderSettings should show theme and font options', () => { + renderLayout(); + renderSettings(); + expect(document.querySelector('.settings-view')).not.toBeNull(); + expect(document.getElementById('font-selector')).not.toBeNull(); + }); + + it('fetchFeeds should update store', async () => { + vi.mocked(apiFetch).mockResolvedValueOnce({ + ok: true, + json: async () => [{ _id: 1, title: 'API Feed' }] + } as Response); + + await fetchFeeds(); + expect(store.feeds).toHaveLength(1); + expect(store.feeds[0].title).toBe('API Feed'); + }); + + it('fetchTags should update store', async () => { + vi.mocked(apiFetch).mockResolvedValueOnce({ + ok: true, + json: async () => [{ title: 'API Tag' }] + } as Response); + + await fetchTags(); + expect(store.tags).toHaveLength(1); + expect(store.tags[0].title).toBe('API Tag'); + }); + + it('fetchItems should update store items', async () => { + vi.mocked(apiFetch).mockResolvedValueOnce({ + ok: true, + json: async () => [{ _id: 1, title: 'API Item' }] + } as Response); + + renderLayout(); + await fetchItems(); + expect(store.items).toHaveLength(1); + expect(store.items[0].title).toBe('API Item'); + }); + + it('init should coordinate startup', async () => { + vi.mocked(apiFetch).mockResolvedValue({ + ok: true, + status: 200, + json: async () => [] + } as Response); + + await init(); + expect(document.getElementById('sidebar')).not.toBeNull(); + }); + + it('should handle search input', () => { + renderLayout(); + const searchInput = document.getElementById('search-input') as HTMLInputElement; + const spy = vi.spyOn(router, 'updateQuery'); + searchInput.value = 'query'; + searchInput.dispatchEvent(new Event('input')); + expect(spy).toHaveBeenCalledWith({ q: 'query' }); + }); + + it('should handle sidebar navigation clicking', () => { + renderLayout(); + const spy = vi.spyOn(router, 'updateQuery'); + const filterLink = document.querySelector('[data-nav="filter"]') as HTMLElement; + filterLink.click(); + expect(spy).toHaveBeenCalled(); + }); + + it('should handle item star toggle', async () => { + renderLayout(); + const mockItem = { _id: 1, title: 'Item 1', starred: false, publish_date: '2023-01-01' } as any; + store.setItems([mockItem]); + renderItems(); + + vi.mocked(apiFetch).mockResolvedValue({ ok: true } as Response); + + const starBtn = document.querySelector('[data-action="toggle-star"]') as HTMLElement; + starBtn.click(); + + expect(apiFetch).toHaveBeenCalledWith(expect.stringContaining('/api/item/1'), expect.objectContaining({ + method: 'PUT', + body: expect.stringContaining('"starred":true') + })); + }); + + it('should handle theme change in settings', () => { + renderLayout(); + renderSettings(); + const darkBtn = document.querySelector('[data-theme="dark"]') as HTMLElement; + const spy = vi.spyOn(store, 'setTheme'); + darkBtn.click(); + expect(spy).toHaveBeenCalledWith('dark'); + }); + + it('should handle logout', async () => { + vi.mocked(apiFetch).mockResolvedValue({ ok: true } as Response); + await logout(); + expect(apiFetch).toHaveBeenCalledWith('/api/logout', { method: 'POST' }); + expect(window.location.href).toBe('/login/'); + }); + + it('should handle keyboard navigation j/k', () => { + const mockItems = [ + { _id: 1, title: 'Item 1', publish_date: '2023-01-01', read: false }, + { _id: 2, title: 'Item 2', publish_date: '2023-01-01', read: false } + ] as any; + store.setItems(mockItems); + renderLayout(); + renderItems(); + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'j' })); + expect(apiFetch).toHaveBeenCalled(); // mark as read + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k' })); + // should go back to first item + }); + + it('should handle toggle star/read with keyboard', async () => { + const mockItem = { _id: 1, title: 'Item 1', publish_date: '2023-01-01', read: true, starred: false } as any; + store.setItems([mockItem]); + renderLayout(); + renderItems(); + + // Already read, so 'j' won't trigger updateItem for read=true + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'j' })); + + vi.mocked(apiFetch).mockResolvedValue({ ok: true } as Response); + + // Toggle star + window.dispatchEvent(new KeyboardEvent('keydown', { key: 's' })); + expect(apiFetch).toHaveBeenCalledWith(expect.stringContaining('/api/item/1'), expect.objectContaining({ + body: expect.stringContaining('"starred":true') + })); + + // Toggle read (currently true -> false) + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'r' })); + expect(apiFetch).toHaveBeenLastCalledWith(expect.stringContaining('/api/item/1'), expect.objectContaining({ + body: expect.stringContaining('"read":false') + })); + }); + + it('should focus search with /', () => { + renderLayout(); + const searchInput = document.getElementById('search-input') as HTMLInputElement; + const spy = vi.spyOn(searchInput, 'focus'); + window.dispatchEvent(new KeyboardEvent('keydown', { key: '/' })); + expect(spy).toHaveBeenCalled(); + }); +}); diff --git a/frontend-vanilla/src/main.ts b/frontend-vanilla/src/main.ts index 0d47575..5e14266 100644 --- a/frontend-vanilla/src/main.ts +++ b/frontend-vanilla/src/main.ts @@ -6,24 +6,29 @@ import { router } from './router'; import type { Feed, Item, Category } from './types'; import { createFeedItem } from './components/FeedItem'; -// Extend Window interface for app object +// Extend Window interface for app object (keeping for compatibility if needed, but removing inline dependencies) declare global { interface Window { app: any; } } -// Cache elements -const appEl = document.querySelector<HTMLDivElement>('#app')!; +// Global App State +let activeItemId: number | null = null; + +// Cache elements (initialized in renderLayout) +let appEl: HTMLDivElement | null = null; -// Initial Layout -function renderLayout() { +// Initial Layout (v2-style 2-pane) +export function renderLayout() { + appEl = document.querySelector<HTMLDivElement>('#app'); + if (!appEl) return; appEl.className = `theme-${store.theme} font-${store.fontTheme}`; appEl.innerHTML = ` <div class="layout"> - <aside class="sidebar"> + <aside class="sidebar" id="sidebar"> <div class="sidebar-header"> - <h2 onclick="window.app.navigate('/')" style="cursor: pointer">Neko v3</h2> + <h2 id="logo-link">Neko v3</h2> </div> <div class="sidebar-search"> <input type="search" id="search-input" placeholder="Search..." value="${store.searchQuery}"> @@ -31,192 +36,261 @@ function renderLayout() { <div class="sidebar-scroll"> <section class="sidebar-section"> <h3>Filters</h3> - <ul id="filter-list" class="filter-list"> - <li class="filter-item" data-filter="unread"><a href="#" onclick="event.preventDefault(); window.app.setFilter('unread')">Unread</a></li> - <li class="filter-item" data-filter="all"><a href="#" onclick="event.preventDefault(); window.app.setFilter('all')">All</a></li> - <li class="filter-item" data-filter="starred"><a href="#" onclick="event.preventDefault(); window.app.setFilter('starred')">Starred</a></li> + <ul id="filter-list"> + <li class="filter-item" data-filter="unread"><a href="/v3/?filter=unread" data-nav="filter" data-value="unread">Unread</a></li> + <li class="filter-item" data-filter="all"><a href="/v3/?filter=all" data-nav="filter" data-value="all">All</a></li> + <li class="filter-item" data-filter="starred"><a href="/v3/?filter=starred" data-nav="filter" data-value="starred">Starred</a></li> </ul> </section> <section class="sidebar-section"> <h3>Tags</h3> - <ul id="tag-list" class="tag-list"></ul> + <ul id="tag-list"></ul> </section> <section class="sidebar-section"> <h3>Feeds</h3> - <ul id="feed-list" class="feed-list"></ul> + <ul id="feed-list"></ul> </section> </div> <div class="sidebar-footer"> - <a href="#" onclick="event.preventDefault(); window.app.navigate('/settings')">Settings</a> - <a href="#" onclick="event.preventDefault(); window.app.logout()">Logout</a> + <a href="/v3/settings" id="settings-link">Settings</a> + <a href="#" id="logout-button">Logout</a> </div> </aside> - <section class="item-list-pane"> - <header class="top-bar"> - <h1 id="view-title">All Items</h1> - </header> - <div id="item-list-container" class="item-list-container"></div> - </section> - <main class="item-detail-pane" id="main-pane"> - <div id="item-detail-content" class="item-detail-content"> - <div class="empty-state">Select an item to read</div> - </div> + <main class="main-content" id="main-content"> + <div id="content-area"></div> </main> </div> `; - // Attach search listener + attachLayoutListeners(); +} + +export function attachLayoutListeners() { const searchInput = document.getElementById('search-input') as HTMLInputElement; searchInput?.addEventListener('input', (e) => { const query = (e.target as HTMLInputElement).value; - window.app.setSearch(query); + router.updateQuery({ q: query }); }); -} -renderLayout(); + const logoLink = document.getElementById('logo-link'); + logoLink?.addEventListener('click', () => router.navigate('/')); -const feedListEl = document.getElementById('feed-list')!; -const tagListEl = document.getElementById('tag-list')!; -const filterListEl = document.getElementById('filter-list')!; -const viewTitleEl = document.getElementById('view-title')!; -const itemListEl = document.getElementById('item-list-container')!; -const itemDetailEl = document.getElementById('item-detail-content')!; + const logoutBtn = document.getElementById('logout-button'); + logoutBtn?.addEventListener('click', (e) => { + e.preventDefault(); + logout(); + }); -let activeItemId: number | null = null; + const settingsLink = document.getElementById('settings-link'); + settingsLink?.addEventListener('click', (e) => { + e.preventDefault(); + router.navigate('/settings'); + }); + + // Event delegation for filters, tags, and feeds in sidebar + const sidebar = document.getElementById('sidebar'); + sidebar?.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + const link = target.closest('a'); + if (!link) return; + + const navType = link.getAttribute('data-nav'); + if (navType === 'filter') { + e.preventDefault(); + const filter = link.getAttribute('data-value') as FilterType; + router.updateQuery({ filter }); + } else if (navType === 'tag') { + e.preventDefault(); + const tag = link.getAttribute('data-value')!; + router.navigate(`/tag/${encodeURIComponent(tag)}`); + } else if (navType === 'feed') { + e.preventDefault(); + const feedId = link.getAttribute('data-value')!; + router.navigate(`/feed/${feedId}`); + } + }); + + // Event delegation for content area (items) + const contentArea = document.getElementById('content-area'); + contentArea?.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + + // Handle Toggle Star + const starBtn = target.closest('[data-action="toggle-star"]'); + if (starBtn) { + const itemRow = starBtn.closest('[data-id]'); + if (itemRow) { + const id = parseInt(itemRow.getAttribute('data-id')!); + toggleStar(id); + } + return; + } + + // Handle Scrape + const scrapeBtn = target.closest('[data-action="scrape"]'); + if (scrapeBtn) { + const itemRow = scrapeBtn.closest('[data-id]'); + if (itemRow) { + const id = parseInt(itemRow.getAttribute('data-id')!); + scrapeItem(id); + } + return; + } + + // Handle Item interaction (mark as read on click title or row) + const itemTitle = target.closest('[data-action="open"]'); + const itemRow = target.closest('.feed-item'); + if (itemRow && !itemTitle) { // Clicking the row itself (but not the link) + // We can add "expand" logic here if we want but v2 shows it by default if loaded + // For now, let's just mark as read if it's unread + const id = parseInt(itemRow.getAttribute('data-id')!); + const item = store.items.find(i => i._id === id); + if (item && !item.read) { + updateItem(id, { read: true }); + } + } + }); +} // --- Rendering Functions --- -function renderFeeds() { +export function renderFeeds() { const { feeds, activeFeedId } = store; + const feedListEl = document.getElementById('feed-list'); if (!feedListEl) return; - feedListEl.innerHTML = feeds.map((feed: Feed) => - createFeedItem(feed, feed._id === activeFeedId) - ).join(''); + feedListEl.innerHTML = feeds.map((feed: Feed) => ` + <li class="${feed._id === activeFeedId ? 'active' : ''}"> + <a href="/v3/feed/${feed._id}" data-nav="feed" data-value="${feed._id}"> + ${feed.title || feed.url} + </a> + </li> + `).join(''); } -function renderTags() { +export function renderTags() { const { tags, activeTagName } = store; + const tagListEl = document.getElementById('tag-list'); if (!tagListEl) return; tagListEl.innerHTML = tags.map((tag: Category) => ` - <li class="tag-item ${tag.title === activeTagName ? 'active' : ''}"> - <a href="/v3/tag/${encodeURIComponent(tag.title)}" class="tag-link" onclick="event.preventDefault(); window.app.navigate('/tag/${encodeURIComponent(tag.title)}')"> + <li class="${tag.title === activeTagName ? 'active' : ''}"> + <a href="/v3/tag/${encodeURIComponent(tag.title)}" data-nav="tag" data-value="${tag.title}"> ${tag.title} </a> </li> `).join(''); } -function renderFilters() { +export function renderFilters() { const { filter } = store; + const filterListEl = document.getElementById('filter-list'); if (!filterListEl) return; - filterListEl.querySelectorAll('.filter-item').forEach(el => { + filterListEl.querySelectorAll('li').forEach(el => { el.classList.toggle('active', el.getAttribute('data-filter') === filter); }); } -function renderItems() { +export function renderItems() { const { items, loading } = store; - if (!itemListEl) return; + const contentArea = document.getElementById('content-area'); + if (!contentArea || router.getCurrentRoute().path === '/settings') return; if (loading && items.length === 0) { - itemListEl.innerHTML = '<p class="loading">Loading items...</p>'; + contentArea.innerHTML = '<p class="loading">Loading items...</p>'; return; } if (items.length === 0) { - itemListEl.innerHTML = '<p class="empty">No items found.</p>'; + contentArea.innerHTML = '<p class="empty">No items found.</p>'; return; } - itemListEl.innerHTML = ` + contentArea.innerHTML = ` <ul class="item-list"> - ${items.map((item: Item) => ` - <li class="item-row ${item.read ? 'read' : ''} ${item._id === activeItemId ? 'active' : ''}" data-id="${item._id}"> - <div class="item-title">${item.title}</div> - <div class="item-meta">${item.feed_title || ''}</div> - </li> - `).join('')} + ${items.map((item: Item) => createFeedItem(item)).join('')} </ul> - ${store.hasMore ? '<div id="load-more" class="load-more">Loading more...</div>' : ''} + ${store.hasMore ? '<div id="load-more-sentinel" class="loading-more">Loading more...</div>' : ''} `; - // Add click listeners to items - itemListEl.querySelectorAll('.item-row').forEach(row => { - row.addEventListener('click', () => { - const id = parseInt(row.getAttribute('data-id') || '0'); - selectItem(id); - }); - }); - - // Infinite scroll observer - const loadMoreEl = document.getElementById('load-more'); - if (loadMoreEl) { + // Setup infinite scroll + const sentinel = document.getElementById('load-more-sentinel'); + if (sentinel) { const observer = new IntersectionObserver((entries) => { if (entries[0].isIntersecting && !store.loading && store.hasMore) { loadMore(); } }, { threshold: 0.1 }); - observer.observe(loadMoreEl); + observer.observe(sentinel); } } -async function selectItem(id: number, scroll: boolean = false) { - activeItemId = id; - const item = store.items.find((i: Item) => i._id === id); - if (!item) return; +export function renderSettings() { + const contentArea = document.getElementById('content-area'); + if (!contentArea) return; + contentArea.innerHTML = ` + <div class="settings-view"> + <h2>Settings</h2> + <section class="settings-section"> + <h3>Theme</h3> + <div class="theme-options" id="theme-options"> + <button class="${store.theme === 'light' ? 'active' : ''}" data-theme="light">Light</button> + <button class="${store.theme === 'dark' ? 'active' : ''}" data-theme="dark">Dark</button> + </div> + </section> + <section class="settings-section"> + <h3>Font</h3> + <select id="font-selector"> + <option value="default" ${store.fontTheme === 'default' ? 'selected' : ''}>Default (Serif)</option> + <option value="serif" ${store.fontTheme === 'serif' ? 'selected' : ''}>Serif (Georgia)</option> + <option value="mono" ${store.fontTheme === 'mono' ? 'selected' : ''}>Monospace</option> + </select> + </section> + </div> + `; - // Mark active row - itemListEl.querySelectorAll('.item-row').forEach(row => { - const rowId = parseInt(row.getAttribute('data-id') || '0'); - row.classList.toggle('active', rowId === id); - if (scroll && rowId === id) { - row.scrollIntoView({ block: 'nearest' }); + // Attach settings listeners + const themeOptions = document.getElementById('theme-options'); + themeOptions?.addEventListener('click', (e) => { + const btn = (e.target as HTMLElement).closest('button'); + if (btn) { + const theme = btn.getAttribute('data-theme')!; + store.setTheme(theme); + renderSettings(); // Re-render to show active } }); - // Render basic detail - itemDetailEl.innerHTML = ` - <article class="item-detail"> - <header> - <h1><a href="${item.url}" target="_blank">${item.title}</a></h1> - <div class="item-meta"> - From ${item.feed_title || 'Unknown'} on ${new Date(item.publish_date).toLocaleString()} - </div> - <div class="item-actions"> - <button onclick="window.app.toggleStar(${item._id})">${item.starred ? '★ Unstar' : '☆ Star'}</button> - <button onclick="window.app.toggleRead(${item._id})">${item.read ? 'Unread' : 'Read'}</button> - </div> - </header> - <div id="full-content" class="full-content"> - ${item.description || 'No description available.'} - </div> - </article> - `; + const fontSelector = document.getElementById('font-selector') as HTMLSelectElement; + fontSelector?.addEventListener('change', () => { + store.setFontTheme(fontSelector.value); + }); +} + +// --- Data Actions --- - // Mark as read if not already - if (!item.read) { - updateItem(item._id, { read: true }); +export async function toggleStar(id: number) { + const item = store.items.find(i => i._id === id); + if (item) { + updateItem(id, { starred: !item.starred }); } +} - // Fetch full content if missing - if (item.url && (!item.full_content || item.full_content === item.description)) { - try { - const res = await apiFetch(`/api/item/${item._id}/content`); - if (res.ok) { - const data = await res.json(); - if (data.full_content) { - item.full_content = data.full_content; - const contentEl = document.getElementById('full-content'); - if (contentEl) contentEl.innerHTML = data.full_content; - } +export async function scrapeItem(id: number) { + const item = store.items.find(i => i._id === id); + if (!item) return; + + try { + const res = await apiFetch(`/api/item/${id}/content`); + if (res.ok) { + const data = await res.json(); + if (data.full_content) { + updateItem(id, { full_content: data.full_content }); } - } catch (err) { - console.error('Failed to fetch full content', err); } + } catch (err) { + console.error('Failed to fetch full content', err); } } -async function updateItem(id: number, updates: Partial<Item>) { +export async function updateItem(id: number, updates: Partial<Item>) { try { const res = await apiFetch(`/api/item/${id}`, { method: 'PUT', @@ -227,15 +301,21 @@ async function updateItem(id: number, updates: Partial<Item>) { const item = store.items.find(i => i._id === id); if (item) { Object.assign(item, updates); - const row = itemListEl.querySelector(`.item-row[data-id="${id}"]`); - if (row) { - if (updates.read !== undefined) row.classList.toggle('read', updates.read); - } - // Update detail view if active - if (activeItemId === id) { - const starBtn = itemDetailEl.querySelector('.item-actions button'); - if (starBtn && updates.starred !== undefined) { - starBtn.textContent = updates.starred ? '★ Unstar' : '☆ Star'; + // Selective DOM update to avoid full re-render + const el = document.querySelector(`.feed-item[data-id="${id}"]`); + if (el) { + if (updates.read !== undefined) el.classList.toggle('read', updates.read); + if (updates.starred !== undefined) { + const starBtn = el.querySelector('.star-btn'); + if (starBtn) { + starBtn.classList.toggle('is-starred', updates.starred); + starBtn.classList.toggle('is-unstarred', !updates.starred); + starBtn.setAttribute('title', updates.starred ? 'Unstar' : 'Star'); + } + } + if (updates.full_content) { + // If full content was scraped, we might need to update description or re-render chunk + renderItems(); // Full re-render is safer for content injection } } } @@ -245,64 +325,29 @@ async function updateItem(id: number, updates: Partial<Item>) { } } -function renderSettings() { - viewTitleEl.textContent = 'Settings'; - itemListEl.innerHTML = ''; - itemDetailEl.innerHTML = ` - <div class="settings-view"> - <h2>Settings</h2> - <section class="settings-section"> - <h3>Theme</h3> - <div class="theme-options"> - <button class="${store.theme === 'light' ? 'active' : ''}" onclick="window.app.setTheme('light')">Light</button> - <button class="${store.theme === 'dark' ? 'active' : ''}" onclick="window.app.setTheme('dark')">Dark</button> - </div> - </section> - <section class="settings-section"> - <h3>Font</h3> - <select onchange="window.app.setFontTheme(this.value)"> - <option value="default" ${store.fontTheme === 'default' ? 'selected' : ''}>Default</option> - <option value="serif" ${store.fontTheme === 'serif' ? 'selected' : ''}>Serif</option> - <option value="mono" ${store.fontTheme === 'mono' ? 'selected' : ''}>Monospace</option> - </select> - </section> - </div> - `; -} - -// --- Data Actions --- - -async function fetchFeeds() { - try { - const res = await apiFetch('/api/feed/'); - if (!res.ok) throw new Error('Failed to fetch feeds'); +export async function fetchFeeds() { + const res = await apiFetch('/api/feed/'); + if (res.ok) { const feeds = await res.json(); store.setFeeds(feeds); - } catch (err) { - console.error(err); } } -async function fetchTags() { - try { - const res = await apiFetch('/api/tag'); - if (!res.ok) throw new Error('Failed to fetch tags'); +export async function fetchTags() { + const res = await apiFetch('/api/tag'); + if (res.ok) { const tags = await res.json(); store.setTags(tags); - } catch (err) { - console.error(err); } } -async function fetchItems(feedId?: string, tagName?: string, append: boolean = false) { +export async function fetchItems(feedId?: string, tagName?: string, append: boolean = false) { store.setLoading(true); try { - let url = '/api/stream'; const params = new URLSearchParams(); if (feedId) params.append('feed_id', feedId); if (tagName) params.append('tag', tagName); if (store.searchQuery) params.append('q', store.searchQuery); - if (store.filter === 'unread') params.append('read', 'false'); if (store.filter === 'starred') params.append('starred', 'true'); @@ -310,30 +355,27 @@ async function fetchItems(feedId?: string, tagName?: string, append: boolean = f params.append('max_id', String(store.items[store.items.length - 1]._id)); } - const res = await apiFetch(`${url}?${params.toString()}`); - if (!res.ok) throw new Error('Failed to fetch items'); - const items = await res.json(); - - store.setHasMore(items.length >= 50); - store.setItems(items, append); - - if (!append) { - activeItemId = null; - itemDetailEl.innerHTML = '<div class="empty-state">Select an item to read</div>'; + const res = await apiFetch(`/api/stream?${params.toString()}`); + if (res.ok) { + const items = await res.json(); + store.setHasMore(items.length >= 50); + store.setItems(items, append); } - } catch (err) { - console.error(err); - if (!append) store.setItems([]); } finally { store.setLoading(false); } } -async function loadMore() { +export async function loadMore() { const route = router.getCurrentRoute(); fetchItems(route.params.feedId, route.params.tagName, true); } +export async function logout() { + await apiFetch('/api/logout', { method: 'POST' }); + window.location.href = '/login/'; +} + // --- App Logic --- function handleRoute() { @@ -357,17 +399,13 @@ function handleRoute() { if (route.path === '/feed' && route.params.feedId) { const id = parseInt(route.params.feedId); store.setActiveFeed(id); - const feed = store.feeds.find((f: Feed) => f._id === id); - viewTitleEl.textContent = feed ? feed.title : `Feed ${id}`; fetchItems(route.params.feedId); } else if (route.path === '/tag' && route.params.tagName) { store.setActiveTag(route.params.tagName); - viewTitleEl.textContent = `Tag: ${route.params.tagName}`; fetchItems(undefined, route.params.tagName); } else { store.setActiveFeed(null); store.setActiveTag(null); - viewTitleEl.textContent = 'All Items'; fetchItems(); } } @@ -407,7 +445,16 @@ function navigateItems(direction: number) { let index = store.items.findIndex(i => i._id === activeItemId); index += direction; if (index >= 0 && index < store.items.length) { - selectItem(store.items[index]._id, true); + activeItemId = store.items[index]._id; + const el = document.querySelector(`.feed-item[data-id="${activeItemId}"]`); + if (el) el.scrollIntoView({ block: 'nearest' }); + // Optional: mark as read when keyboard navigating + if (!store.items[index].read) updateItem(activeItemId, { read: true }); + // Since we are in 2-pane, we just scroll to it. + } else if (index === -1) { + activeItemId = store.items[0]._id; + const el = document.querySelector(`.feed-item[data-id="${activeItemId}"]`); + if (el) el.scrollIntoView({ block: 'nearest' }); } } @@ -428,7 +475,10 @@ store.on('search-updated', () => { handleRoute(); }); store.on('theme-updated', () => { - appEl.className = `theme-${store.theme} font-${store.fontTheme}`; + if (!appEl) appEl = document.querySelector<HTMLDivElement>('#app'); + if (appEl) { + appEl.className = `theme-${store.theme} font-${store.fontTheme}`; + } }); store.on('items-updated', renderItems); @@ -437,40 +487,31 @@ store.on('loading-state-changed', renderItems); // Subscribe to router router.addEventListener('route-changed', handleRoute); -// Global app object for inline handlers +// Compatibility app object (empty handlers, since we use delegation) window.app = { - navigate: (path: string) => router.navigate(path), - setFilter: (filter: FilterType) => router.updateQuery({ filter }), - setSearch: (q: string) => { - router.updateQuery({ q }); - }, - setTheme: (t: string) => store.setTheme(t), - setFontTheme: (f: string) => store.setFontTheme(f), - toggleStar: (id: number) => { - const item = store.items.find(i => i._id === id); - if (item) updateItem(id, { starred: !item.starred }); - }, - toggleRead: (id: number) => { - const item = store.items.find(i => i._id === id); - if (item) updateItem(id, { read: !item.read }); - }, - logout: async () => { - await apiFetch('/api/logout', { method: 'POST' }); - window.location.href = '/login/'; - } + navigate: (path: string) => router.navigate(path) }; // Start -async function init() { +// Start +export async function init() { const authRes = await apiFetch('/api/auth'); - if (authRes.status === 401) { + if (!authRes || authRes.status === 401) { window.location.href = '/login/'; return; } + renderLayout(); renderFilters(); - await Promise.all([fetchFeeds(), fetchTags()]); - handleRoute(); // handles initial route + try { + await Promise.all([fetchFeeds(), fetchTags()]); + } catch (err) { + console.error('Initial fetch failed', err); + } + handleRoute(); } -init(); +// Only auto-init if not in a test environment +if (typeof window !== 'undefined' && !(window as any).__VITEST__) { + init(); +} diff --git a/frontend-vanilla/src/router.test.ts b/frontend-vanilla/src/router.test.ts index d79abc1..c206d9c 100644 --- a/frontend-vanilla/src/router.test.ts +++ b/frontend-vanilla/src/router.test.ts @@ -1,14 +1,28 @@ -import { describe, it, expect, vi } from 'vitest'; -import { router } from './router'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Router } from './router'; describe('Router', () => { + let router: Router; + + beforeEach(() => { + vi.stubGlobal('location', { + href: 'http://localhost/v3/', + pathname: '/v3/', + search: '', + origin: 'http://localhost' + }); + vi.stubGlobal('history', { + pushState: vi.fn() + }); + router = new Router(); + }); + it('should parse simple paths', () => { - // Mock window.location vi.stubGlobal('location', { href: 'http://localhost/v3/feed/123', - pathname: '/v3/feed/123' + pathname: '/v3/feed/123', + search: '' }); - const route = router.getCurrentRoute(); expect(route.path).toBe('/feed'); expect(route.params.feedId).toBe('123'); @@ -17,9 +31,9 @@ describe('Router', () => { it('should parse tags correctly', () => { vi.stubGlobal('location', { href: 'http://localhost/v3/tag/Tech%20News', - pathname: '/v3/tag/Tech%20News' + pathname: '/v3/tag/Tech%20News', + search: '' }); - const route = router.getCurrentRoute(); expect(route.path).toBe('/tag'); expect(route.params.tagName).toBe('Tech News'); @@ -28,10 +42,34 @@ describe('Router', () => { it('should parse query parameters', () => { vi.stubGlobal('location', { href: 'http://localhost/v3/?filter=starred', - pathname: '/v3/' + pathname: '/v3/', + search: '?filter=starred' }); - const route = router.getCurrentRoute(); expect(route.query.get('filter')).toBe('starred'); }); + + it('should navigate to new path', () => { + router.navigate('/settings'); + // Match what the router actually does. + // If it uses new URL().pathname, it might be absolute. + expect(history.pushState).toHaveBeenCalled(); + }); + + it('should update query parameters', () => { + router.updateQuery({ q: 'test' }); + expect(history.pushState).toHaveBeenCalled(); + const call = vi.mocked(history.pushState).mock.calls[0]; + expect(call[2]).toContain('q=test'); + }); + + it('should trigger event on popstate', () => { + const handler = vi.fn(); + router.addEventListener('route-changed', handler); + + // Simulate popstate + window.dispatchEvent(PopStateEvent.prototype instanceof PopStateEvent ? new PopStateEvent('popstate') : new Event('popstate')); + + expect(handler).toHaveBeenCalled(); + }); }); diff --git a/frontend-vanilla/src/store.test.ts b/frontend-vanilla/src/store.test.ts index ccf9a1d..33deb7f 100644 --- a/frontend-vanilla/src/store.test.ts +++ b/frontend-vanilla/src/store.test.ts @@ -17,6 +17,20 @@ describe('Store', () => { expect(callback).toHaveBeenCalled(); }); + it('should handle tags', () => { + const store = new Store(); + const mockTags = [{ title: 'Tag 1' } as any]; + const callback = vi.fn(); + store.on('tags-updated', callback); + + store.setTags(mockTags); + expect(store.tags).toEqual(mockTags); + expect(callback).toHaveBeenCalled(); + + store.setActiveTag('Tag 1'); + expect(store.activeTagName).toBe('Tag 1'); + }); + it('should handle items and loading state', () => { const store = new Store(); const mockItems = [{ _id: 1, title: 'Item 1' } as any]; @@ -34,6 +48,20 @@ describe('Store', () => { store.setItems(mockItems); expect(store.items).toEqual(mockItems); expect(itemCallback).toHaveBeenCalled(); + + // Test append + const moreItems = [{ _id: 2, title: 'Item 2' } as any]; + store.setItems(moreItems, true); + expect(store.items).toHaveLength(2); + expect(store.items[1]._id).toBe(2); + }); + + it('should handle pagination state', () => { + const store = new Store(); + store.setHasMore(true); + expect(store.hasMore).toBe(true); + store.setHasMore(false); + expect(store.hasMore).toBe(false); }); it('should notify when active feed changes', () => { @@ -66,4 +94,15 @@ describe('Store', () => { expect(localStorage.getItem('neko-theme')).toBe('dark'); expect(callback).toHaveBeenCalled(); }); + + it('should handle font theme changes', () => { + const store = new Store(); + const callback = vi.fn(); + store.on('theme-updated', callback); + + store.setFontTheme('serif'); + expect(store.fontTheme).toBe('serif'); + expect(localStorage.getItem('neko-font-theme')).toBe('serif'); + expect(callback).toHaveBeenCalled(); + }); }); diff --git a/frontend-vanilla/src/style.css b/frontend-vanilla/src/style.css index c79fd3d..575be9d 100644 --- a/frontend-vanilla/src/style.css +++ b/frontend-vanilla/src/style.css @@ -1,40 +1,28 @@ :root { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + /* Font Variables */ + --font-body: Palatino, 'Palatino Linotype', 'Palatino LT STD', 'Book Antiqua', Georgia, serif; + --font-heading: 'Helvetica Neue', Helvetica, Arial, sans-serif; + --font-sans: Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + line-height: 1.5; - font-weight: 400; + font-size: 18px; - color-scheme: light dark; + /* Light Mode Defaults */ --bg-color: #ffffff; - --text-color: #213547; - --sidebar-bg: #f8f9fa; - --border-color: #e9ecef; + --text-color: rgba(0, 0, 0, 0.87); + --sidebar-bg: #ccc; + --link-color: #0000ee; + --border-color: #999; --accent-color: #007bff; - --hover-color: #e2e6ea; - --sidebar-width: 250px; - --item-list-width: 350px; -} - -.theme-dark { - --bg-color: #1a1a1a; - --text-color: #e9ecef; - --sidebar-bg: #212529; - --border-color: #343a40; - --accent-color: #375a7f; - --hover-color: #2c3034; -} - -.font-serif { - font-family: Georgia, serif; -} -.font-mono { - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + color-scheme: light dark; } body { margin: 0; - color: var(--text-color); + font-family: var(--font-body); background-color: var(--bg-color); + color: var(--text-color); height: 100vh; overflow: hidden; } @@ -46,59 +34,67 @@ body { .layout { display: flex; height: 100%; + width: 100%; } -/* Sidebar */ +/* Sidebar - glassmorphism by default */ .sidebar { - width: var(--sidebar-width); - background-color: var(--sidebar-bg); - border-right: 1px solid var(--border-color); + width: 14rem; + background: rgba(255, 255, 255, 0.05); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-right: 1px solid rgba(255, 255, 255, 0.1); display: flex; flex-direction: column; + height: 100%; + overflow: hidden; + z-index: 100; + padding: 1.5rem; } -.sidebar-header { - padding: 1rem; - border-bottom: 1px solid var(--border-color); +.theme-dark .sidebar { + background: rgba(0, 0, 0, 0.2); + border-right-color: rgba(255, 255, 255, 0.05); } .sidebar-header h2 { - margin: 0; - font-size: 1.1rem; + font-family: var(--font-heading); + font-size: 1.5rem; + margin: 0 0 2rem 0; + opacity: 0.8; + cursor: pointer; } .sidebar-search { - padding: 0.75rem 1rem; - border-bottom: 1px solid var(--border-color); + margin-bottom: 2rem; } .sidebar-search input { width: 100%; - padding: 0.4rem 0.6rem; - background-color: var(--bg-color); - border: 1px solid var(--border-color); - border-radius: 4px; + border-radius: 20px; + background: rgba(0, 0, 0, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); color: var(--text-color); - font-size: 0.85rem; + padding: 0.5rem 1rem; + font-size: 0.9rem; } .sidebar-scroll { flex: 1; overflow-y: auto; - padding: 1rem 0; -} - -.sidebar-section { - margin-bottom: 2rem; + margin: 0 -1.5rem; + padding: 0 1.5rem; } .sidebar-section h3 { - padding: 0 1rem; - font-size: 0.7rem; + font-family: var(--font-heading); + font-size: 0.75rem; text-transform: uppercase; - color: #888; - margin: 0 0 0.5rem 0; - letter-spacing: 0.05rem; + letter-spacing: 0.1em; + opacity: 0.5; + margin-top: 2rem; + margin-bottom: 0.5rem; + font-weight: 600; } .sidebar-section ul { @@ -109,206 +105,200 @@ body { .sidebar-section li a { display: block; - padding: 0.4rem 1rem; + padding: 0.4rem 0.8rem; + margin: 0.2rem 0; + border-radius: 8px; + transition: all 0.2s ease; + font-weight: 500; text-decoration: none; color: var(--text-color); - font-size: 0.9rem; + opacity: 0.8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.sidebar-section li:hover { - background-color: var(--hover-color); -} - -.sidebar-section li.active { - background-color: var(--hover-color); - font-weight: bold; +.sidebar-section li a:hover { + background: rgba(255, 255, 255, 0.1); + opacity: 1; + transform: translateX(4px); } .sidebar-section li.active a { - color: var(--accent-color); + background: rgba(255, 255, 255, 0.25); + opacity: 1; + font-weight: 700; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); } .sidebar-footer { - padding: 1rem; - border-top: 1px solid var(--border-color); + margin-top: auto; + padding-top: 1.5rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); display: flex; - justify-content: space-between; - font-size: 0.85rem; + flex-direction: column; + gap: 0.5rem; } .sidebar-footer a { - color: var(--text-color); + opacity: 0.6; + padding: 0.5rem 0.8rem; + border-radius: 8px; text-decoration: none; - opacity: 0.7; + color: var(--text-color); + font-size: 0.9rem; + font-family: var(--font-heading); } .sidebar-footer a:hover { + background: rgba(255, 255, 255, 0.05); opacity: 1; } -/* Item List Pane */ -.item-list-pane { - width: var(--item-list-width); - border-right: 1px solid var(--border-color); - display: flex; - flex-direction: column; - background-color: var(--bg-color); -} - -.top-bar { - padding: 0.75rem 1rem; - border-bottom: 1px solid var(--border-color); +/* Main Content area */ +.main-content { + flex: 1; + min-width: 0; + overflow-y: auto; background-color: var(--bg-color); - height: 40px; - display: flex; - align-items: center; -} - -.top-bar h1 { - margin: 0; - font-size: 0.95rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + padding: 2rem; } -.item-list-container { - flex: 1; - overflow-y: auto; +.main-content>* { + max-width: 35em; + margin: 0 auto; } +/* Feed Items Styles (from v2) */ .item-list { list-style: none; padding: 0; margin: 0; } -.item-row { - padding: 0.75rem 1rem; - border-bottom: 1px solid var(--border-color); - cursor: pointer; - transition: background 0.1s; -} - -.item-row:hover { - background-color: var(--hover-color); -} - -.item-row.active { - background-color: var(--hover-color); - border-left: 3px solid var(--accent-color); +.feed-item { + padding: 1rem 0; + margin-top: 5rem; + border-bottom: none; } -.item-row.read { - opacity: 0.6; +.item-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.5rem; } .item-title { - font-weight: 600; - font-size: 0.9rem; - margin-bottom: 0.2rem; - line-height: 1.3; -} - -.item-meta { - font-size: 0.75rem; - color: #888; + font-family: var(--font-heading); + font-size: 1.8rem; + font-weight: bold; + text-decoration: none; + color: var(--link-color); + display: block; + flex: 1; + cursor: pointer; } -.load-more { - padding: 1.5rem; - text-align: center; - color: #888; - font-size: 0.85rem; +.item-title:hover { + text-decoration: underline; } -/* Item Detail Pane */ -.item-detail-pane { - flex: 1; - overflow-y: auto; - background-color: var(--bg-color); +.star-btn { + background: none; + border: none; + cursor: pointer; + font-size: 1.25rem; + padding: 0 0 0 0.5rem; + vertical-align: middle; + transition: color 0.2s; + line-height: 1; } -.item-detail-content { - max-width: 700px; - margin: 0 auto; - padding: 2rem; +.star-btn.is-starred { + color: blue; } -.item-detail header { - margin-bottom: 2rem; - border-bottom: 1px solid var(--border-color); - padding-bottom: 1.5rem; +.star-btn.is-unstarred { + color: var(--text-color); + opacity: 0.3; } -.item-detail h1 { - font-size: 1.75rem; - margin: 0 0 0.75rem 0; - line-height: 1.2; +.dateline { + margin-top: 0; + font-weight: normal; + font-size: 0.75em; + color: #ccc; + margin-bottom: 1rem; } -.item-detail h1 a { - color: var(--text-color); +.dateline a { + color: #ccc; text-decoration: none; } -.item-detail h1 a:hover { - text-decoration: underline; -} - -.item-actions { - display: flex; - gap: 0.5rem; +.item-description { + color: var(--text-color); + line-height: 1.5; + font-size: 1rem; margin-top: 1rem; + overflow-wrap: break-word; + word-break: break-word; } -.item-actions button { - padding: 0.3rem 0.6rem; - font-size: 0.8rem; - cursor: pointer; - background-color: var(--bg-color); - border: 1px solid var(--border-color); - color: var(--text-color); - border-radius: 4px; +.item-description img { + max-width: 100%; + height: auto; + display: block; + margin: 1rem 0; } -.item-actions button:hover { - background-color: var(--hover-color); +.scrape-btn { + background: var(--bg-color); + border: 1px solid var(--border-color, #ccc); + color: blue; + cursor: pointer; + font-family: var(--font-heading); + font-weight: bold; + font-size: 0.8rem; + padding: 2px 6px; + margin-left: 0.5rem; } -.full-content { - font-size: 1.1rem; - line-height: 1.7; +/* Themes */ +.theme-dark { + --bg-color: #000000; + --text-color: #ffffff; + --sidebar-bg: #111111; + --link-color: rgb(90, 200, 250); + --border-color: #333; } -.full-content img { - max-width: 100%; - height: auto; - display: block; - margin: 1.5rem 0; - border-radius: 4px; +.font-serif { + --font-body: Georgia, 'Times New Roman', Times, serif; + font-family: var(--font-body); } -.full-content a { - color: var(--accent-color); +.font-mono { + --font-body: Menlo, Monaco, Consolas, 'Courier New', monospace; + font-family: var(--font-body); } +/* Settings View */ .settings-view { - padding: 2rem; + padding-top: 2rem; } .settings-section { - margin-bottom: 2rem; + margin-bottom: 2.5rem; } .settings-section h3 { - font-size: 1rem; - margin-bottom: 1rem; + font-family: var(--font-heading); border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem; + margin-bottom: 1rem; } .theme-options { @@ -316,23 +306,37 @@ body { gap: 1rem; } -.theme-options button.active { +button { + border-radius: 8px; + border: 1px solid var(--border-color); + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: bold; + font-family: inherit; + background-color: #f9f9f9; + cursor: pointer; + transition: all 0.2s; +} + +.theme-dark button { + background-color: #1a1a1a; + color: #fff; + border-color: #333; +} + +button.active { border-color: var(--accent-color); - background-color: var(--hover-color); + background-color: #eef; } -.empty-state { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - color: #888; - font-size: 1.1rem; +.theme-dark button.active { + background-color: #224; + border-color: var(--accent-color); } -.loading, -.empty { - padding: 2rem; - text-align: center; - color: #888; +@media (max-width: 768px) { + .sidebar { + display: none; + /* Mobile sidebar will need to be handled later */ + } }
\ No newline at end of file diff --git a/web/dist/v3/assets/index-CPnxXrEk.css b/web/dist/v3/assets/index-CPnxXrEk.css deleted file mode 100644 index 98e580a..0000000 --- a/web/dist/v3/assets/index-CPnxXrEk.css +++ /dev/null @@ -1 +0,0 @@ -:root{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;line-height:1.5;font-weight:400;color-scheme:light dark;--bg-color: #ffffff;--text-color: #213547;--sidebar-bg: #f8f9fa;--border-color: #e9ecef;--accent-color: #007bff;--hover-color: #e2e6ea;--sidebar-width: 250px;--item-list-width: 350px}.theme-dark{--bg-color: #1a1a1a;--text-color: #e9ecef;--sidebar-bg: #212529;--border-color: #343a40;--accent-color: #375a7f;--hover-color: #2c3034}.font-serif{font-family:Georgia,serif}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}body{margin:0;color:var(--text-color);background-color:var(--bg-color);height:100vh;overflow:hidden}#app{height:100%}.layout{display:flex;height:100%}.sidebar{width:var(--sidebar-width);background-color:var(--sidebar-bg);border-right:1px solid var(--border-color);display:flex;flex-direction:column}.sidebar-header{padding:1rem;border-bottom:1px solid var(--border-color)}.sidebar-header h2{margin:0;font-size:1.1rem}.sidebar-search{padding:.75rem 1rem;border-bottom:1px solid var(--border-color)}.sidebar-search input{width:100%;padding:.4rem .6rem;background-color:var(--bg-color);border:1px solid var(--border-color);border-radius:4px;color:var(--text-color);font-size:.85rem}.sidebar-scroll{flex:1;overflow-y:auto;padding:1rem 0}.sidebar-section{margin-bottom:2rem}.sidebar-section h3{padding:0 1rem;font-size:.7rem;text-transform:uppercase;color:#888;margin:0 0 .5rem;letter-spacing:.05rem}.sidebar-section ul{list-style:none;padding:0;margin:0}.sidebar-section li a{display:block;padding:.4rem 1rem;text-decoration:none;color:var(--text-color);font-size:.9rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sidebar-section li:hover{background-color:var(--hover-color)}.sidebar-section li.active{background-color:var(--hover-color);font-weight:700}.sidebar-section li.active a{color:var(--accent-color)}.sidebar-footer{padding:1rem;border-top:1px solid var(--border-color);display:flex;justify-content:space-between;font-size:.85rem}.sidebar-footer a{color:var(--text-color);text-decoration:none;opacity:.7}.sidebar-footer a:hover{opacity:1}.item-list-pane{width:var(--item-list-width);border-right:1px solid var(--border-color);display:flex;flex-direction:column;background-color:var(--bg-color)}.top-bar{padding:.75rem 1rem;border-bottom:1px solid var(--border-color);background-color:var(--bg-color);height:40px;display:flex;align-items:center}.top-bar h1{margin:0;font-size:.95rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.item-list-container{flex:1;overflow-y:auto}.item-list{list-style:none;padding:0;margin:0}.item-row{padding:.75rem 1rem;border-bottom:1px solid var(--border-color);cursor:pointer;transition:background .1s}.item-row:hover{background-color:var(--hover-color)}.item-row.active{background-color:var(--hover-color);border-left:3px solid var(--accent-color)}.item-row.read{opacity:.6}.item-title{font-weight:600;font-size:.9rem;margin-bottom:.2rem;line-height:1.3}.item-meta{font-size:.75rem;color:#888}.load-more{padding:1.5rem;text-align:center;color:#888;font-size:.85rem}.item-detail-pane{flex:1;overflow-y:auto;background-color:var(--bg-color)}.item-detail-content{max-width:700px;margin:0 auto;padding:2rem}.item-detail header{margin-bottom:2rem;border-bottom:1px solid var(--border-color);padding-bottom:1.5rem}.item-detail h1{font-size:1.75rem;margin:0 0 .75rem;line-height:1.2}.item-detail h1 a{color:var(--text-color);text-decoration:none}.item-detail h1 a:hover{text-decoration:underline}.item-actions{display:flex;gap:.5rem;margin-top:1rem}.item-actions button{padding:.3rem .6rem;font-size:.8rem;cursor:pointer;background-color:var(--bg-color);border:1px solid var(--border-color);color:var(--text-color);border-radius:4px}.item-actions button:hover{background-color:var(--hover-color)}.full-content{font-size:1.1rem;line-height:1.7}.full-content img{max-width:100%;height:auto;display:block;margin:1.5rem 0;border-radius:4px}.full-content a{color:var(--accent-color)}.settings-view{padding:2rem}.settings-section{margin-bottom:2rem}.settings-section h3{font-size:1rem;margin-bottom:1rem;border-bottom:1px solid var(--border-color);padding-bottom:.5rem}.theme-options{display:flex;gap:1rem}.theme-options button.active{border-color:var(--accent-color);background-color:var(--hover-color)}.empty-state{display:flex;align-items:center;justify-content:center;height:100%;color:#888;font-size:1.1rem}.loading,.empty{padding:2rem;text-align:center;color:#888} diff --git a/web/dist/v3/assets/index-DWEqmxLr.css b/web/dist/v3/assets/index-DWEqmxLr.css new file mode 100644 index 0000000..e5cfd30 --- /dev/null +++ b/web/dist/v3/assets/index-DWEqmxLr.css @@ -0,0 +1 @@ +:root{--font-body: Palatino, "Palatino Linotype", "Palatino LT STD", "Book Antiqua", Georgia, serif;--font-heading: "Helvetica Neue", Helvetica, Arial, sans-serif;--font-sans: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;line-height:1.5;font-size:18px;--bg-color: #ffffff;--text-color: rgba(0, 0, 0, .87);--sidebar-bg: #ccc;--link-color: #0000ee;--border-color: #999;--accent-color: #007bff;color-scheme:light dark}body{margin:0;font-family:var(--font-body);background-color:var(--bg-color);color:var(--text-color);height:100vh;overflow:hidden}#app{height:100%}.layout{display:flex;height:100%;width:100%}.sidebar{width:14rem;background:#ffffff0d;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-right:1px solid rgba(255,255,255,.1);display:flex;flex-direction:column;height:100%;overflow:hidden;z-index:100;padding:1.5rem}.theme-dark .sidebar{background:#0003;border-right-color:#ffffff0d}.sidebar-header h2{font-family:var(--font-heading);font-size:1.5rem;margin:0 0 2rem;opacity:.8;cursor:pointer}.sidebar-search{margin-bottom:2rem}.sidebar-search input{width:100%;border-radius:20px;background:#0000000d;border:1px solid rgba(255,255,255,.1);color:var(--text-color);padding:.5rem 1rem;font-size:.9rem}.sidebar-scroll{flex:1;overflow-y:auto;margin:0 -1.5rem;padding:0 1.5rem}.sidebar-section h3{font-family:var(--font-heading);font-size:.75rem;text-transform:uppercase;letter-spacing:.1em;opacity:.5;margin-top:2rem;margin-bottom:.5rem;font-weight:600}.sidebar-section ul{list-style:none;padding:0;margin:0}.sidebar-section li a{display:block;padding:.4rem .8rem;margin:.2rem 0;border-radius:8px;transition:all .2s ease;font-weight:500;text-decoration:none;color:var(--text-color);opacity:.8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sidebar-section li a:hover{background:#ffffff1a;opacity:1;transform:translate(4px)}.sidebar-section li.active a{background:#ffffff40;opacity:1;font-weight:700;box-shadow:0 4px 12px #0000001a;border:1px solid rgba(255,255,255,.2)}.sidebar-footer{margin-top:auto;padding-top:1.5rem;border-top:1px solid rgba(255,255,255,.1);display:flex;flex-direction:column;gap:.5rem}.sidebar-footer a{opacity:.6;padding:.5rem .8rem;border-radius:8px;text-decoration:none;color:var(--text-color);font-size:.9rem;font-family:var(--font-heading)}.sidebar-footer a:hover{background:#ffffff0d;opacity:1}.main-content{flex:1;min-width:0;overflow-y:auto;background-color:var(--bg-color);padding:2rem}.main-content>*{max-width:35em;margin:0 auto}.item-list{list-style:none;padding:0;margin:0}.feed-item{padding:1rem 0;margin-top:5rem;border-bottom:none}.item-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:.5rem}.item-title{font-family:var(--font-heading);font-size:1.8rem;font-weight:700;text-decoration:none;color:var(--link-color);display:block;flex:1;cursor:pointer}.item-title:hover{text-decoration:underline}.star-btn{background:none;border:none;cursor:pointer;font-size:1.25rem;padding:0 0 0 .5rem;vertical-align:middle;transition:color .2s;line-height:1}.star-btn.is-starred{color:#00f}.star-btn.is-unstarred{color:var(--text-color);opacity:.3}.dateline{margin-top:0;font-weight:400;font-size:.75em;color:#ccc;margin-bottom:1rem}.dateline a{color:#ccc;text-decoration:none}.item-description{color:var(--text-color);line-height:1.5;font-size:1rem;margin-top:1rem;overflow-wrap:break-word;word-break:break-word}.item-description img{max-width:100%;height:auto;display:block;margin:1rem 0}.scrape-btn{background:var(--bg-color);border:1px solid var(--border-color, #ccc);color:#00f;cursor:pointer;font-family:var(--font-heading);font-weight:700;font-size:.8rem;padding:2px 6px;margin-left:.5rem}.theme-dark{--bg-color: #000000;--text-color: #ffffff;--sidebar-bg: #111111;--link-color: rgb(90, 200, 250);--border-color: #333}.font-serif{--font-body: Georgia, "Times New Roman", Times, serif;font-family:var(--font-body)}.font-mono{--font-body: Menlo, Monaco, Consolas, "Courier New", monospace;font-family:var(--font-body)}.settings-view{padding-top:2rem}.settings-section{margin-bottom:2.5rem}.settings-section h3{font-family:var(--font-heading);border-bottom:1px solid var(--border-color);padding-bottom:.5rem;margin-bottom:1rem}.theme-options{display:flex;gap:1rem}button{border-radius:8px;border:1px solid var(--border-color);padding:.6em 1.2em;font-size:1em;font-weight:700;font-family:inherit;background-color:#f9f9f9;cursor:pointer;transition:all .2s}.theme-dark button{background-color:#1a1a1a;color:#fff;border-color:#333}button.active{border-color:var(--accent-color);background-color:#eef}.theme-dark button.active{background-color:#224;border-color:var(--accent-color)}@media(max-width:768px){.sidebar{display:none}} diff --git a/web/dist/v3/assets/index-FNdWoCuA.js b/web/dist/v3/assets/index-FNdWoCuA.js deleted file mode 100644 index 96a65fc..0000000 --- a/web/dist/v3/assets/index-FNdWoCuA.js +++ /dev/null @@ -1,102 +0,0 @@ -(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const n of document.querySelectorAll('link[rel="modulepreload"]'))s(n);new MutationObserver(n=>{for(const r of n)if(r.type==="childList")for(const d of r.addedNodes)d.tagName==="LINK"&&d.rel==="modulepreload"&&s(d)}).observe(document,{childList:!0,subtree:!0});function i(n){const r={};return n.integrity&&(r.integrity=n.integrity),n.referrerPolicy&&(r.referrerPolicy=n.referrerPolicy),n.crossOrigin==="use-credentials"?r.credentials="include":n.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function s(n){if(n.ep)return;n.ep=!0;const r=i(n);fetch(n.href,r)}})();function k(t){const i=`; ${document.cookie}`.split(`; ${t}=`);if(i.length===2)return i.pop()?.split(";").shift()}async function c(t,e){const i=e?.method?.toUpperCase()||"GET",s=["POST","PUT","DELETE"].includes(i),n=new Headers(e?.headers||{});if(s){const r=k("csrf_token");r&&n.set("X-CSRF-Token",r)}return fetch(t,{...e,headers:n,credentials:"include"})}class F extends EventTarget{feeds=[];tags=[];items=[];activeFeedId=null;activeTagName=null;filter="unread";searchQuery="";loading=!1;hasMore=!0;theme=localStorage.getItem("neko-theme")||"light";fontTheme=localStorage.getItem("neko-font-theme")||"default";setFeeds(e){this.feeds=e,this.emit("feeds-updated")}setTags(e){this.tags=e,this.emit("tags-updated")}setItems(e,i=!1){i?this.items=[...this.items,...e]:this.items=e,this.emit("items-updated")}setActiveFeed(e){this.activeFeedId=e,this.activeTagName=null,this.emit("active-feed-updated")}setActiveTag(e){this.activeTagName=e,this.activeFeedId=null,this.emit("active-tag-updated")}setFilter(e){this.filter!==e&&(this.filter=e,this.emit("filter-updated"))}setSearchQuery(e){this.searchQuery!==e&&(this.searchQuery=e,this.emit("search-updated"))}setLoading(e){this.loading=e,this.emit("loading-state-changed")}setHasMore(e){this.hasMore=e}setTheme(e){this.theme=e,localStorage.setItem("neko-theme",e),this.emit("theme-updated")}setFontTheme(e){this.fontTheme=e,localStorage.setItem("neko-font-theme",e),this.emit("theme-updated")}emit(e,i){this.dispatchEvent(new CustomEvent(e,{detail:i}))}on(e,i){this.addEventListener(e,i)}}const a=new F;class _ extends EventTarget{constructor(){super(),window.addEventListener("popstate",()=>this.handleRouteChange())}handleRouteChange(){this.dispatchEvent(new CustomEvent("route-changed",{detail:this.getCurrentRoute()}))}getCurrentRoute(){const e=new URL(window.location.href),s=e.pathname.replace(/^\/v3\//,"").split("/").filter(Boolean);let n="/";const r={};return s[0]==="feed"&&s[1]?(n="/feed",r.feedId=s[1]):s[0]==="tag"&&s[1]&&(n="/tag",r.tagName=decodeURIComponent(s[1])),{path:n,params:r,query:e.searchParams}}navigate(e,i){let s=`/v3${e}`;if(i){const n=new URLSearchParams(i);s+=`?${n.toString()}`}window.history.pushState({},"",s),this.handleRouteChange()}updateQuery(e){const i=new URL(window.location.href);for(const[s,n]of Object.entries(e))n?i.searchParams.set(s,n):i.searchParams.delete(s);window.history.pushState({},"",i.toString()),this.handleRouteChange()}}const u=new _;function R(t,e){return` - <li class="feed-item ${e?"active":""}" data-id="${t._id}"> - <a href="/v3/feed/${t._id}" class="feed-link" onclick="event.preventDefault(); window.app.navigate('/feed/${t._id}')"> - ${t.title||t.url} - </a> - </li> - `}const v=document.querySelector("#app");function C(){v.className=`theme-${a.theme} font-${a.fontTheme}`,v.innerHTML=` - <div class="layout"> - <aside class="sidebar"> - <div class="sidebar-header"> - <h2 onclick="window.app.navigate('/')" style="cursor: pointer">Neko v3</h2> - </div> - <div class="sidebar-search"> - <input type="search" id="search-input" placeholder="Search..." value="${a.searchQuery}"> - </div> - <div class="sidebar-scroll"> - <section class="sidebar-section"> - <h3>Filters</h3> - <ul id="filter-list" class="filter-list"> - <li class="filter-item" data-filter="unread"><a href="#" onclick="event.preventDefault(); window.app.setFilter('unread')">Unread</a></li> - <li class="filter-item" data-filter="all"><a href="#" onclick="event.preventDefault(); window.app.setFilter('all')">All</a></li> - <li class="filter-item" data-filter="starred"><a href="#" onclick="event.preventDefault(); window.app.setFilter('starred')">Starred</a></li> - </ul> - </section> - <section class="sidebar-section"> - <h3>Tags</h3> - <ul id="tag-list" class="tag-list"></ul> - </section> - <section class="sidebar-section"> - <h3>Feeds</h3> - <ul id="feed-list" class="feed-list"></ul> - </section> - </div> - <div class="sidebar-footer"> - <a href="#" onclick="event.preventDefault(); window.app.navigate('/settings')">Settings</a> - <a href="#" onclick="event.preventDefault(); window.app.logout()">Logout</a> - </div> - </aside> - <section class="item-list-pane"> - <header class="top-bar"> - <h1 id="view-title">All Items</h1> - </header> - <div id="item-list-container" class="item-list-container"></div> - </section> - <main class="item-detail-pane" id="main-pane"> - <div id="item-detail-content" class="item-detail-content"> - <div class="empty-state">Select an item to read</div> - </div> - </main> - </div> - `,document.getElementById("search-input")?.addEventListener("input",e=>{const i=e.target.value;window.app.setSearch(i)})}C();const w=document.getElementById("feed-list"),y=document.getElementById("tag-list"),T=document.getElementById("filter-list"),h=document.getElementById("view-title"),l=document.getElementById("item-list-container"),p=document.getElementById("item-detail-content");let o=null;function $(){const{feeds:t,activeFeedId:e}=a;w&&(w.innerHTML=t.map(i=>R(i,i._id===e)).join(""))}function E(){const{tags:t,activeTagName:e}=a;y&&(y.innerHTML=t.map(i=>` - <li class="tag-item ${i.title===e?"active":""}"> - <a href="/v3/tag/${encodeURIComponent(i.title)}" class="tag-link" onclick="event.preventDefault(); window.app.navigate('/tag/${encodeURIComponent(i.title)}')"> - ${i.title} - </a> - </li> - `).join(""))}function L(){const{filter:t}=a;T&&T.querySelectorAll(".filter-item").forEach(e=>{e.classList.toggle("active",e.getAttribute("data-filter")===t)})}function S(){const{items:t,loading:e}=a;if(!l)return;if(e&&t.length===0){l.innerHTML='<p class="loading">Loading items...</p>';return}if(t.length===0){l.innerHTML='<p class="empty">No items found.</p>';return}l.innerHTML=` - <ul class="item-list"> - ${t.map(s=>` - <li class="item-row ${s.read?"read":""} ${s._id===o?"active":""}" data-id="${s._id}"> - <div class="item-title">${s.title}</div> - <div class="item-meta">${s.feed_title||""}</div> - </li> - `).join("")} - </ul> - ${a.hasMore?'<div id="load-more" class="load-more">Loading more...</div>':""} - `,l.querySelectorAll(".item-row").forEach(s=>{s.addEventListener("click",()=>{const n=parseInt(s.getAttribute("data-id")||"0");b(n)})});const i=document.getElementById("load-more");i&&new IntersectionObserver(n=>{n[0].isIntersecting&&!a.loading&&a.hasMore&&P()},{threshold:.1}).observe(i)}async function b(t,e=!1){o=t;const i=a.items.find(s=>s._id===t);if(i&&(l.querySelectorAll(".item-row").forEach(s=>{const n=parseInt(s.getAttribute("data-id")||"0");s.classList.toggle("active",n===t),e&&n===t&&s.scrollIntoView({block:"nearest"})}),p.innerHTML=` - <article class="item-detail"> - <header> - <h1><a href="${i.url}" target="_blank">${i.title}</a></h1> - <div class="item-meta"> - From ${i.feed_title||"Unknown"} on ${new Date(i.publish_date).toLocaleString()} - </div> - <div class="item-actions"> - <button onclick="window.app.toggleStar(${i._id})">${i.starred?"★ Unstar":"☆ Star"}</button> - <button onclick="window.app.toggleRead(${i._id})">${i.read?"Unread":"Read"}</button> - </div> - </header> - <div id="full-content" class="full-content"> - ${i.description||"No description available."} - </div> - </article> - `,i.read||f(i._id,{read:!0}),i.url&&(!i.full_content||i.full_content===i.description)))try{const s=await c(`/api/item/${i._id}/content`);if(s.ok){const n=await s.json();if(n.full_content){i.full_content=n.full_content;const r=document.getElementById("full-content");r&&(r.innerHTML=n.full_content)}}}catch(s){console.error("Failed to fetch full content",s)}}async function f(t,e){try{if((await c(`/api/item/${t}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)})).ok){const s=a.items.find(n=>n._id===t);if(s){Object.assign(s,e);const n=l.querySelector(`.item-row[data-id="${t}"]`);if(n&&e.read!==void 0&&n.classList.toggle("read",e.read),o===t){const r=p.querySelector(".item-actions button");r&&e.starred!==void 0&&(r.textContent=e.starred?"★ Unstar":"☆ Star")}}}}catch(i){console.error("Failed to update item",i)}}function M(){h.textContent="Settings",l.innerHTML="",p.innerHTML=` - <div class="settings-view"> - <h2>Settings</h2> - <section class="settings-section"> - <h3>Theme</h3> - <div class="theme-options"> - <button class="${a.theme==="light"?"active":""}" onclick="window.app.setTheme('light')">Light</button> - <button class="${a.theme==="dark"?"active":""}" onclick="window.app.setTheme('dark')">Dark</button> - </div> - </section> - <section class="settings-section"> - <h3>Font</h3> - <select onchange="window.app.setFontTheme(this.value)"> - <option value="default" ${a.fontTheme==="default"?"selected":""}>Default</option> - <option value="serif" ${a.fontTheme==="serif"?"selected":""}>Serif</option> - <option value="mono" ${a.fontTheme==="mono"?"selected":""}>Monospace</option> - </select> - </section> - </div> - `}async function N(){try{const t=await c("/api/feed/");if(!t.ok)throw new Error("Failed to fetch feeds");const e=await t.json();a.setFeeds(e)}catch(t){console.error(t)}}async function A(){try{const t=await c("/api/tag");if(!t.ok)throw new Error("Failed to fetch tags");const e=await t.json();a.setTags(e)}catch(t){console.error(t)}}async function m(t,e,i=!1){a.setLoading(!0);try{let s="/api/stream";const n=new URLSearchParams;t&&n.append("feed_id",t),e&&n.append("tag",e),a.searchQuery&&n.append("q",a.searchQuery),a.filter==="unread"&&n.append("read","false"),a.filter==="starred"&&n.append("starred","true"),i&&a.items.length>0&&n.append("max_id",String(a.items[a.items.length-1]._id));const r=await c(`${s}?${n.toString()}`);if(!r.ok)throw new Error("Failed to fetch items");const d=await r.json();a.setHasMore(d.length>=50),a.setItems(d,i),i||(o=null,p.innerHTML='<div class="empty-state">Select an item to read</div>')}catch(s){console.error(s),i||a.setItems([])}finally{a.setLoading(!1)}}async function P(){const t=u.getCurrentRoute();m(t.params.feedId,t.params.tagName,!0)}function g(){const t=u.getCurrentRoute(),e=t.query.get("filter");e&&["unread","all","starred"].includes(e)&&a.setFilter(e);const i=t.query.get("q");if(i!==null&&a.setSearchQuery(i),t.path==="/settings"){M();return}if(t.path==="/feed"&&t.params.feedId){const s=parseInt(t.params.feedId);a.setActiveFeed(s);const n=a.feeds.find(r=>r._id===s);h.textContent=n?n.title:`Feed ${s}`,m(t.params.feedId)}else t.path==="/tag"&&t.params.tagName?(a.setActiveTag(t.params.tagName),h.textContent=`Tag: ${t.params.tagName}`,m(void 0,t.params.tagName)):(a.setActiveFeed(null),a.setActiveTag(null),h.textContent="All Items",m())}window.addEventListener("keydown",t=>{if(!["INPUT","TEXTAREA"].includes(t.target.tagName))switch(t.key){case"j":I(1);break;case"k":I(-1);break;case"r":if(o){const e=a.items.find(i=>i._id===o);e&&f(e._id,{read:!e.read})}break;case"s":if(o){const e=a.items.find(i=>i._id===o);e&&f(e._id,{starred:!e.starred})}break;case"/":t.preventDefault(),document.getElementById("search-input")?.focus();break}});function I(t){if(a.items.length===0)return;let e=a.items.findIndex(i=>i._id===o);e+=t,e>=0&&e<a.items.length&&b(a.items[e]._id,!0)}a.on("feeds-updated",$);a.on("tags-updated",E);a.on("active-feed-updated",$);a.on("active-tag-updated",E);a.on("filter-updated",()=>{L(),g()});a.on("search-updated",()=>{const t=document.getElementById("search-input");t&&t.value!==a.searchQuery&&(t.value=a.searchQuery),g()});a.on("theme-updated",()=>{v.className=`theme-${a.theme} font-${a.fontTheme}`});a.on("items-updated",S);a.on("loading-state-changed",S);u.addEventListener("route-changed",g);window.app={navigate:t=>u.navigate(t),setFilter:t=>u.updateQuery({filter:t}),setSearch:t=>{u.updateQuery({q:t})},setTheme:t=>a.setTheme(t),setFontTheme:t=>a.setFontTheme(t),toggleStar:t=>{const e=a.items.find(i=>i._id===t);e&&f(t,{starred:!e.starred})},toggleRead:t=>{const e=a.items.find(i=>i._id===t);e&&f(t,{read:!e.read})},logout:async()=>{await c("/api/logout",{method:"POST"}),window.location.href="/login/"}};async function U(){if((await c("/api/auth")).status===401){window.location.href="/login/";return}L(),await Promise.all([N(),A()]),g()}U(); diff --git a/web/dist/v3/assets/index-eg2Tn_DA.js b/web/dist/v3/assets/index-eg2Tn_DA.js new file mode 100644 index 0000000..575654a --- /dev/null +++ b/web/dist/v3/assets/index-eg2Tn_DA.js @@ -0,0 +1,102 @@ +(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))i(s);new MutationObserver(s=>{for(const r of s)if(r.type==="childList")for(const o of r.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&i(o)}).observe(document,{childList:!0,subtree:!0});function a(s){const r={};return s.integrity&&(r.integrity=s.integrity),s.referrerPolicy&&(r.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?r.credentials="include":s.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function i(s){if(s.ep)return;s.ep=!0;const r=a(s);fetch(s.href,r)}})();function F(t){const a=`; ${document.cookie}`.split(`; ${t}=`);if(a.length===2)return a.pop()?.split(";").shift()}async function g(t,e){const a=e?.method?.toUpperCase()||"GET",i=["POST","PUT","DELETE"].includes(a),s=new Headers(e?.headers||{});if(i){const r=F("csrf_token");r&&s.set("X-CSRF-Token",r)}return fetch(t,{...e,headers:s,credentials:"include"})}class A extends EventTarget{feeds=[];tags=[];items=[];activeFeedId=null;activeTagName=null;filter="unread";searchQuery="";loading=!1;hasMore=!0;theme=localStorage.getItem("neko-theme")||"light";fontTheme=localStorage.getItem("neko-font-theme")||"default";setFeeds(e){this.feeds=e,this.emit("feeds-updated")}setTags(e){this.tags=e,this.emit("tags-updated")}setItems(e,a=!1){a?this.items=[...this.items,...e]:this.items=e,this.emit("items-updated")}setActiveFeed(e){this.activeFeedId=e,this.activeTagName=null,this.emit("active-feed-updated")}setActiveTag(e){this.activeTagName=e,this.activeFeedId=null,this.emit("active-tag-updated")}setFilter(e){this.filter!==e&&(this.filter=e,this.emit("filter-updated"))}setSearchQuery(e){this.searchQuery!==e&&(this.searchQuery=e,this.emit("search-updated"))}setLoading(e){this.loading=e,this.emit("loading-state-changed")}setHasMore(e){this.hasMore=e}setTheme(e){this.theme=e,localStorage.setItem("neko-theme",e),this.emit("theme-updated")}setFontTheme(e){this.fontTheme=e,localStorage.setItem("neko-font-theme",e),this.emit("theme-updated")}emit(e,a){this.dispatchEvent(new CustomEvent(e,{detail:a}))}on(e,a){this.addEventListener(e,a)}}const n=new A;class R extends EventTarget{constructor(){super(),window.addEventListener("popstate",()=>this.handleRouteChange())}handleRouteChange(){this.dispatchEvent(new CustomEvent("route-changed",{detail:this.getCurrentRoute()}))}getCurrentRoute(){const e=new URL(window.location.href),i=e.pathname.replace(/^\/v3\//,"").split("/").filter(Boolean);let s="/";const r={};return i[0]==="feed"&&i[1]?(s="/feed",r.feedId=i[1]):i[0]==="tag"&&i[1]&&(s="/tag",r.tagName=decodeURIComponent(i[1])),{path:s,params:r,query:e.searchParams}}navigate(e,a){let i=`/v3${e}`;if(a){const s=new URLSearchParams(a);i+=`?${s.toString()}`}window.history.pushState({},"",i),this.handleRouteChange()}updateQuery(e){const a=new URL(window.location.href);for(const[i,s]of Object.entries(e))s?a.searchParams.set(i,s):a.searchParams.delete(i);window.history.pushState({},"",a.toString()),this.handleRouteChange()}}const d=new R;function B(t){const e=new Date(t.publish_date).toLocaleDateString();return` + <li class="feed-item ${t.read?"read":"unread"}" data-id="${t._id}"> + <div class="item-header"> + <a href="${t.url}" target="_blank" rel="noopener noreferrer" class="item-title" data-action="open"> + ${t.title||"(No Title)"} + </a> + <button class="star-btn ${t.starred?"is-starred":"is-unstarred"}" title="${t.starred?"Unstar":"Star"}" data-action="toggle-star"> + ★ + </button> + </div> + <div class="dateline"> + <a href="${t.url}" target="_blank" rel="noopener noreferrer"> + ${e} + ${t.feed_title?` - ${t.feed_title}`:""} + </a> + <div class="item-actions" style="display: inline-block; float: right;"> + ${t.full_content?"":` + <button class="scrape-btn" title="Load Full Content" data-action="scrape"> + text + </button> + `} + </div> + </div> + ${t.full_content||t.description?` + <div class="item-description"> + ${t.full_content||t.description} + </div> + `:""} + </li> + `}let c=null,f=null;function C(){f=document.querySelector("#app"),f&&(f.className=`theme-${n.theme} font-${n.fontTheme}`,f.innerHTML=` + <div class="layout"> + <aside class="sidebar" id="sidebar"> + <div class="sidebar-header"> + <h2 id="logo-link">Neko v3</h2> + </div> + <div class="sidebar-search"> + <input type="search" id="search-input" placeholder="Search..." value="${n.searchQuery}"> + </div> + <div class="sidebar-scroll"> + <section class="sidebar-section"> + <h3>Filters</h3> + <ul id="filter-list"> + <li class="filter-item" data-filter="unread"><a href="/v3/?filter=unread" data-nav="filter" data-value="unread">Unread</a></li> + <li class="filter-item" data-filter="all"><a href="/v3/?filter=all" data-nav="filter" data-value="all">All</a></li> + <li class="filter-item" data-filter="starred"><a href="/v3/?filter=starred" data-nav="filter" data-value="starred">Starred</a></li> + </ul> + </section> + <section class="sidebar-section"> + <h3>Tags</h3> + <ul id="tag-list"></ul> + </section> + <section class="sidebar-section"> + <h3>Feeds</h3> + <ul id="feed-list"></ul> + </section> + </div> + <div class="sidebar-footer"> + <a href="/v3/settings" id="settings-link">Settings</a> + <a href="#" id="logout-button">Logout</a> + </div> + </aside> + <main class="main-content" id="main-content"> + <div id="content-area"></div> + </main> + </div> + `,N())}function N(){document.getElementById("search-input")?.addEventListener("input",o=>{const h=o.target.value;d.updateQuery({q:h})}),document.getElementById("logo-link")?.addEventListener("click",()=>d.navigate("/")),document.getElementById("logout-button")?.addEventListener("click",o=>{o.preventDefault(),U()}),document.getElementById("settings-link")?.addEventListener("click",o=>{o.preventDefault(),d.navigate("/settings")}),document.getElementById("sidebar")?.addEventListener("click",o=>{const l=o.target.closest("a");if(!l)return;const p=l.getAttribute("data-nav");if(p==="filter"){o.preventDefault();const m=l.getAttribute("data-value");d.updateQuery({filter:m})}else if(p==="tag"){o.preventDefault();const m=l.getAttribute("data-value");d.navigate(`/tag/${encodeURIComponent(m)}`)}else if(p==="feed"){o.preventDefault();const m=l.getAttribute("data-value");d.navigate(`/feed/${m}`)}}),document.getElementById("content-area")?.addEventListener("click",o=>{const h=o.target,l=h.closest('[data-action="toggle-star"]');if(l){const u=l.closest("[data-id]");if(u){const v=parseInt(u.getAttribute("data-id"));M(v)}return}const p=h.closest('[data-action="scrape"]');if(p){const u=p.closest("[data-id]");if(u){const v=parseInt(u.getAttribute("data-id"));P(v)}return}const m=h.closest('[data-action="open"]'),L=h.closest(".feed-item");if(L&&!m){const u=parseInt(L.getAttribute("data-id")),v=n.items.find(_=>_._id===u);v&&!v.read&&y(u,{read:!0})}})}function E(){const{feeds:t,activeFeedId:e}=n,a=document.getElementById("feed-list");a&&(a.innerHTML=t.map(i=>` + <li class="${i._id===e?"active":""}"> + <a href="/v3/feed/${i._id}" data-nav="feed" data-value="${i._id}"> + ${i.title||i.url} + </a> + </li> + `).join(""))}function $(){const{tags:t,activeTagName:e}=n,a=document.getElementById("tag-list");a&&(a.innerHTML=t.map(i=>` + <li class="${i.title===e?"active":""}"> + <a href="/v3/tag/${encodeURIComponent(i.title)}" data-nav="tag" data-value="${i.title}"> + ${i.title} + </a> + </li> + `).join(""))}function k(){const{filter:t}=n,e=document.getElementById("filter-list");e&&e.querySelectorAll("li").forEach(a=>{a.classList.toggle("active",a.getAttribute("data-filter")===t)})}function w(){const{items:t,loading:e}=n,a=document.getElementById("content-area");if(!a||d.getCurrentRoute().path==="/settings")return;if(e&&t.length===0){a.innerHTML='<p class="loading">Loading items...</p>';return}if(t.length===0){a.innerHTML='<p class="empty">No items found.</p>';return}a.innerHTML=` + <ul class="item-list"> + ${t.map(s=>B(s)).join("")} + </ul> + ${n.hasMore?'<div id="load-more-sentinel" class="loading-more">Loading more...</div>':""} + `;const i=document.getElementById("load-more-sentinel");i&&new IntersectionObserver(r=>{r[0].isIntersecting&&!n.loading&&n.hasMore&&O()},{threshold:.1}).observe(i)}function S(){const t=document.getElementById("content-area");if(!t)return;t.innerHTML=` + <div class="settings-view"> + <h2>Settings</h2> + <section class="settings-section"> + <h3>Theme</h3> + <div class="theme-options" id="theme-options"> + <button class="${n.theme==="light"?"active":""}" data-theme="light">Light</button> + <button class="${n.theme==="dark"?"active":""}" data-theme="dark">Dark</button> + </div> + </section> + <section class="settings-section"> + <h3>Font</h3> + <select id="font-selector"> + <option value="default" ${n.fontTheme==="default"?"selected":""}>Default (Serif)</option> + <option value="serif" ${n.fontTheme==="serif"?"selected":""}>Serif (Georgia)</option> + <option value="mono" ${n.fontTheme==="mono"?"selected":""}>Monospace</option> + </select> + </section> + </div> + `,document.getElementById("theme-options")?.addEventListener("click",i=>{const s=i.target.closest("button");if(s){const r=s.getAttribute("data-theme");n.setTheme(r),S()}});const a=document.getElementById("font-selector");a?.addEventListener("change",()=>{n.setFontTheme(a.value)})}async function M(t){const e=n.items.find(a=>a._id===t);e&&y(t,{starred:!e.starred})}async function P(t){if(n.items.find(a=>a._id===t))try{const a=await g(`/api/item/${t}/content`);if(a.ok){const i=await a.json();i.full_content&&y(t,{full_content:i.full_content})}}catch(a){console.error("Failed to fetch full content",a)}}async function y(t,e){try{if((await g(`/api/item/${t}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)})).ok){const i=n.items.find(s=>s._id===t);if(i){Object.assign(i,e);const s=document.querySelector(`.feed-item[data-id="${t}"]`);if(s){if(e.read!==void 0&&s.classList.toggle("read",e.read),e.starred!==void 0){const r=s.querySelector(".star-btn");r&&(r.classList.toggle("is-starred",e.starred),r.classList.toggle("is-unstarred",!e.starred),r.setAttribute("title",e.starred?"Unstar":"Star"))}e.full_content&&w()}}}}catch(a){console.error("Failed to update item",a)}}async function Q(){const t=await g("/api/feed/");if(t.ok){const e=await t.json();n.setFeeds(e)}}async function q(){const t=await g("/api/tag");if(t.ok){const e=await t.json();n.setTags(e)}}async function b(t,e,a=!1){n.setLoading(!0);try{const i=new URLSearchParams;t&&i.append("feed_id",t),e&&i.append("tag",e),n.searchQuery&&i.append("q",n.searchQuery),n.filter==="unread"&&i.append("read","false"),n.filter==="starred"&&i.append("starred","true"),a&&n.items.length>0&&i.append("max_id",String(n.items[n.items.length-1]._id));const s=await g(`/api/stream?${i.toString()}`);if(s.ok){const r=await s.json();n.setHasMore(r.length>=50),n.setItems(r,a)}}finally{n.setLoading(!1)}}async function O(){const t=d.getCurrentRoute();b(t.params.feedId,t.params.tagName,!0)}async function U(){await g("/api/logout",{method:"POST"}),window.location.href="/login/"}function I(){const t=d.getCurrentRoute(),e=t.query.get("filter");e&&["unread","all","starred"].includes(e)&&n.setFilter(e);const a=t.query.get("q");if(a!==null&&n.setSearchQuery(a),t.path==="/settings"){S();return}if(t.path==="/feed"&&t.params.feedId){const i=parseInt(t.params.feedId);n.setActiveFeed(i),b(t.params.feedId)}else t.path==="/tag"&&t.params.tagName?(n.setActiveTag(t.params.tagName),b(void 0,t.params.tagName)):(n.setActiveFeed(null),n.setActiveTag(null),b())}window.addEventListener("keydown",t=>{if(!["INPUT","TEXTAREA"].includes(t.target.tagName))switch(t.key){case"j":T(1);break;case"k":T(-1);break;case"r":if(c){const e=n.items.find(a=>a._id===c);e&&y(e._id,{read:!e.read})}break;case"s":if(c){const e=n.items.find(a=>a._id===c);e&&y(e._id,{starred:!e.starred})}break;case"/":t.preventDefault(),document.getElementById("search-input")?.focus();break}});function T(t){if(n.items.length===0)return;let e=n.items.findIndex(a=>a._id===c);if(e+=t,e>=0&&e<n.items.length){c=n.items[e]._id;const a=document.querySelector(`.feed-item[data-id="${c}"]`);a&&a.scrollIntoView({block:"nearest"}),n.items[e].read||y(c,{read:!0})}else if(e===-1){c=n.items[0]._id;const a=document.querySelector(`.feed-item[data-id="${c}"]`);a&&a.scrollIntoView({block:"nearest"})}}n.on("feeds-updated",E);n.on("tags-updated",$);n.on("active-feed-updated",E);n.on("active-tag-updated",$);n.on("filter-updated",()=>{k(),I()});n.on("search-updated",()=>{const t=document.getElementById("search-input");t&&t.value!==n.searchQuery&&(t.value=n.searchQuery),I()});n.on("theme-updated",()=>{f||(f=document.querySelector("#app")),f&&(f.className=`theme-${n.theme} font-${n.fontTheme}`)});n.on("items-updated",w);n.on("loading-state-changed",w);d.addEventListener("route-changed",I);window.app={navigate:t=>d.navigate(t)};async function j(){const t=await g("/api/auth");if(!t||t.status===401){window.location.href="/login/";return}C(),k();try{await Promise.all([Q(),q()])}catch(e){console.error("Initial fetch failed",e)}I()}typeof window<"u"&&!window.__VITEST__&&j(); diff --git a/web/dist/v3/index.html b/web/dist/v3/index.html index 616f437..7705abf 100644 --- a/web/dist/v3/index.html +++ b/web/dist/v3/index.html @@ -5,8 +5,8 @@ <link rel="icon" type="image/svg+xml" href="/vite.svg" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>frontend-vanilla</title> - <script type="module" crossorigin src="/v3/assets/index-FNdWoCuA.js"></script> - <link rel="stylesheet" crossorigin href="/v3/assets/index-CPnxXrEk.css"> + <script type="module" crossorigin src="/v3/assets/index-eg2Tn_DA.js"></script> + <link rel="stylesheet" crossorigin href="/v3/assets/index-DWEqmxLr.css"> </head> <body> <div id="app"></div> |
