diff options
| -rw-r--r-- | .thicket/tickets.jsonl | 5 | ||||
| -rw-r--r-- | README.md | 5 | ||||
| -rw-r--r-- | frontend-vanilla/src/main.test.ts | 27 | ||||
| -rw-r--r-- | frontend-vanilla/src/main.ts | 80 |
4 files changed, 115 insertions, 2 deletions
diff --git a/.thicket/tickets.jsonl b/.thicket/tickets.jsonl index e0c37a9..ca30fc0 100644 --- a/.thicket/tickets.jsonl +++ b/.thicket/tickets.jsonl @@ -38,6 +38,7 @@ {"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-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":"open","priority":0,"labels":null,"assignee":"","created":"2026-02-16T16:24:06.315635122Z","updated":"2026-02-16T16:24:06.315635122Z"} {"id":"NK-a217qm","title":"font styles","description":"Switch the default font stack and size to match the legacy UI","type":"feature","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-14T00:59:37.686539676Z","updated":"2026-02-14T01:25:03.119825567Z"} {"id":"NK-a7c6lb","title":"coverage status","description":"check coverage status -- are we still close to 80%\nit's ok to ignore the old static legacy javascript or vanilla js prototype\nif it's low file a ticket to get coverage back up","type":"task","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-14T17:32:19.995215347Z","updated":"2026-02-14T18:03:41.748377361Z"} {"id":"NK-a9hs2m","title":"Update golangci-lint version and config to v2","description":"The local environment uses golangci-lint v2.9.0 which is incompatible with the current v1 config. Running migration updated the .golangci.yml to version 2. We should update the GitHub CI workflow to use a compatible version and ensure local/CI parity.","type":"cleanup","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-15T16:11:21.682298135Z","updated":"2026-02-15T19:08:26.641314931Z"} @@ -49,9 +50,10 @@ {"id":"NK-arckp3","title":"Install golangci-lint in dev environment","description":"Local Makefile uses 'go vet' because 'golangci-lint' is missing. CI uses golangci-lint. We should install it locally for consistency.","type":"task","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-15T05:14:44.840444844Z","updated":"2026-02-15T05:14:44.840444844Z"} {"id":"NK-bsdwqz","title":"terminal UI","description":"once there is good test coverage and a clean backend API, work on a nice efficient TUI with https://github.com/charmbracelet/bubbletea","type":"task","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-13T01:54:02.285738454Z","updated":"2026-02-13T04:42:09.824268427Z"} {"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-cdwj52","title":"Bulk edit feeds in settings","description":"Allow selecting multiple feeds to delete or tag at once.","type":"feature","status":"open","priority":4,"labels":null,"assignee":"","created":"2026-02-16T16:33:54.080449467Z","updated":"2026-02-16T16:33:54.080449467Z"} {"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":"closed","priority":4,"labels":null,"assignee":"","created":"2026-02-15T02:21:34.96843041Z","updated":"2026-02-15T19:14:31.660189629Z"} -{"id":"NK-cuz8gh","title":"v3 feed management","description":"there's no way to delete or add a tag to a feed right now\nlet's add that to the settings page to start.","type":"epic","status":"open","priority":1,"labels":null,"assignee":"","created":"2026-02-16T15:49:33.753016244Z","updated":"2026-02-16T15:49:33.753016244Z"} +{"id":"NK-cuz8gh","title":"v3 feed management","description":"there's no way to delete or add a tag to a feed right now\nlet's add that to the settings page to start.","type":"epic","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-16T15:49:33.753016244Z","updated":"2026-02-16T16:33:44.393899494Z"} {"id":"NK-cv567g","title":"Vanilla JS (v3): Remove inline JS and fix CSP errors","description":"","type":"bug","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-16T02:42:53.916053881Z","updated":"2026-02-16T03:36:10.178537038Z"} {"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":"closed","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"} @@ -186,6 +188,7 @@ {"id":"NK-dgbrb79","from_ticket_id":"NK-9hx0y7","to_ticket_id":"NK-t0nmbj","type":"created_from","created":"2026-02-13T05:44:01.556027956Z"} {"id":"NK-dgfppki","from_ticket_id":"NK-gqkh96","to_ticket_id":"NK-x924bu","type":"created_from","created":"2026-02-13T03:54:30.303602703Z"} {"id":"NK-dgu0o9d","from_ticket_id":"NK-ca9t70","to_ticket_id":"NK-d4c8jv","type":"created_from","created":"2026-02-14T04:47:41.786634182Z"} +{"id":"NK-dgyuzkp","from_ticket_id":"NK-cdwj52","to_ticket_id":"NK-cuz8gh","type":"created_from","created":"2026-02-16T16:33:54.116141693Z"} {"id":"NK-dikc4i2","from_ticket_id":"NK-aibd0t","to_ticket_id":"NK-rhelrq","type":"created_from","created":"2026-02-15T05:20:29.004681893Z"} {"id":"NK-dikvat5","from_ticket_id":"NK-ck4co9","to_ticket_id":"NK-mpb1e1","type":"created_from","created":"2026-02-15T02:21:35.000829694Z"} {"id":"NK-dj3r998","from_ticket_id":"NK-thq2oq","to_ticket_id":"NK-9pgjph","type":"created_from","created":"2026-02-14T03:30:58.76860979Z"} @@ -203,8 +203,10 @@ To include **unread** items in the purge: View all command line options with `-h` or `--help` +```bash $ neko -h - +``` +``` Usage of neko: -a, --add http://example.com/rss.xml add the feed at URL http://example.com/rss.xml @@ -234,6 +236,7 @@ Usage of neko: fetch feeds and store new items -v, --verbose verbose output +``` These are POSIX style flags so -- diff --git a/frontend-vanilla/src/main.test.ts b/frontend-vanilla/src/main.test.ts index d397a5e..c9d0e0c 100644 --- a/frontend-vanilla/src/main.test.ts +++ b/frontend-vanilla/src/main.test.ts @@ -320,4 +320,31 @@ describe('main application logic', () => { expect(navigateSpy).toHaveBeenCalledWith('/', expect.objectContaining({ filter: 'starred' })); getCurrentRouteSpy.mockRestore(); }); + + it('deleteFeed should call API', async () => { + vi.mocked(apiFetch).mockResolvedValueOnce({ ok: true } as Response); + const { deleteFeed } = await import('./main'); + await deleteFeed(123); + expect(apiFetch).toHaveBeenCalledWith('/api/feed/123', expect.objectContaining({ method: 'DELETE' })); + }); + + it('updateFeed should call API', async () => { + vi.mocked(apiFetch).mockResolvedValueOnce({ ok: true } as Response); + const { updateFeed } = await import('./main'); + await updateFeed(123, { category: 'New Tag' }); + expect(apiFetch).toHaveBeenCalledWith('/api/feed', expect.objectContaining({ + method: 'PUT', + body: expect.stringContaining('"category":"New Tag"') + })); + }); + + it('renderSettings should show manage feeds section', () => { + store.setFeeds([{ _id: 1, title: 'My Feed', url: 'http://example.com', category: 'Tech' } as any]); + renderLayout(); + renderSettings(); + const manageSection = document.querySelector('.manage-feeds-section'); + expect(manageSection).not.toBeNull(); + expect(manageSection?.innerHTML).toContain('My Feed'); + expect(document.querySelector('.feed-tag-input')).not.toBeNull(); + }); }); diff --git a/frontend-vanilla/src/main.ts b/frontend-vanilla/src/main.ts index ba842f9..c0adb92 100644 --- a/frontend-vanilla/src/main.ts +++ b/frontend-vanilla/src/main.ts @@ -317,6 +317,25 @@ export function renderSettings() { </div> </section> + <section class="settings-section manage-feeds-section"> + <h3>Manage Feeds</h3> + <ul class="manage-feed-list" style="list-style: none; padding: 0;"> + ${store.feeds.map(feed => ` + <li class="manage-feed-item" style="margin-bottom: 1rem; padding-bottom: 1rem; border-bottom: 1px solid #eee; display: flex; flex-direction: column; gap: 0.5rem;"> + <div class="feed-info"> + <div class="feed-title" style="font-weight: bold;">${feed.title || feed.url}</div> + <div class="feed-url" style="font-size: 0.8em; color: #888; overflow: hidden; text-overflow: ellipsis;">${feed.url}</div> + </div> + <div class="feed-actions" style="display: flex; gap: 0.5rem;"> + <input type="text" class="feed-tag-input" data-id="${feed._id}" value="${feed.category || ''}" placeholder="Tag" style="flex: 1; padding: 0.4rem;"> + <button class="update-feed-tag-btn" data-id="${feed._id}" style="padding: 0.4rem 0.8rem;">Save</button> + <button class="delete-feed-btn" data-id="${feed._id}" style="padding: 0.4rem 0.8rem; color: red;">Delete</button> + </div> + </li> + `).join('')} + </ul> + </section> + <section class="settings-section"> <h3>Data Management</h3> <div class="data-actions"> @@ -375,6 +394,43 @@ export function renderSettings() { } } }); + + // Feed Management Listeners + document.querySelectorAll('.delete-feed-btn').forEach(btn => { + btn.addEventListener('click', async (e) => { + const id = parseInt((e.target as HTMLElement).getAttribute('data-id')!); + if (confirm('Are you sure you want to delete this feed?')) { + await deleteFeed(id); + fetchFeeds(); + // re-render settings to remove the deleted feed from list + // delay slightly to allow feed fetch? No, fetchFeeds is async. + // We should await fetchFeeds before re-rendering? + // But fetchFeeds updates store, and store emits 'feeds-updated'. + // Does 'feeds-updated' re-render settings? + // No, 'feeds-updated' calls renderFeeds (the sidebar list). + // So we need to explicitly call renderSettings() to update the management list. + // But we should wait for fetchFeeds() to complete so store is updated. + // wait... fetchFeeds() is async but we don't await result in the listener above? + // Ah, fetchFeeds() returns Promise. + await fetchFeeds(); + renderSettings(); + } + }); + }); + + document.querySelectorAll('.update-feed-tag-btn').forEach(btn => { + btn.addEventListener('click', async (e) => { + const id = parseInt((e.target as HTMLElement).getAttribute('data-id')!); + const input = document.querySelector(`.feed-tag-input[data-id="${id}"]`) as HTMLInputElement; + const category = input.value.trim(); + await updateFeed(id, { category }); + // updateFeed returns boolean, assuming success + await fetchFeeds(); + await fetchTags(); + renderSettings(); // Update list to show persistence + alert('Feed updated'); + }); + }); } async function addFeed(url: string): Promise<boolean> { @@ -414,6 +470,30 @@ async function importOPML(file: File): Promise<boolean> { } } +export async function deleteFeed(id: number): Promise<boolean> { + try { + const res = await apiFetch(`/api/feed/${id}`, { method: 'DELETE' }); + return res.ok; + } catch (err) { + console.error('Failed to delete feed', err); + return false; + } +} + +export async function updateFeed(id: number, updates: Partial<Feed>): Promise<boolean> { + try { + const res = await apiFetch('/api/feed', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...updates, _id: id }) + }); + return res.ok; + } catch (err) { + console.error('Failed to update feed', err); + return false; + } +} + // --- Data Actions --- export async function toggleStar(id: number) { |
