aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.thicket/tickets.jsonl5
-rw-r--r--README.md5
-rw-r--r--frontend-vanilla/src/main.test.ts27
-rw-r--r--frontend-vanilla/src/main.ts80
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"}
diff --git a/README.md b/README.md
index 7471203..7254923 100644
--- a/README.md
+++ b/README.md
@@ -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) {