diff options
| -rw-r--r-- | .claude/claude.json | 1 | ||||
| -rw-r--r-- | .thicket/tickets.jsonl | 12 | ||||
| -rw-r--r-- | api/api_test.go | 88 | ||||
| -rw-r--r-- | frontend-vanilla/src/style.css | 10 | ||||
| -rw-r--r-- | models/item/item.go | 43 | ||||
| -rw-r--r-- | models/item/item_test.go | 93 | ||||
| -rw-r--r-- | web/dist/v3/assets/index-BV0ir9Wv.css | 1 | ||||
| -rw-r--r-- | web/dist/v3/assets/index-Cqcte0U4.css | 1 | ||||
| -rw-r--r-- | web/dist/v3/assets/index-CyyR50mK.js (renamed from web/dist/v3/assets/index-M5xszonw.js) | 16 | ||||
| -rw-r--r-- | web/dist/v3/index.html | 4 | ||||
| -rw-r--r-- | web/login_test.go | 86 | ||||
| -rw-r--r-- | web/routing_test.go | 7 |
12 files changed, 327 insertions, 35 deletions
diff --git a/.claude/claude.json b/.claude/claude.json new file mode 100644 index 0000000..a39d98a --- /dev/null +++ b/.claude/claude.json @@ -0,0 +1 @@ +{ "hooks": [ { "type": "startup", "matcher": ".*", "action": "run", "command": "test -f ./thicket || (git clone https://github.com/abarth/thicket.git && cd thicket && go build -o thicket ./cmd/thicket && cd ..)" } ] } diff --git a/.thicket/tickets.jsonl b/.thicket/tickets.jsonl index e7690e5..3e2d7fa 100644 --- a/.thicket/tickets.jsonl +++ b/.thicket/tickets.jsonl @@ -36,7 +36,7 @@ {"id":"NK-8rhpp3","title":"v2 frontend: when selected, don't change style of feed items","description":"Just leave them the same when j/k \"selects\" an item. No blue side thing, no change in background, it's distracting. Just scroll it to the right place.","type":"bug","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-14T00:39:50.01934312Z","updated":"2026-02-14T01:02:54.204739756Z"} {"id":"NK-8s75ec","title":"page size and performance","description":"Do some analysis of page size (css/html/javascript) on the legacy version vs. new version and give me a report. We want it to be small and fast! If the new version is much worse file some tickets to investigate further.","type":"task","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-13T20:16:13.898081788Z","updated":"2026-02-13T21:50:12.004391671Z"} {"id":"NK-8u3uo3","title":"Add configurable username support","description":"Currently login accepts a username field but ignores it. We should allow configuring a username (defaulting to 'neko') and validate it during login.","type":"task","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-15T05:16:55.257993616Z","updated":"2026-02-15T05:16:55.257993616Z"} -{"id":"NK-967mx5","title":"Add configurable scroll-to-read delay/debounce","description":"Currently items are marked as read immediately when fully visible. Consider adding a short delay (e.g. 500ms) so fast scrolling doesn't mark items as read. This would be a UX enhancement building on NK-s2g59a.","type":"feature","status":"open","priority":3,"labels":null,"assignee":"","created":"2026-02-16T19:09:49.041731485Z","updated":"2026-02-16T19:09:49.041731485Z"} +{"id":"NK-967mx5","title":"Add configurable scroll-to-read delay/debounce","description":"Currently items are marked as read immediately when fully visible. Consider adding a short delay (e.g. 500ms) so fast scrolling doesn't mark items as read. This would be a UX enhancement building on NK-s2g59a.","type":"","status":"closed","priority":3,"labels":null,"assignee":"","created":"2026-02-16T19:09:49.041731485Z","updated":"2026-02-16T23:27:09.944951714Z"} {"id":"NK-9hx0y7","title":"Implement Frontend Login","description":"Create login page and auth logic in the new React frontend. Port functionality from legacy login.html.","type":"","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-13T05:44:01.546395342Z","updated":"2026-02-13T05:50:33.877452063Z"} {"id":"NK-9pgjph","title":"v2 ui - font size 18px","description":"Compare your font sizes with the legacy version -- I think they're a little too small (16 vs 18 baseline)","type":"bug","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-14T03:21:48.453217898Z","updated":"2026-02-14T03:24:25.316927886Z"} {"id":"NK-9vquj9","title":"v3 login","description":"Login still doesn't work right -- i'm assuming the CSRF stuff is still wrong","type":"bug","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-16T16:24:06.315635122Z","updated":"2026-02-16T16:50:26.432014927Z"} @@ -65,6 +65,7 @@ {"id":"NK-ed1iah","title":"Make feed crawling async in API","description":"Currently, POST /api/feed triggers an immediate crawl which blocks the response (or at least keeps the goroutine alive). Refactor the crawling architecture to be truly async with a job queue or status updates, improving API responsiveness and reliability.","type":"cleanup","status":"icebox","priority":4,"labels":null,"assignee":"","created":"2026-02-13T04:26:55.908243985Z","updated":"2026-02-13T04:26:55.908243985Z"} {"id":"NK-edahin","title":"v3: cut \"FILTERS\" text","description":"there's no header needed above unread/read/starred, just cut that please","type":"task","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-16T15:48:13.470233363Z","updated":"2026-02-16T16:21:21.828382936Z"} {"id":"NK-ek0cox","title":"Implement Item Interactions","description":"Add ability to toggle read/unread and star/unstar status for items. Use PUT /item/:id","type":"","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-13T14:55:14.825454967Z","updated":"2026-02-13T14:58:18.307521003Z"} +{"id":"NK-ekxfvv","title":"Seed full_content in benchmarks to measure real payload savings","description":"The stream benchmark seeds items without full_content, so B/op doesn't reflect the real-world improvement from excluding full_content. Add seeded full_content to benchmark items so we can quantify the actual memory/payload savings.","type":"task","status":"open","priority":4,"labels":null,"assignee":"","created":"2026-02-16T23:03:28.808266923Z","updated":"2026-02-16T23:03:28.808266923Z"} {"id":"NK-eqduq1","title":"run the performance test suite and provide some benchmarks","description":"Create a document in DOCS after running the perf test suite with findings and potential improvements.","type":"task","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-16T19:48:08.40974488Z","updated":"2026-02-16T22:05:48.66181541Z"} {"id":"NK-exyau3","title":"check coverage again","description":"check test coverage again and see if more tests are needed","type":"task","status":"closed","priority":3,"labels":null,"assignee":"","created":"2026-02-15T00:07:39.320521992Z","updated":"2026-02-15T05:31:22.48357749Z"} {"id":"NK-f64ocp","title":"Refactor routing logic for consistent settings exit","description":"Instead of checking 'if currentRoute.path == /settings' in every click handler, maybe centralize this logic in the router or a helper function to ensure consistent behavior when navigating away from settings.","type":"cleanup","status":"open","priority":4,"labels":null,"assignee":"","created":"2026-02-16T16:17:36.299928698Z","updated":"2026-02-16T16:17:36.299928698Z"} @@ -80,7 +81,7 @@ {"id":"NK-gdf99z","title":"TUI is terrible and needs fixing","description":"The TUI doesn't really work and doesn't make sense. Think very hard and look at the v2 HTML UI implementation and make something cool like that. Probably needs tests too.","type":"epic","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-14T03:51:59.882212859Z","updated":"2026-02-14T04:31:28.290051717Z"} {"id":"NK-gfh33y","title":"[security] Implement CSRF Protection for API","description":"Add CSRF protection to all state-changing API endpoints. 1. Implement a middleware that generates a CSRF token and sets it in a cookie (readable by JS) or header. 2. Update the AuthWrap middleware to validate the presence of this token in the X-CSRF-Token header for all unsafe methods (POST, PUT, DELETE). 3. Update the React frontend to read the token and include it in all API requests.","type":"","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-14T16:35:56.341543505Z","updated":"2026-02-14T17:08:53.079904915Z"} {"id":"NK-ghpdkr","title":"Add coverage check to 'make check'","description":"Add a code coverage check to the 'make check' workflow, ensuring it doesn't significantly slow down execution. This replaces ad-hoc coverage checks.","type":"task","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-15T16:27:42.348183236Z","updated":"2026-02-15T17:26:33.764005669Z"} -{"id":"NK-gjj92c","title":"Add tablet breakpoint CSS for sidebar (1024px)","description":"The sidebar cookie persistence uses 1024px as the tablet/mobile threshold for default-closed behavior, but the CSS media queries still use 768px for mobile sidebar styles. Consider adding a tablet breakpoint at 1024px for intermediate layout behavior.","type":"task","status":"open","priority":3,"labels":null,"assignee":"","created":"2026-02-16T22:08:13.765775664Z","updated":"2026-02-16T22:08:13.765775664Z"} +{"id":"NK-gjj92c","title":"Add tablet breakpoint CSS for sidebar (1024px)","description":"The sidebar cookie persistence uses 1024px as the tablet/mobile threshold for default-closed behavior, but the CSS media queries still use 768px for mobile sidebar styles. Consider adding a tablet breakpoint at 1024px for intermediate layout behavior.","type":"","status":"closed","priority":3,"labels":null,"assignee":"","created":"2026-02-16T22:08:13.765775664Z","updated":"2026-02-16T23:28:34.541451654Z"} {"id":"NK-gjymiw","title":"FEEDS in sidebar needs to be more clearly a collapsible thing","description":"FEEDS in the sidebar needs a caret (\u003e that turns to V) or something similar to make it clear it's a toggle collapsible list thing","type":"feature","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-14T23:54:20.355824171Z","updated":"2026-02-15T00:06:02.456524674Z"} {"id":"NK-gnxc6e","title":"Feed list collapsed by default","description":"The list of feeds on the left side should be collapsed by default, with a little control to extend it.","type":"feature","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-14T00:58:15.661695308Z","updated":"2026-02-14T01:29:12.82081713Z"} {"id":"NK-gqkh96","title":"Remaining test coverage gaps","description":"Cross-package test coverage is at 81.2%. The remaining untested functions are: GetFullContent (goose HTTP extraction), indexHandler/serveBoxedFile (rice.MustFindBox), Serve (starts HTTP server), main, util.init. To reach 90%, consider: (1) refactoring GetFullContent to accept an interface for HTTP fetching, (2) refactoring Serve to extract route setup into a testable function, (3) mocking rice.MustFindBox, (4) using feeds from https://trenchant.org/feeds.txt as static test fixtures for integration tests.","type":"cleanup","status":"closed","priority":3,"labels":null,"assignee":"","created":"2026-02-13T03:54:30.298141982Z","updated":"2026-02-14T02:44:05.399097286Z"} @@ -102,7 +103,7 @@ {"id":"NK-k1p9ij","title":"Evaluate E2E test harness","description":"Evaluate the current E2E test harness (Cleaning up test environment...\nCleanup complete.\nBuilding backend...\nCreating data directory...\nStarting mock feed server on port 9090...\nMock Server PID: 166494\nMock server is up.\nStarting backend on port 4994...\nBackend PID: 166520\nWaiting for backend to start...\nWaiting...\nBackend is up!\nRunning E2E tests...\n\n\u003e frontend@0.0.0 test:e2e\n\u003e playwright test\n\n\nRunning 13 tests using 1 worker\n\n\u001b[1A\u001b[2K[1/13] [chromium] › tests/auth.spec.ts:12:5 › Authentication - No Password Required › should allow direct access to dashboard without login\n\u001b[1A\u001b[2K[2/13] [chromium] › tests/auth.spec.ts:25:5 › Authentication - No Password Required › should allow login with empty password\n\u001b[1A\u001b[2K[3/13] [chromium] › tests/auth.spec.ts:38:5 › Authentication - No Password Required › should report authenticated status via API when no password\n\u001b[1A\u001b[2K[4/13] [chromium] › tests/auth.spec.ts:53:10 › Authentication - Password Required › should redirect to login when accessing protected routes\n\u001b[1A\u001b[2K[5/13] [chromium] › tests/auth.spec.ts:64:10 › Authentication - Password Required › should reject incorrect password\n\u001b[1A\u001b[2K[6/13] [chromium] › tests/auth.spec.ts:79:10 › Authentication - Password Required › should accept correct password and redirect to dashboard\n\u001b[1A\u001b[2K[7/13] [chromium] › tests/auth.spec.ts:93:10 › Authentication - Password Required › should persist authentication across page reloads\n\u001b[1A\u001b[2K[8/13] [chromium] › tests/auth.spec.ts:109:10 › Authentication - Password Required › should logout and redirect to login page\n\u001b[1A\u001b[2K[9/13] [chromium] › tests/auth.spec.ts:128:10 › Authentication - Password Required › should report unauthenticated status via API\n\u001b[1A\u001b[2K[10/13] [chromium] › tests/auth.spec.ts:142:5 › Authentication - Complete Flow › should handle complete user flow without password\n\u001b[1A\u001b[2K[11/13] [chromium] › tests/crawl.spec.ts:4:5 › Crawl Integration › should add a feed and see items after crawl\n\u001b[1A\u001b[2K[12/13] [chromium] › tests/e2e.spec.ts:4:3 › Neko Reader E2E › should allow login, viewing feeds, and logout\n\u001b[1A\u001b[2K[chromium] › tests/e2e.spec.ts:4:3 › Neko Reader E2E › should allow login, viewing feeds, and logout\nStep 5: Navigate to Home\n\n\u001b[1A\u001b[2KStep 6: Logout\n\n\u001b[1A\u001b[2K[13/13] [chromium] › tests/font.spec.ts:4:5 › Font Theme Settings › should change font family when theme starts\n\u001b[1A\u001b[2K 6 skipped\n 7 passed (5.2s)\n\nTo open last HTML report run:\n\u001b[36m\u001b[39m\n\u001b[36m npx playwright show-report\u001b[39m\n\u001b[36m\u001b[39m\nTests passed!\nCleaning up...\nCleaning up test environment...\nCleanup complete.) which is brittle and resource intensive. Consider alternative approaches or refactoring.","type":"task","status":"closed","priority":4,"labels":null,"assignee":"","created":"2026-02-15T16:23:52.252436663Z","updated":"2026-02-15T19:14:31.772890559Z"} {"id":"NK-k2fh32","title":"scroll mark as read broken in V3 UI","description":"j/k properly select and mark an item as read, but scrolling it off the screen on desktop and mobile don't seem to mark it as read.","type":"bug","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-16T15:32:42.579032595Z","updated":"2026-02-16T15:37:20.993241382Z"} {"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-k9otuy","title":"Exclude full_content from stream list API responses","description":"Benchmarks show ~380KB/op allocation for stream endpoint, largely from serializing full_content field that is not displayed in list views. Excluding this field from list responses would reduce memory allocation and improve API response times. Full content is already available via the /api/item/:id/content scrape endpoint.","type":"task","status":"open","priority":3,"labels":null,"assignee":"","created":"2026-02-16T22:08:15.014135834Z","updated":"2026-02-16T22:08:15.014135834Z"} +{"id":"NK-k9otuy","title":"Exclude full_content from stream list API responses","description":"Benchmarks show ~380KB/op allocation for stream endpoint, largely from serializing full_content field that is not displayed in list views. Excluding this field from list responses would reduce memory allocation and improve API response times. Full content is already available via the /api/item/:id/content scrape endpoint.","type":"","status":"closed","priority":3,"labels":null,"assignee":"","created":"2026-02-16T22:08:15.014135834Z","updated":"2026-02-16T23:03:03.193816656Z"} {"id":"NK-kdn1m2","title":"Optimize observer creation in renderItems","description":"We currently recreate IntersectionObservers every time renderItems is called. We should investigate if this impacts performance and potentially reuse the observer or optimize the logic.","type":"task","status":"open","priority":4,"labels":null,"assignee":"","created":"2026-02-16T15:37:41.915993277Z","updated":"2026-02-16T15:37:41.915993277Z"} {"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"} @@ -114,7 +115,7 @@ {"id":"NK-mgmn5m","title":"serve \"legacy\" version UI at /v1/ instead of /","description":"Let's \"softly\" start to deprecated the legacy version by moving it to /v1/ -- ideally this won't require any changes but there may be some relative/absolute URLs to adjust in the static files there or in rouoting","type":"task","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-14T16:41:04.710679944Z","updated":"2026-02-14T17:38:25.35292336Z"} {"id":"NK-mpb1e1","title":"Mock RSS feeds in E2E tests","description":"Currently E2E tests fetch real feeds like CNN, which makes them slow (10s+) and potentially flaky depending on network. Use a local mock server or file-based mocks.","type":"task","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-14T23:43:56.494457169Z","updated":"2026-02-15T02:17:12.5439427Z"} {"id":"NK-mwf9q2","title":"Implement Tag View","description":"Create frontend view for browsing items by tag/category. Use /tag/:id endpoint.","type":"","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-13T15:04:12.441165286Z","updated":"2026-02-13T18:04:38.644796168Z"} -{"id":"NK-mx8o1b","title":"review recent tickets / CLs and write tests","description":"Given the key behaviors/features/bugs are there any additional tests needed -- don't worry about e2e tests.","type":"epic","status":"open","priority":3,"labels":null,"assignee":"","created":"2026-02-16T16:01:44.60710814Z","updated":"2026-02-16T16:01:44.60710814Z"} +{"id":"NK-mx8o1b","title":"review recent tickets / CLs and write tests","description":"Given the key behaviors/features/bugs are there any additional tests needed -- don't worry about e2e tests.","type":"","status":"closed","priority":3,"labels":null,"assignee":"","created":"2026-02-16T16:01:44.60710814Z","updated":"2026-02-16T23:31:41.92974498Z"} {"id":"NK-n7nuyy","title":"Fix TypeScript Lint Errors in Tests","description":"There are lint errors in test files regarding jest-dom matchers (toBeInTheDocument, etc). Ensure proper types are included.","type":"bug","status":"closed","priority":3,"labels":null,"assignee":"","created":"2026-02-13T21:50:15.140702806Z","updated":"2026-02-13T21:50:15.140702806Z"} {"id":"NK-ngokc3","title":"Enhance CSRF protection for login page","description":"The /login/ endpoint is currently excluded from CSRF checks to allow standard form submission. We should explore adding a hidden CSRF token to the form or using Javascript to submit to /api/login for better protection.","type":"","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-16T15:56:34.617681973Z","updated":"2026-02-16T22:02:51.841992054Z"} {"id":"NK-nx8dhw","title":"fix github ci","description":"seems to be broken, are the right things still in there given all the refactorings","type":"bug","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-14T18:02:19.485418738Z","updated":"2026-02-14T18:08:16.790742187Z"} @@ -159,6 +160,7 @@ {"id":"NK-uy90he","title":"UI Styling: Feed Items (Spacing, Dateline)","description":"","type":"","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-13T18:05:17.289457994Z","updated":"2026-02-13T18:11:46.255816698Z"} {"id":"NK-uywybr","title":"https://computer.rip/rss.xml fails to importa","description":"running neko -a https://computer.rip/rss.xml gave an error. debug it and add test case to catch.","type":"bug","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-13T20:12:28.54350403Z","updated":"2026-02-14T01:03:02.755247954Z"} {"id":"NK-v9e7r3","title":"consistency in sidebar","description":"With the new sidebar styling, SETTINGS and LOGOUT and the light/dark look really different than the rest. Let's make them more consistent from a style perspective.","type":"feature","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-14T23:55:51.554786606Z","updated":"2026-02-15T00:22:33.826814528Z"} +{"id":"NK-vztuud","title":"Sanitize HTML at ingest time instead of on every read","description":"The Filter function runs bluemonday sanitization on every query result. Moving sanitization to item creation/crawl time would reduce per-request CPU cost, especially for frequently-read items.","type":"task","status":"open","priority":4,"labels":null,"assignee":"","created":"2026-02-16T23:03:28.861761558Z","updated":"2026-02-16T23:03:28.861761558Z"} {"id":"NK-wibjlg","title":"update README.md","description":"Ensure the build, configuration, etc are up too date.\nNote the git change when we started to vibe-code this in the history (with dates etc.)","type":"task","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-13T20:18:08.790048498Z","updated":"2026-02-13T22:36:07.717448961Z"} {"id":"NK-wj8m4v","title":"Fix pre-existing safehttp test failure","description":"TestSafeClient in internal/safehttp fails: private IP check returns nil error instead of blocking. The test expects an error for 10.0.0.1 but gets nil.","type":"","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-16T19:09:38.775620083Z","updated":"2026-02-16T22:07:45.904161737Z"} {"id":"NK-wjats7","title":"v3 ui: takes 3 presses of 'j' to move to next item","description":"Is there some weird timer before it's scrolling it -- after that it's fine. Or a delay to setup the listener or something","type":"bug","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-16T16:20:01.7097349Z","updated":"2026-02-16T18:53:59.531388427Z"} @@ -204,6 +206,7 @@ {"id":"NK-dk4vq5o","from_ticket_id":"NK-uy90he","to_ticket_id":"NK-mwf9q2","type":"created_from","created":"2026-02-13T18:05:17.305248863Z"} {"id":"NK-dkhu8ov","from_ticket_id":"NK-mwf9q2","to_ticket_id":"NK-0ppv3f","type":"created_from","created":"2026-02-13T15:04:12.449401674Z"} {"id":"NK-dl8clj9","from_ticket_id":"NK-0nf7hu","to_ticket_id":"NK-9hx0y7","type":"created_from","created":"2026-02-13T05:50:46.769436228Z"} +{"id":"NK-dlg1gco","from_ticket_id":"NK-ekxfvv","to_ticket_id":"NK-k9otuy","type":"created_from","created":"2026-02-16T23:03:28.824814398Z"} {"id":"NK-dlvmiyc","from_ticket_id":"NK-7tzbql","to_ticket_id":"NK-bsdwqz","type":"created_from","created":"2026-02-13T05:02:57.392616851Z"} {"id":"NK-dm35o6g","from_ticket_id":"NK-d4c8jv","to_ticket_id":"NK-chns2b","type":"created_from","created":"2026-02-14T04:46:32.151239137Z"} {"id":"NK-dm75oc8","from_ticket_id":"NK-3om7x2","to_ticket_id":"NK-zt4e32","type":"created_from","created":"2026-02-13T05:59:46.169842933Z"} @@ -215,6 +218,7 @@ {"id":"NK-dnw8qnj","from_ticket_id":"NK-qwef98","to_ticket_id":"NK-mwf9q2","type":"created_from","created":"2026-02-13T18:05:18.469080925Z"} {"id":"NK-do2cces","from_ticket_id":"NK-ngokc3","to_ticket_id":"NK-oqd24q","type":"created_from","created":"2026-02-16T15:56:34.653005025Z"} {"id":"NK-dofihuz","from_ticket_id":"NK-0ppv3f","to_ticket_id":"NK-t0nmbj","type":"created_from","created":"2026-02-13T05:44:01.640770816Z"} +{"id":"NK-drbilma","from_ticket_id":"NK-vztuud","to_ticket_id":"NK-k9otuy","type":"created_from","created":"2026-02-16T23:03:28.877255067Z"} {"id":"NK-ds03bw7","from_ticket_id":"NK-0oti10","to_ticket_id":"NK-pwogze","type":"created_from","created":"2026-02-14T20:44:37.268882219Z"} {"id":"NK-dspotwc","from_ticket_id":"NK-kdn1m2","to_ticket_id":"NK-k2fh32","type":"created_from","created":"2026-02-16T15:37:41.946633536Z"} {"id":"NK-dumpdcp","from_ticket_id":"NK-59kbij","to_ticket_id":"NK-ek0cox","type":"created_from","created":"2026-02-13T14:58:18.351925575Z"} diff --git a/api/api_test.go b/api/api_test.go index 010908a..a2c3415 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -445,6 +445,94 @@ func TestHandleStreamComplexFilters(t *testing.T) { } } +func TestStreamExcludesFullContent(t *testing.T) { + setupTestDB(t) + server := newTestServer() + + f := &feed.Feed{Url: "http://example.com/content", Title: "Content Feed"} + f.Create() + i := &item.Item{ + Title: "Content Item", + Url: "http://example.com/content/1", + FeedId: f.Id, + } + _ = i.Create() + // Simulate having full_content stored in DB + models.DB.Exec("UPDATE item SET full_content=? WHERE id=?", "<p>Full article text</p>", i.Id) + + // Stream response should NOT include full_content + req := httptest.NewRequest("GET", "/stream?read_filter=all", nil) + rr := httptest.NewRecorder() + server.HandleStream(rr, req) + + body := rr.Body.String() + if strings.Contains(body, "Full article text") { + t.Error("stream response should not contain full_content") + } + if strings.Contains(body, `"full_content"`) { + t.Error("stream response should not contain full_content key") + } +} + +func TestStreamReadFilterAll(t *testing.T) { + setupTestDB(t) + server := newTestServer() + + f := &feed.Feed{Url: "http://example.com/rf", Title: "RF Feed"} + f.Create() + i := &item.Item{Title: "Read Item", Url: "http://example.com/rf/1", FeedId: f.Id} + _ = i.Create() + i.ReadState = true + i.Save() + + // Default (unread only) should return 0 since item is read + req := httptest.NewRequest("GET", "/stream", nil) + rr := httptest.NewRecorder() + server.HandleStream(rr, req) + var items []item.Item + json.NewDecoder(rr.Body).Decode(&items) + if len(items) != 0 { + t.Errorf("Default stream should return 0 read items, got %d", len(items)) + } + + // read_filter=all should return the read item + req = httptest.NewRequest("GET", "/stream?read_filter=all", nil) + rr = httptest.NewRecorder() + server.HandleStream(rr, req) + json.NewDecoder(rr.Body).Decode(&items) + if len(items) != 1 { + t.Errorf("read_filter=all should return 1 item, got %d", len(items)) + } +} + +func TestStreamMultipleFeedIds(t *testing.T) { + setupTestDB(t) + server := newTestServer() + + f1 := &feed.Feed{Url: "http://example.com/mf1", Title: "Feed 1"} + f1.Create() + f2 := &feed.Feed{Url: "http://example.com/mf2", Title: "Feed 2"} + f2.Create() + f3 := &feed.Feed{Url: "http://example.com/mf3", Title: "Feed 3"} + f3.Create() + + (&item.Item{Title: "F1", Url: "http://example.com/mf1/1", FeedId: f1.Id}).Create() + (&item.Item{Title: "F2", Url: "http://example.com/mf2/1", FeedId: f2.Id}).Create() + (&item.Item{Title: "F3", Url: "http://example.com/mf3/1", FeedId: f3.Id}).Create() + + // Filter by feed_ids=1,2 + url := "/stream?feed_ids=" + strconv.FormatInt(f1.Id, 10) + "," + strconv.FormatInt(f2.Id, 10) + req := httptest.NewRequest("GET", url, nil) + rr := httptest.NewRecorder() + server.HandleStream(rr, req) + + var items []item.Item + json.NewDecoder(rr.Body).Decode(&items) + if len(items) != 2 { + t.Errorf("feed_ids filter should return 2 items, got %d", len(items)) + } +} + func TestHandleCategorySuccess(t *testing.T) { setupTestDB(t) server := newTestServer() diff --git a/frontend-vanilla/src/style.css b/frontend-vanilla/src/style.css index 37ba761..440bcca 100644 --- a/frontend-vanilla/src/style.css +++ b/frontend-vanilla/src/style.css @@ -206,7 +206,7 @@ html { transition: padding 0.3s ease; } -@media (max-width: 768px) { +@media (max-width: 1024px) { .main-content { padding: 1rem; padding-top: 4rem; @@ -264,7 +264,7 @@ html { z-index: 999; } -@media (max-width: 768px) { +@media (max-width: 1024px) { .sidebar-visible .sidebar-backdrop { display: block; } @@ -284,12 +284,10 @@ html { transform: translateX(0); box-shadow: 10px 0 20px rgba(0, 0, 0, 0.1); } - - /* Move toggle button with sidebar when visible - Removed to keep it fixed */ } /* Desktop Sidebar state */ -@media (min-width: 769px) { +@media (min-width: 1025px) { /* Desktop Sidebar state - Removed to keep toggle fixed */ } @@ -298,7 +296,7 @@ html { display: none; } -@media (min-width: 769px) { +@media (min-width: 1025px) { .sidebar-hidden .sidebar { display: none; } diff --git a/models/item/item.go b/models/item/item.go index 189cb4a..e1e740e 100644 --- a/models/item/item.go +++ b/models/item/item.go @@ -45,8 +45,8 @@ type Item struct { ReadState bool `json:"read"` Starred bool `json:"starred"` - FullContent string `json:"full_content"` - HeaderImage string `json:"header_image"` + FullContent string `json:"full_content,omitempty"` + HeaderImage string `json:"header_image,omitempty"` } func (i *Item) Print() { @@ -97,7 +97,7 @@ func filterPolicy() *bluemonday.Policy { } func ItemById(id int64) *Item { - items, err := Filter(0, nil, "", false, false, id, "") + items, err := Filter(0, nil, "", false, false, id, "", true) if err != nil || len(items) == 0 { return nil } @@ -134,7 +134,12 @@ func (i *Item) GetFullContent() { } } -func Filter(max_id int64, feed_ids []int64, category string, unread_only bool, starred_only bool, item_id int64, search_query string) ([]*Item, error) { +func Filter(max_id int64, feed_ids []int64, category string, unread_only bool, starred_only bool, item_id int64, search_query string, includeContent ...bool) ([]*Item, error) { + + withContent := false + if len(includeContent) > 0 { + withContent = includeContent[0] + } var args []interface{} tables := " feed,item" @@ -142,12 +147,15 @@ func Filter(max_id int64, feed_ids []int64, category string, unread_only bool, s tables = tables + ",fts_item" } - query := `SELECT item.id, item.feed_id, item.title, - item.url, item.description, - item.read_state, item.starred, item.publish_date, - item.full_content, item.header_image, - feed.url, feed.title, feed.category - FROM ` + selectCols := `item.id, item.feed_id, item.title, + item.url, item.description, + item.read_state, item.starred, item.publish_date` + if withContent { + selectCols += `, item.full_content, item.header_image` + } + selectCols += `, feed.url, feed.title, feed.category` + + query := `SELECT ` + selectCols + ` FROM ` query = query + tables + ` WHERE item.feed_id=feed.id AND item.id!=0 ` if max_id != 0 { @@ -206,7 +214,12 @@ func Filter(max_id int64, feed_ids []int64, category string, unread_only bool, s for rows.Next() { i := new(Item) var feed_id int64 - err := rows.Scan(&i.Id, &feed_id, &i.Title, &i.Url, &i.Description, &i.ReadState, &i.Starred, &i.PublishDate, &i.FullContent, &i.HeaderImage, &i.FeedUrl, &i.FeedTitle, &i.FeedCategory) + var err error + if withContent { + err = rows.Scan(&i.Id, &feed_id, &i.Title, &i.Url, &i.Description, &i.ReadState, &i.Starred, &i.PublishDate, &i.FullContent, &i.HeaderImage, &i.FeedUrl, &i.FeedTitle, &i.FeedCategory) + } else { + err = rows.Scan(&i.Id, &feed_id, &i.Title, &i.Url, &i.Description, &i.ReadState, &i.Starred, &i.PublishDate, &i.FeedUrl, &i.FeedTitle, &i.FeedCategory) + } if err != nil { vlog.Println(err) return nil, err @@ -223,9 +236,11 @@ func Filter(max_id int64, feed_ids []int64, category string, unread_only bool, s i.Url = p.Sanitize(i.Url) i.FeedTitle = p.Sanitize(i.FeedTitle) i.FeedUrl = p.Sanitize(i.FeedUrl) - i.FullContent = p.Sanitize(i.FullContent) - i.HeaderImage = p.Sanitize(i.HeaderImage) - i.CleanHeaderImage() + if withContent { + i.FullContent = p.Sanitize(i.FullContent) + i.HeaderImage = p.Sanitize(i.HeaderImage) + i.CleanHeaderImage() + } items = append(items, i) } if err = rows.Err(); err != nil { diff --git a/models/item/item_test.go b/models/item/item_test.go index 241a650..c5b557a 100644 --- a/models/item/item_test.go +++ b/models/item/item_test.go @@ -622,6 +622,99 @@ func TestGetFullContentWithMock(t *testing.T) { } } +func TestFilterIncludeContent(t *testing.T) { + setupTestDB(t) + feedId := createTestFeed(t) + + i := &Item{ + Title: "Content Item", + Url: "https://example.com/content", + Description: "desc", + PublishDate: "2024-01-01 00:00:00", + FeedId: feedId, + } + i.Create() + models.DB.Exec("UPDATE item SET full_content=?, header_image=? WHERE id=?", + "<p>Full article</p>", "https://example.com/img.jpg", i.Id) + + // Without includeContent (default) — should NOT have full_content + items, err := Filter(0, nil, "", false, false, 0, "") + if err != nil { + t.Fatal(err) + } + if len(items) != 1 { + t.Fatalf("Expected 1 item, got %d", len(items)) + } + if items[0].FullContent != "" { + t.Errorf("Expected empty FullContent without includeContent, got %q", items[0].FullContent) + } + if items[0].HeaderImage != "" { + t.Errorf("Expected empty HeaderImage without includeContent, got %q", items[0].HeaderImage) + } + + // With includeContent=true — should have full_content + items, err = Filter(0, nil, "", false, false, 0, "", true) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 { + t.Fatalf("Expected 1 item, got %d", len(items)) + } + if items[0].FullContent == "" { + t.Error("Expected FullContent with includeContent=true") + } + if items[0].HeaderImage == "" { + t.Error("Expected HeaderImage with includeContent=true") + } +} + +func TestItemByIdIncludesContent(t *testing.T) { + setupTestDB(t) + feedId := createTestFeed(t) + + i := &Item{ + Title: "ById Content", + Url: "https://example.com/byidcontent", + Description: "desc", + PublishDate: "2024-01-01 00:00:00", + FeedId: feedId, + } + i.Create() + models.DB.Exec("UPDATE item SET full_content=? WHERE id=?", "<p>Article body</p>", i.Id) + + found := ItemById(i.Id) + if found == nil { + t.Fatal("ItemById should return an item") + } + if found.FullContent == "" { + t.Error("ItemById should include full_content") + } +} + +func TestFilterMultipleFeedIds(t *testing.T) { + setupTestDB(t) + + res1, _ := models.DB.Exec("INSERT INTO feed(url, title) VALUES(?, ?)", "https://feed1.com", "Feed 1") + feedId1, _ := res1.LastInsertId() + res2, _ := models.DB.Exec("INSERT INTO feed(url, title) VALUES(?, ?)", "https://feed2.com", "Feed 2") + feedId2, _ := res2.LastInsertId() + res3, _ := models.DB.Exec("INSERT INTO feed(url, title) VALUES(?, ?)", "https://feed3.com", "Feed 3") + feedId3, _ := res3.LastInsertId() + + (&Item{Title: "F1 Item", Url: "https://feed1.com/1", Description: "d", PublishDate: "2024-01-01", FeedId: feedId1}).Create() + (&Item{Title: "F2 Item", Url: "https://feed2.com/1", Description: "d", PublishDate: "2024-01-01", FeedId: feedId2}).Create() + (&Item{Title: "F3 Item", Url: "https://feed3.com/1", Description: "d", PublishDate: "2024-01-01", FeedId: feedId3}).Create() + + // Filter by feed IDs 1 and 2 + items, err := Filter(0, []int64{feedId1, feedId2}, "", false, false, 0, "") + if err != nil { + t.Fatal(err) + } + if len(items) != 2 { + t.Errorf("Expected 2 items for two feed IDs, got %d", len(items)) + } +} + func TestRewriteImagesNoSrc(t *testing.T) { input := `<html><body><img alt="no src"/></body></html>` result := rewriteImages(input) diff --git a/web/dist/v3/assets/index-BV0ir9Wv.css b/web/dist/v3/assets/index-BV0ir9Wv.css deleted file mode 100644 index b7dde03..0000000 --- a/web/dist/v3/assets/index-BV0ir9Wv.css +++ /dev/null @@ -1 +0,0 @@ -: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}*{box-sizing:border-box}body{margin:0;font-family:var(--font-body);background-color:var(--bg-color);color:var(--text-color);height:100vh;width:100%;max-width:100vw;overflow:hidden}html{overflow-x:hidden;max-width:100vw}#app{height:100%;width:100%}.layout{display:flex;height:100%;width:100%;overflow-x:hidden;position:relative}.sidebar{width:11rem;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:5rem 1.5rem 1.5rem}.theme-dark .sidebar{background:#0003;border-right-color:#ffffff0d}.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-sans);font-size:.7rem;text-transform:uppercase;letter-spacing:.1em;opacity:.5;margin-top:2rem;margin-bottom:.5rem;font-weight:700;display:flex;align-items:center;justify-content:space-between;cursor:pointer;-webkit-user-select:none;user-select:none}.sidebar-section h3:hover{opacity:.8}.sidebar-section .caret{font-size:.6rem;transition:transform .2s ease;transform:rotate(90deg)}.sidebar-section.collapsed .caret{transform:rotate(0)}.sidebar-section.collapsed ul{display:none}.sidebar-section ul{list-style:none;padding:0;margin:0}.sidebar-section li a{display:block;padding:.3rem .8rem;margin:.1rem 0;border-radius:8px;transition:all .2s ease;font-weight:500;font-size:.85rem;text-decoration:none;color:var(--text-color);opacity:.8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:var(--font-sans)}.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-sans)}.sidebar-footer a:hover{background:#ffffff0d;opacity:1}.main-content{flex:1;min-width:0;overflow-y:auto;background-color:var(--bg-color);padding:1.5rem 2rem;transition:padding .3s ease}@media(max-width:768px){.main-content{padding:4rem 1rem 1rem}}.main-content>*{max-width:35em;margin-left:auto;margin-right:auto}.sidebar-toggle{position:fixed;top:1rem;left:1rem;z-index:1001;background:var(--sidebar-bg);border:1px solid var(--border-color);border-radius:50%;width:3rem;height:3rem;font-size:1.5rem;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 12px #0000001a;transition:transform .2s cubic-bezier(.175,.885,.32,1.275);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)}.sidebar-toggle:hover{transform:scale(1.1)}.sidebar-toggle:active{transform:scale(.95)}.sidebar-backdrop{display:none;position:fixed;inset:0;background:#0000004d;backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);z-index:999}@media(max-width:768px){.sidebar-visible .sidebar-backdrop{display:block}.sidebar{position:fixed;top:0;left:0;bottom:0;z-index:1000;transform:translate(-100%);transition:transform .3s cubic-bezier(.4,0,.2,1);box-shadow:none}.sidebar-visible .sidebar{transform:translate(0);box-shadow:10px 0 20px #0000001a}}.sidebar-hidden .sidebar{display:none}@media(min-width:769px){.sidebar-hidden .sidebar{display:none}.sidebar-visible .sidebar{display:flex}}input[type=text],input[type=url],input[type=search],select{padding:.4rem .8rem;border-radius:8px;border:1px solid var(--border-color);background:var(--bg-color);color:var(--text-color);font-family:inherit;font-size:.9rem}input:focus,select:focus{outline:none;border-color:var(--accent-color);box-shadow:0 0 0 2px #007bff33}.logo{font-size:2.5rem;margin:0 0 1.5rem;cursor:pointer;text-align:center;-webkit-user-select:none;user-select:none}.sidebar .logo{margin-bottom:1.5rem}.item-list{list-style:none;padding:0;margin:0}.feed-item{padding:1rem .5rem;margin-top:2rem;border-bottom:none;border-radius:8px;transition:background-color .2s ease}.feed-item.selected{background-color:#007bff0d;box-shadow:inset 4px 0 0 var(--accent-color)}.theme-dark .feed-item.selected{background-color:#2188ff1a}.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,.item-description video,.item-description pre{max-width:100%;height:auto;display:block;margin:1rem 0}.item-description pre{white-space:pre-wrap;word-wrap:break-word;overflow-x:auto;background:#0000000d;padding:1em;border-radius:4px}.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: #24292e;--text-color: #ffffff;--sidebar-bg: #1b1f23;--link-color: #5ac8fa;--border-color: #444d56;--accent-color: #2188ff}.font-serif{--font-body: Georgia, "Times New Roman", Times, serif;font-family:var(--font-body)}.font-sans{--font-body: var(--font-heading);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:#282e34;border-color:var(--accent-color)}.theme-dark input,.theme-dark select{background-color:#1b1f23;color:#fff;border-color:#444d56}.add-feed-form{display:flex;gap:.5rem}.add-feed-form input{flex:1;padding:.6rem 1rem;border-radius:8px;border:1px solid var(--border-color);background:var(--bg-color);color:var(--text-color)}.settings-group label{display:block;font-size:.85rem;font-weight:600;margin-bottom:.5rem;opacity:.7}#font-selector{width:100%;padding:.6rem;border-radius:8px;border:1px solid var(--border-color);background:var(--bg-color);color:var(--text-color)}.data-actions button,.data-actions .button{display:inline-block;text-align:center;width:100%}label.button{cursor:pointer} diff --git a/web/dist/v3/assets/index-Cqcte0U4.css b/web/dist/v3/assets/index-Cqcte0U4.css new file mode 100644 index 0000000..0ce336c --- /dev/null +++ b/web/dist/v3/assets/index-Cqcte0U4.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}*{box-sizing:border-box}body{margin:0;font-family:var(--font-body);background-color:var(--bg-color);color:var(--text-color);height:100vh;width:100%;max-width:100vw;overflow:hidden}html{overflow-x:hidden;max-width:100vw}#app{height:100%;width:100%}.layout{display:flex;height:100%;width:100%;overflow-x:hidden;position:relative}.sidebar{width:11rem;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:5rem 1.5rem 1.5rem}.theme-dark .sidebar{background:#0003;border-right-color:#ffffff0d}.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-sans);font-size:.7rem;text-transform:uppercase;letter-spacing:.1em;opacity:.5;margin-top:2rem;margin-bottom:.5rem;font-weight:700;display:flex;align-items:center;justify-content:space-between;cursor:pointer;-webkit-user-select:none;user-select:none}.sidebar-section h3:hover{opacity:.8}.sidebar-section .caret{font-size:.6rem;transition:transform .2s ease;transform:rotate(90deg)}.sidebar-section.collapsed .caret{transform:rotate(0)}.sidebar-section.collapsed ul{display:none}.sidebar-section ul{list-style:none;padding:0;margin:0}.sidebar-section li a{display:block;padding:.3rem .8rem;margin:.1rem 0;border-radius:8px;transition:all .2s ease;font-weight:500;font-size:.85rem;text-decoration:none;color:var(--text-color);opacity:.8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:var(--font-sans)}.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-sans)}.sidebar-footer a:hover{background:#ffffff0d;opacity:1}.main-content{flex:1;min-width:0;overflow-y:auto;background-color:var(--bg-color);padding:1.5rem 2rem;transition:padding .3s ease}@media(max-width:1024px){.main-content{padding:4rem 1rem 1rem}}.main-content>*{max-width:35em;margin-left:auto;margin-right:auto}.sidebar-toggle{position:fixed;top:1rem;left:1rem;z-index:1001;background:var(--sidebar-bg);border:1px solid var(--border-color);border-radius:50%;width:3rem;height:3rem;font-size:1.5rem;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 12px #0000001a;transition:transform .2s cubic-bezier(.175,.885,.32,1.275);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)}.sidebar-toggle:hover{transform:scale(1.1)}.sidebar-toggle:active{transform:scale(.95)}.sidebar-backdrop{display:none;position:fixed;inset:0;background:#0000004d;backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);z-index:999}@media(max-width:1024px){.sidebar-visible .sidebar-backdrop{display:block}.sidebar{position:fixed;top:0;left:0;bottom:0;z-index:1000;transform:translate(-100%);transition:transform .3s cubic-bezier(.4,0,.2,1);box-shadow:none}.sidebar-visible .sidebar{transform:translate(0);box-shadow:10px 0 20px #0000001a}}.sidebar-hidden .sidebar{display:none}@media(min-width:1025px){.sidebar-hidden .sidebar{display:none}.sidebar-visible .sidebar{display:flex}}input[type=text],input[type=url],input[type=search],select{padding:.4rem .8rem;border-radius:8px;border:1px solid var(--border-color);background:var(--bg-color);color:var(--text-color);font-family:inherit;font-size:.9rem}input:focus,select:focus{outline:none;border-color:var(--accent-color);box-shadow:0 0 0 2px #007bff33}.logo{font-size:2.5rem;margin:0 0 1.5rem;cursor:pointer;text-align:center;-webkit-user-select:none;user-select:none}.sidebar .logo{margin-bottom:1.5rem}.item-list{list-style:none;padding:0;margin:0}.feed-item{padding:1rem .5rem;margin-top:2rem;border-bottom:none;border-radius:8px;transition:background-color .2s ease}.feed-item.selected{background-color:#007bff0d;box-shadow:inset 4px 0 0 var(--accent-color)}.theme-dark .feed-item.selected{background-color:#2188ff1a}.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,.item-description video,.item-description pre{max-width:100%;height:auto;display:block;margin:1rem 0}.item-description pre{white-space:pre-wrap;word-wrap:break-word;overflow-x:auto;background:#0000000d;padding:1em;border-radius:4px}.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: #24292e;--text-color: #ffffff;--sidebar-bg: #1b1f23;--link-color: #5ac8fa;--border-color: #444d56;--accent-color: #2188ff}.font-serif{--font-body: Georgia, "Times New Roman", Times, serif;font-family:var(--font-body)}.font-sans{--font-body: var(--font-heading);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:#282e34;border-color:var(--accent-color)}.theme-dark input,.theme-dark select{background-color:#1b1f23;color:#fff;border-color:#444d56}.add-feed-form{display:flex;gap:.5rem}.add-feed-form input{flex:1;padding:.6rem 1rem;border-radius:8px;border:1px solid var(--border-color);background:var(--bg-color);color:var(--text-color)}.settings-group label{display:block;font-size:.85rem;font-weight:600;margin-bottom:.5rem;opacity:.7}#font-selector{width:100%;padding:.6rem;border-radius:8px;border:1px solid var(--border-color);background:var(--bg-color);color:var(--text-color)}.data-actions button,.data-actions .button{display:inline-block;text-align:center;width:100%}label.button{cursor:pointer} diff --git a/web/dist/v3/assets/index-M5xszonw.js b/web/dist/v3/assets/index-CyyR50mK.js index 8348b8a..71e25b9 100644 --- a/web/dist/v3/assets/index-M5xszonw.js +++ b/web/dist/v3/assets/index-CyyR50mK.js @@ -1,4 +1,4 @@ -(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))n(s);new MutationObserver(s=>{for(const r of s)if(r.type==="childList")for(const d of r.addedNodes)d.tagName==="LINK"&&d.rel==="modulepreload"&&n(d)}).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 n(s){if(s.ep)return;s.ep=!0;const r=a(s);fetch(s.href,r)}})();function R(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",n=["POST","PUT","DELETE"].includes(a),s=new Headers(e?.headers||{});if(n){const r=R("csrf_token");r&&s.set("X-CSRF-Token",r)}return fetch(t,{...e,headers:s,credentials:"include"})}class B 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";sidebarVisible=window.innerWidth>768;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")}setSidebarVisible(e){this.sidebarVisible=e,this.emit("sidebar-toggle")}toggleSidebar(){this.setSidebarVisible(!this.sidebarVisible)}emit(e,a){this.dispatchEvent(new CustomEvent(e,{detail:a}))}on(e,a){this.addEventListener(e,a)}}const i=new B;class C 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),n=e.pathname.replace(/^\/v3\//,"").split("/").filter(Boolean);let s="/";const r={};return n[0]==="feed"&&n[1]?(s="/feed",r.feedId=n[1]):n[0]==="tag"&&n[1]?(s="/tag",r.tagName=decodeURIComponent(n[1])):n[0]==="settings"&&(s="/settings"),{path:s,params:r,query:e.searchParams}}navigate(e,a){let n=`/v3${e}`;if(a){const s=new URLSearchParams(a);n+=`?${s.toString()}`}window.history.pushState({},"",n),this.handleRouteChange()}updateQuery(e){const a=new URL(window.location.href);for(const[n,s]of Object.entries(e))s?a.searchParams.set(n,s):a.searchParams.delete(n);window.history.pushState({},"",a.toString()),this.handleRouteChange()}}const o=new C;function P(t,e=!1){const a=new Date(t.publish_date).toLocaleDateString();return` +(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))n(s);new MutationObserver(s=>{for(const r of s)if(r.type==="childList")for(const d of r.addedNodes)d.tagName==="LINK"&&d.rel==="modulepreload"&&n(d)}).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 n(s){if(s.ep)return;s.ep=!0;const r=a(s);fetch(s.href,r)}})();function R(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",n=["POST","PUT","DELETE"].includes(a),s=new Headers(e?.headers||{});if(n){const r=R("csrf_token");r&&s.set("X-CSRF-Token",r)}return fetch(t,{...e,headers:s,credentials:"include"})}function C(){const t=document.cookie.split("; ").find(e=>e.startsWith("neko_sidebar="));return t?t.split("=")[1]==="1":null}function B(t){document.cookie=`neko_sidebar=${t?"1":"0"}; path=/; max-age=31536000; SameSite=Lax`}function P(){const t=C();return t!==null?t:window.innerWidth>1024}class x 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";sidebarVisible=P();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")}setSidebarVisible(e){this.sidebarVisible=e,B(e),this.emit("sidebar-toggle")}toggleSidebar(){this.setSidebarVisible(!this.sidebarVisible)}emit(e,a){this.dispatchEvent(new CustomEvent(e,{detail:a}))}on(e,a){this.addEventListener(e,a)}}const i=new x;class O 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),n=e.pathname.replace(/^\/v3\//,"").split("/").filter(Boolean);let s="/";const r={};return n[0]==="feed"&&n[1]?(s="/feed",r.feedId=n[1]):n[0]==="tag"&&n[1]?(s="/tag",r.tagName=decodeURIComponent(n[1])):n[0]==="settings"&&(s="/settings"),{path:s,params:r,query:e.searchParams}}navigate(e,a){let n=`/v3${e}`;if(a){const s=new URLSearchParams(a);n+=`?${s.toString()}`}window.history.pushState({},"",n),this.handleRouteChange()}updateQuery(e){const a=new URL(window.location.href);for(const[n,s]of Object.entries(e))s?a.searchParams.set(n,s):a.searchParams.delete(n);window.history.pushState({},"",a.toString()),this.handleRouteChange()}}const o=new O;function M(t,e=!1){const a=new Date(t.publish_date).toLocaleDateString();return` <li class="feed-item ${t.read?"read":"unread"} ${e?"selected":""}" data-id="${t._id}"> <div class="item-header"> <a href="${t.url}" target="_blank" rel="noopener noreferrer" class="item-title" data-action="open"> @@ -27,7 +27,7 @@ </div> `:""} </li> - `}let c=null,p=null,b=null;function O(){p=document.querySelector("#app"),p&&(p.className=`theme-${i.theme} font-${i.fontTheme}`,p.innerHTML=` + `}let u=null,p=null,b=null;function q(){p=document.querySelector("#app"),p&&(p.className=`theme-${i.theme} font-${i.fontTheme}`,p.innerHTML=` <div class="layout ${i.sidebarVisible?"sidebar-visible":"sidebar-hidden"}"> <button class="sidebar-toggle" id="sidebar-toggle-btn" title="Toggle Sidebar">🐱</button> <div class="sidebar-backdrop" id="sidebar-backdrop"></div> @@ -61,24 +61,24 @@ <div id="content-area"></div> </main> </div> - `,M())}function M(){document.getElementById("search-input")?.addEventListener("input",s=>{const r=s.target.value;o.updateQuery({q:r})}),document.getElementById("logo-link")?.addEventListener("click",()=>o.navigate("/")),document.getElementById("logout-button")?.addEventListener("click",s=>{s.preventDefault(),V()}),document.getElementById("sidebar-toggle-btn")?.addEventListener("click",()=>{i.toggleSidebar()}),document.getElementById("sidebar-backdrop")?.addEventListener("click",()=>{i.setSidebarVisible(!1)}),window.addEventListener("resize",()=>{window.innerWidth>768&&!i.sidebarVisible&&i.setSidebarVisible(!0)}),document.querySelectorAll(".sidebar-section.collapsible h3").forEach(s=>{s.addEventListener("click",()=>{s.parentElement?.classList.toggle("collapsed")})}),document.getElementById("sidebar")?.addEventListener("click",s=>{const r=s.target,d=r.closest("a");if(!d){r.classList.contains("logo")&&(s.preventDefault(),o.navigate("/",{}));return}const u=d.getAttribute("data-nav"),f=Object.fromEntries(o.getCurrentRoute().query.entries());if(u==="filter"){s.preventDefault();const l=d.getAttribute("data-value");o.getCurrentRoute().path==="/settings"?o.navigate("/",{...f,filter:l}):o.updateQuery({filter:l})}else if(u==="tag"){s.preventDefault();const l=d.getAttribute("data-value");o.navigate(`/tag/${encodeURIComponent(l)}`,f)}else if(u==="feed"){s.preventDefault();const l=d.getAttribute("data-value");i.activeFeedId===parseInt(l)?o.navigate("/",f):o.navigate(`/feed/${l}`,f)}else u==="settings"&&(s.preventDefault(),o.getCurrentRoute().path==="/settings"?o.navigate("/",f):o.navigate("/settings",f));window.innerWidth<=768&&i.setSidebarVisible(!1)}),document.getElementById("content-area")?.addEventListener("click",s=>{const r=s.target,d=r.closest('[data-action="toggle-star"]');if(d){const m=d.closest("[data-id]");if(m){const v=parseInt(m.getAttribute("data-id"));Q(v)}return}const u=r.closest('[data-action="scrape"]');if(u){const m=u.closest("[data-id]");if(m){const v=parseInt(m.getAttribute("data-id"));j(v)}return}const f=r.closest('[data-action="open"]'),l=r.closest(".feed-item");if(l&&!f){const m=parseInt(l.getAttribute("data-id"));c=m,document.querySelectorAll(".feed-item").forEach(w=>{const A=parseInt(w.getAttribute("data-id")||"0");w.classList.toggle("selected",A===c)});const v=i.items.find(w=>w._id===m);v&&!v.read&&h(m,{read:!0})}})}function $(){const{feeds:t,activeFeedId:e}=i,a=document.getElementById("feed-list");a&&(a.innerHTML=t.map(n=>` + `,N())}function N(){document.getElementById("search-input")?.addEventListener("input",s=>{const r=s.target.value;o.updateQuery({q:r})}),document.getElementById("logo-link")?.addEventListener("click",()=>o.navigate("/")),document.getElementById("logout-button")?.addEventListener("click",s=>{s.preventDefault(),J()}),document.getElementById("sidebar-toggle-btn")?.addEventListener("click",()=>{i.toggleSidebar()}),document.getElementById("sidebar-backdrop")?.addEventListener("click",()=>{i.setSidebarVisible(!1)}),document.querySelectorAll(".sidebar-section.collapsible h3").forEach(s=>{s.addEventListener("click",()=>{s.parentElement?.classList.toggle("collapsed")})}),document.getElementById("sidebar")?.addEventListener("click",s=>{const r=s.target,d=r.closest("a");if(!d){r.classList.contains("logo")&&(s.preventDefault(),o.navigate("/",{}));return}const f=d.getAttribute("data-nav"),m=Object.fromEntries(o.getCurrentRoute().query.entries());if(f==="filter"){s.preventDefault();const l=d.getAttribute("data-value");o.getCurrentRoute().path==="/settings"?o.navigate("/",{...m,filter:l}):o.updateQuery({filter:l})}else if(f==="tag"){s.preventDefault();const l=d.getAttribute("data-value");o.navigate(`/tag/${encodeURIComponent(l)}`,m)}else if(f==="feed"){s.preventDefault();const l=d.getAttribute("data-value"),c=o.getCurrentRoute();i.activeFeedId===parseInt(l)&&c.path!=="/settings"?o.navigate("/",m):o.navigate(`/feed/${l}`,m)}else f==="settings"&&(s.preventDefault(),o.getCurrentRoute().path==="/settings"?o.navigate("/",m):o.navigate("/settings",m));window.innerWidth<=768&&i.setSidebarVisible(!1)}),document.getElementById("content-area")?.addEventListener("click",s=>{const r=s.target,d=r.closest('[data-action="toggle-star"]');if(d){const c=d.closest("[data-id]");if(c){const v=parseInt(c.getAttribute("data-id"));V(v)}return}const f=r.closest('[data-action="scrape"]');if(f){const c=f.closest("[data-id]");if(c){const v=parseInt(c.getAttribute("data-id"));H(v)}return}const m=r.closest('[data-action="open"]'),l=r.closest(".feed-item");if(l&&!m){const c=parseInt(l.getAttribute("data-id"));u=c,document.querySelectorAll(".feed-item").forEach(w=>{const A=parseInt(w.getAttribute("data-id")||"0");w.classList.toggle("selected",A===u)});const v=i.items.find(w=>w._id===c);v&&!v.read&&h(c,{read:!0})}})}function k(){const{feeds:t,activeFeedId:e}=i,a=document.getElementById("feed-list");a&&(a.innerHTML=t.map(n=>` <li class="${n._id===e?"active":""}"> <a href="/v3/feed/${n._id}" data-nav="feed" data-value="${n._id}"> ${n.title||n.url} </a> </li> - `).join(""))}function k(){const{tags:t,activeTagName:e}=i,a=document.getElementById("tag-list");a&&(a.innerHTML=t.map(n=>` + `).join(""))}function $(){const{tags:t,activeTagName:e}=i,a=document.getElementById("tag-list");a&&(a.innerHTML=t.map(n=>` <li class="${n.title===e?"active":""}"> <a href="/v3/tag/${encodeURIComponent(n.title)}" data-nav="tag" data-value="${n.title}"> ${n.title} </a> </li> - `).join(""))}function F(){const{filter:t}=i,e=document.getElementById("filter-list");e&&e.querySelectorAll("li").forEach(a=>{a.classList.toggle("active",a.getAttribute("data-filter")===t)})}function L(){const{items:t,loading:e}=i;b&&(b.disconnect(),b=null);const a=document.getElementById("content-area");if(!a||o.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=` + `).join(""))}function F(){const{filter:t}=i,e=document.getElementById("filter-list");e&&e.querySelectorAll("li").forEach(a=>{a.classList.toggle("active",a.getAttribute("data-filter")===t)})}function E(){const{items:t,loading:e}=i;b&&(b.disconnect(),b=null);const a=document.getElementById("content-area");if(!a||o.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=>P(s,s._id===c)).join("")} + ${t.map(s=>M(s,s._id===u)).join("")} </ul> ${i.hasMore?'<div id="load-more-sentinel" class="loading-more">Loading more...</div>':""} - `;const n=document.getElementById("load-more-sentinel");n&&new IntersectionObserver(r=>{r[0].isIntersecting&&!i.loading&&i.hasMore&&U()},{threshold:.1}).observe(n),b=new IntersectionObserver(s=>{s.forEach(r=>{if(r.isIntersecting){const d=r.target,u=parseInt(d.getAttribute("data-id")||"0");if(u){const f=i.items.find(l=>l._id===u);f&&!f.read&&(h(u,{read:!0}),b?.unobserve(d))}}})},{threshold:1}),a.querySelectorAll(".feed-item").forEach(s=>b.observe(s))}function I(){const t=document.getElementById("content-area");t&&(t.innerHTML=` + `;const n=document.getElementById("load-more-sentinel");n&&new IntersectionObserver(r=>{r[0].isIntersecting&&!i.loading&&i.hasMore&&W()},{threshold:.1}).observe(n),b=new IntersectionObserver(s=>{s.forEach(r=>{if(r.isIntersecting){const d=r.target,f=parseInt(d.getAttribute("data-id")||"0");if(f){const m=i.items.find(l=>l._id===f);m&&!m.read&&(h(f,{read:!0}),b?.unobserve(d))}}})},{threshold:1}),a.querySelectorAll(".feed-item").forEach(s=>b.observe(s))}function I(){const t=document.getElementById("content-area");t&&(t.innerHTML=` <div class="settings-view"> <h2>Settings</h2> @@ -140,4 +140,4 @@ </div> </section> </div> - `,document.getElementById("theme-options")?.addEventListener("click",e=>{const a=e.target.closest("button");if(a){const n=a.getAttribute("data-theme");i.setTheme(n),I()}}),document.getElementById("font-selector")?.addEventListener("change",e=>{i.setFontTheme(e.target.value)}),document.getElementById("add-feed-btn")?.addEventListener("click",async()=>{const e=document.getElementById("new-feed-url"),a=e.value.trim();a&&(await x(a)?(e.value="",alert("Feed added successfully!"),y()):alert("Failed to add feed."))}),document.getElementById("export-opml-btn")?.addEventListener("click",()=>{window.location.href="/api/export/opml"}),document.getElementById("import-opml-file")?.addEventListener("change",async e=>{const a=e.target.files?.[0];a&&(await q(a)?(alert("OPML imported successfully! Crawling started."),y()):alert("Failed to import OPML."))}),document.querySelectorAll(".delete-feed-btn").forEach(e=>{e.addEventListener("click",async a=>{const n=parseInt(a.target.getAttribute("data-id"));confirm("Are you sure you want to delete this feed?")&&(await N(n),y(),await y(),I())})}),document.querySelectorAll(".update-feed-tag-btn").forEach(e=>{e.addEventListener("click",async a=>{const n=parseInt(a.target.getAttribute("data-id")),r=document.querySelector(`.feed-tag-input[data-id="${n}"]`).value.trim();await D(n,{category:r}),await y(),await _(),I(),alert("Feed updated")})}))}async function x(t){try{return(await g("/api/feed",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({url:t})})).ok}catch(e){return console.error("Failed to add feed",e),!1}}async function q(t){try{const e=new FormData;e.append("file",t),e.append("format","opml");const a=document.cookie.split("; ").find(s=>s.startsWith("csrf_token="))?.split("=")[1];return(await fetch("/api/import",{method:"POST",headers:{"X-CSRF-Token":a||""},body:e})).ok}catch(e){return console.error("Failed to import OPML",e),!1}}async function N(t){try{return(await g(`/api/feed/${t}`,{method:"DELETE"})).ok}catch(e){return console.error("Failed to delete feed",e),!1}}async function D(t,e){try{return(await g("/api/feed",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({...e,_id:t})})).ok}catch(a){return console.error("Failed to update feed",a),!1}}async function Q(t){const e=i.items.find(a=>a._id===t);e&&h(t,{starred:!e.starred})}async function j(t){if(i.items.find(a=>a._id===t))try{const a=await g(`/api/item/${t}/content`);if(a.ok){const n=await a.json();n.full_content&&h(t,{full_content:n.full_content})}}catch(a){console.error("Failed to fetch full content",a)}}async function h(t,e){try{if((await g(`/api/item/${t}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)})).ok){const n=i.items.find(s=>s._id===t);if(n){Object.assign(n,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&&L()}}}}catch(a){console.error("Failed to update item",a)}}async function y(){const t=await g("/api/feed/");if(t.ok){const e=await t.json();i.setFeeds(e)}}async function _(){const t=await g("/api/tag");if(t.ok){const e=await t.json();i.setTags(e)}}async function E(t,e,a=!1){i.setLoading(!0);try{const n=new URLSearchParams;t&&n.append("feed_id",t),e&&n.append("tag",e),i.searchQuery&&n.append("q",i.searchQuery),(i.filter==="starred"||i.filter==="all")&&n.append("read_filter","all"),i.filter==="starred"&&n.append("starred","true"),a&&i.items.length>0&&n.append("max_id",String(i.items[i.items.length-1]._id));const s=await g(`/api/stream?${n.toString()}`);if(s.ok){const r=await s.json();i.setHasMore(r.length>=50),i.setItems(r,a)}}finally{i.setLoading(!1)}}async function U(){const t=o.getCurrentRoute();E(t.params.feedId,t.params.tagName,!0)}async function V(){await g("/api/logout",{method:"POST"}),window.location.href="/login/"}function S(){const t=o.getCurrentRoute(),e=t.query.get("filter");i.setFilter(e||"unread");const a=t.query.get("q");if(a!==null&&i.setSearchQuery(a),t.path==="/settings"){I();return}if(t.path==="/feed"&&t.params.feedId){const n=parseInt(t.params.feedId);i.setActiveFeed(n),E(t.params.feedId),document.getElementById("section-feeds")?.classList.remove("collapsed")}else t.path==="/tag"&&t.params.tagName?(i.setActiveTag(t.params.tagName),E(void 0,t.params.tagName),document.getElementById("section-tags")?.classList.remove("collapsed")):(i.setActiveFeed(null),i.setActiveTag(null),E())}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=i.items.find(a=>a._id===c);e&&h(e._id,{read:!e.read})}break;case"s":if(c){const e=i.items.find(a=>a._id===c);e&&h(e._id,{starred:!e.starred})}break;case"/":t.preventDefault(),document.getElementById("search-input")?.focus();break}});function T(t){if(i.items.length===0)return;const e=i.items.findIndex(n=>n._id===c);let a;if(e===-1?a=t>0?0:i.items.length-1:a=e+t,a>=0&&a<i.items.length){c=i.items[a]._id,document.querySelectorAll(".feed-item").forEach(s=>{const r=parseInt(s.getAttribute("data-id")||"0");s.classList.toggle("selected",r===c)});const n=document.querySelector(`.feed-item[data-id="${c}"]`);n&&n.scrollIntoView({block:"start",behavior:"smooth"}),i.items[a].read||h(c,{read:!0})}}i.on("feeds-updated",$);i.on("tags-updated",k);i.on("active-feed-updated",$);i.on("active-tag-updated",k);i.on("filter-updated",F);i.on("search-updated",()=>{const t=document.getElementById("search-input");t&&t.value!==i.searchQuery&&(t.value=i.searchQuery),S()});i.on("theme-updated",()=>{p||(p=document.querySelector("#app")),p&&(p.className=`theme-${i.theme} font-${i.fontTheme}`)});i.on("sidebar-toggle",()=>{const t=document.querySelector(".layout");t&&(i.sidebarVisible?(t.classList.remove("sidebar-hidden"),t.classList.add("sidebar-visible")):(t.classList.remove("sidebar-visible"),t.classList.add("sidebar-hidden")))});i.on("items-updated",L);i.on("loading-state-changed",L);o.addEventListener("route-changed",S);window.app={navigate:t=>o.navigate(t)};async function H(){const t=await g("/api/auth");if(!t||t.status===401){window.location.href="/login/";return}O(),F();try{await Promise.all([y(),_()])}catch(e){console.error("Initial fetch failed",e)}S()}typeof window<"u"&&!window.__VITEST__&&H(); + `,document.getElementById("theme-options")?.addEventListener("click",e=>{const a=e.target.closest("button");if(a){const n=a.getAttribute("data-theme");i.setTheme(n),I()}}),document.getElementById("font-selector")?.addEventListener("change",e=>{i.setFontTheme(e.target.value)}),document.getElementById("add-feed-btn")?.addEventListener("click",async()=>{const e=document.getElementById("new-feed-url"),a=e.value.trim();a&&(await D(a)?(e.value="",alert("Feed added successfully!"),y()):alert("Failed to add feed."))}),document.getElementById("export-opml-btn")?.addEventListener("click",()=>{window.location.href="/api/export/opml"}),document.getElementById("import-opml-file")?.addEventListener("change",async e=>{const a=e.target.files?.[0];a&&(await Q(a)?(alert("OPML imported successfully! Crawling started."),y()):alert("Failed to import OPML."))}),document.querySelectorAll(".delete-feed-btn").forEach(e=>{e.addEventListener("click",async a=>{const n=parseInt(a.target.getAttribute("data-id"));confirm("Are you sure you want to delete this feed?")&&(await j(n),y(),await y(),I())})}),document.querySelectorAll(".update-feed-tag-btn").forEach(e=>{e.addEventListener("click",async a=>{const n=parseInt(a.target.getAttribute("data-id")),r=document.querySelector(`.feed-tag-input[data-id="${n}"]`).value.trim();await U(n,{category:r}),await y(),await _(),I(),alert("Feed updated")})}))}async function D(t){try{return(await g("/api/feed",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({url:t})})).ok}catch(e){return console.error("Failed to add feed",e),!1}}async function Q(t){try{const e=new FormData;e.append("file",t),e.append("format","opml");const a=document.cookie.split("; ").find(s=>s.startsWith("csrf_token="))?.split("=")[1];return(await fetch("/api/import",{method:"POST",headers:{"X-CSRF-Token":a||""},body:e})).ok}catch(e){return console.error("Failed to import OPML",e),!1}}async function j(t){try{return(await g(`/api/feed/${t}`,{method:"DELETE"})).ok}catch(e){return console.error("Failed to delete feed",e),!1}}async function U(t,e){try{return(await g("/api/feed",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({...e,_id:t})})).ok}catch(a){return console.error("Failed to update feed",a),!1}}async function V(t){const e=i.items.find(a=>a._id===t);e&&h(t,{starred:!e.starred})}async function H(t){if(i.items.find(a=>a._id===t))try{const a=await g(`/api/item/${t}/content`);if(a.ok){const n=await a.json();n.full_content&&h(t,{full_content:n.full_content})}}catch(a){console.error("Failed to fetch full content",a)}}async function h(t,e){try{if((await g(`/api/item/${t}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)})).ok){const n=i.items.find(s=>s._id===t);if(n){Object.assign(n,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&&E()}}}}catch(a){console.error("Failed to update item",a)}}async function y(){const t=await g("/api/feed/");if(t.ok){const e=await t.json();i.setFeeds(e)}}async function _(){const t=await g("/api/tag");if(t.ok){const e=await t.json();i.setTags(e)}}async function L(t,e,a=!1){i.setLoading(!0);try{const n=new URLSearchParams;t&&n.append("feed_id",t),e&&n.append("tag",e),i.searchQuery&&n.append("q",i.searchQuery),(i.filter==="starred"||i.filter==="all")&&n.append("read_filter","all"),i.filter==="starred"&&n.append("starred","true"),a&&i.items.length>0&&n.append("max_id",String(i.items[i.items.length-1]._id));const s=await g(`/api/stream?${n.toString()}`);if(s.ok){const r=await s.json();i.setHasMore(r.length>=50),i.setItems(r,a)}}finally{i.setLoading(!1)}}async function W(){const t=o.getCurrentRoute();L(t.params.feedId,t.params.tagName,!0)}async function J(){await g("/api/logout",{method:"POST"}),window.location.href="/login/"}function S(){const t=o.getCurrentRoute(),e=t.query.get("filter");i.setFilter(e||"unread");const a=t.query.get("q");if(a!==null&&i.setSearchQuery(a),t.path==="/settings"){I();return}if(t.path==="/feed"&&t.params.feedId){const n=parseInt(t.params.feedId);i.setActiveFeed(n),L(t.params.feedId),document.getElementById("section-feeds")?.classList.remove("collapsed")}else t.path==="/tag"&&t.params.tagName?(i.setActiveTag(t.params.tagName),L(void 0,t.params.tagName),document.getElementById("section-tags")?.classList.remove("collapsed")):(i.setActiveFeed(null),i.setActiveTag(null),L())}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(u){const e=i.items.find(a=>a._id===u);e&&h(e._id,{read:!e.read})}break;case"s":if(u){const e=i.items.find(a=>a._id===u);e&&h(e._id,{starred:!e.starred})}break;case"/":t.preventDefault(),document.getElementById("search-input")?.focus();break}});function T(t){if(i.items.length===0)return;const e=i.items.findIndex(n=>n._id===u);let a;if(e===-1?a=t>0?0:i.items.length-1:a=e+t,a>=0&&a<i.items.length){u=i.items[a]._id,document.querySelectorAll(".feed-item").forEach(s=>{const r=parseInt(s.getAttribute("data-id")||"0");s.classList.toggle("selected",r===u)});const n=document.querySelector(`.feed-item[data-id="${u}"]`);n&&n.scrollIntoView({block:"start",behavior:"smooth"}),i.items[a].read||h(u,{read:!0})}}i.on("feeds-updated",k);i.on("tags-updated",$);i.on("active-feed-updated",k);i.on("active-tag-updated",$);i.on("filter-updated",F);i.on("search-updated",()=>{const t=document.getElementById("search-input");t&&t.value!==i.searchQuery&&(t.value=i.searchQuery),S()});i.on("theme-updated",()=>{p||(p=document.querySelector("#app")),p&&(p.className=`theme-${i.theme} font-${i.fontTheme}`)});i.on("sidebar-toggle",()=>{const t=document.querySelector(".layout");t&&(i.sidebarVisible?(t.classList.remove("sidebar-hidden"),t.classList.add("sidebar-visible")):(t.classList.remove("sidebar-visible"),t.classList.add("sidebar-hidden")))});i.on("items-updated",E);i.on("loading-state-changed",E);o.addEventListener("route-changed",S);window.app={navigate:t=>o.navigate(t)};async function X(){const t=await g("/api/auth");if(!t||t.status===401){window.location.href="/login/";return}q(),F();try{await Promise.all([y(),_()])}catch(e){console.error("Initial fetch failed",e)}S()}typeof window<"u"&&!window.__VITEST__&&X(); diff --git a/web/dist/v3/index.html b/web/dist/v3/index.html index c48a396..681b7ca 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>neko</title> - <script type="module" crossorigin src="/v3/assets/index-M5xszonw.js"></script> - <link rel="stylesheet" crossorigin href="/v3/assets/index-BV0ir9Wv.css"> + <script type="module" crossorigin src="/v3/assets/index-CyyR50mK.js"></script> + <link rel="stylesheet" crossorigin href="/v3/assets/index-Cqcte0U4.css"> </head> <body> <div id="app"></div> diff --git a/web/login_test.go b/web/login_test.go index f4931b2..9d78396 100644 --- a/web/login_test.go +++ b/web/login_test.go @@ -85,3 +85,89 @@ func TestCSRFLoginWithFormToken(t *testing.T) { t.Errorf("Expected POST /other with valid CSRF header to succeed, got 403") } } + +func TestCSRFExcludedPaths(t *testing.T) { + originalPw := config.Config.DigestPassword + defer func() { config.Config.DigestPassword = originalPw }() + config.Config.DigestPassword = "secret" + + mux := http.NewServeMux() + mux.HandleFunc("/api/login", apiLoginHandler) + mux.HandleFunc("/api/logout", apiLogoutHandler) + handler := CSRFMiddleware(&config.Config, mux) + + // POST /api/login without CSRF token should succeed (excluded path) + req := httptest.NewRequest("POST", "/api/login", strings.NewReader("password=secret")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code == http.StatusForbidden { + t.Error("POST /api/login should be excluded from CSRF protection") + } + + // POST /api/logout without CSRF token should succeed (excluded path) + req = httptest.NewRequest("POST", "/api/logout", nil) + rr = httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code == http.StatusForbidden { + t.Error("POST /api/logout should be excluded from CSRF protection") + } +} + +func TestCSRFPutAndDeleteMethods(t *testing.T) { + cfg := &config.Settings{SecureCookies: false} + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler := CSRFMiddleware(cfg, inner) + + // GET to get a token + getReq := httptest.NewRequest("GET", "/", nil) + getRR := httptest.NewRecorder() + handler.ServeHTTP(getRR, getReq) + + var csrfToken string + for _, c := range getRR.Result().Cookies() { + if c.Name == "csrf_token" { + csrfToken = c.Value + } + } + + // PUT without token should fail + req := httptest.NewRequest("PUT", "/item/1", nil) + req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken}) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusForbidden { + t.Errorf("PUT without CSRF token should return 403, got %d", rr.Code) + } + + // DELETE without token should fail + req = httptest.NewRequest("DELETE", "/feed/1", nil) + req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken}) + rr = httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusForbidden { + t.Errorf("DELETE without CSRF token should return 403, got %d", rr.Code) + } + + // PUT with valid token should succeed + req = httptest.NewRequest("PUT", "/item/1", nil) + req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken}) + req.Header.Set("X-CSRF-Token", csrfToken) + rr = httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("PUT with valid CSRF token should succeed, got %d", rr.Code) + } + + // DELETE with valid token should succeed + req = httptest.NewRequest("DELETE", "/feed/1", nil) + req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken}) + req.Header.Set("X-CSRF-Token", csrfToken) + rr = httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("DELETE with valid CSRF token should succeed, got %d", rr.Code) + } +} diff --git a/web/routing_test.go b/web/routing_test.go index 972b208..a7c5c37 100644 --- a/web/routing_test.go +++ b/web/routing_test.go @@ -36,6 +36,13 @@ func TestRouting(t *testing.T) { containsBody: "<!doctype html>", }, { + name: "/v3/ serves v3 UI", + path: "/v3/", + method: "GET", + expectedStatus: http.StatusOK, + containsBody: "<!doctype html>", + }, + { name: "/v1/ redirects unauthenticated", path: "/v1/", method: "GET", |
