aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-13 06:55:21 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-13 06:55:21 -0800
commit3ba71500bc2d60a00ca81b9439305029670f4d52 (patch)
tree5752a119effd739c62c80c2d15d4520c2e53eadf
parent2c3cad528a247c771bca136466337877f76f280f (diff)
downloadneko-3ba71500bc2d60a00ca81b9439305029670f4d52.tar.gz
neko-3ba71500bc2d60a00ca81b9439305029670f4d52.tar.bz2
neko-3ba71500bc2d60a00ca81b9439305029670f4d52.zip
Implement Frontend Feed Items View with tests
-rw-r--r--.thicket/tickets.jsonl4
-rw-r--r--frontend/coverage/clover.xml66
-rw-r--r--frontend/coverage/coverage-final.json6
-rw-r--r--frontend/coverage/index.html38
-rw-r--r--frontend/coverage/src/App.css.html2
-rw-r--r--frontend/coverage/src/App.tsx.html18
-rw-r--r--frontend/coverage/src/components/FeedItems.css.html262
-rw-r--r--frontend/coverage/src/components/FeedItems.tsx.html283
-rw-r--r--frontend/coverage/src/components/FeedList.css.html2
-rw-r--r--frontend/coverage/src/components/FeedList.tsx.html25
-rw-r--r--frontend/coverage/src/components/Login.css.html2
-rw-r--r--frontend/coverage/src/components/Login.tsx.html2
-rw-r--r--frontend/coverage/src/components/index.html48
-rw-r--r--frontend/coverage/src/index.html2
-rw-r--r--frontend/src/App.tsx6
-rw-r--r--frontend/src/components/FeedItems.css60
-rw-r--r--frontend/src/components/FeedItems.test.tsx52
-rw-r--r--frontend/src/components/FeedItems.tsx66
-rw-r--r--frontend/src/components/FeedList.test.tsx26
-rw-r--r--frontend/src/components/FeedList.tsx5
-rw-r--r--frontend/src/types.ts14
21 files changed, 912 insertions, 77 deletions
diff --git a/.thicket/tickets.jsonl b/.thicket/tickets.jsonl
index e3174e1..8af7e2b 100644
--- a/.thicket/tickets.jsonl
+++ b/.thicket/tickets.jsonl
@@ -2,12 +2,13 @@
{"id":"NK-0ppv3f","title":"Implement Frontend Settings","description":"Create settings page for managing feeds/categories.","type":"task","status":"open","priority":2,"labels":null,"assignee":"","created":"2026-02-13T05:44:01.631640578Z","updated":"2026-02-13T05:44:01.631640578Z"}
{"id":"NK-1phdpf","title":"refactor backend to have a clean API","description":"create a nice clean API for the backend GO code that is more independent of the frontend\n\nensure that it is working with good tests","type":"task","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-13T01:52:49.8322638Z","updated":"2026-02-13T04:26:47.517515371Z"}
{"id":"NK-27or4b","title":"Increase Test Coverage to \u003e80%","description":"Project-wide test coverage is currently ~63%. Key gaps are in the new and packages, as well as some core model logic. Increase coverage to at least 80% to ensure stability.","type":"task","status":"open","priority":3,"labels":null,"assignee":"","created":"2026-02-13T05:03:09.677147894Z","updated":"2026-02-13T05:03:09.677147894Z"}
-{"id":"NK-3om7x2","title":"Implement Feed Items View","description":"Create a component to display items for a selected feed. Fetch items from /api/stream?feed_id=...","type":"","status":"open","priority":1,"labels":null,"assignee":"","created":"2026-02-13T05:59:46.161356437Z","updated":"2026-02-13T05:59:46.161356437Z"}
+{"id":"NK-3om7x2","title":"Implement Feed Items View","description":"Create a component to display items for a selected feed. Fetch items from /api/stream?feed_id=...","type":"","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-13T05:59:46.161356437Z","updated":"2026-02-13T14:55:14.795643835Z"}
{"id":"NK-6q9nyg","title":"Refactor HTTP-dependent functions for testability","description":"Several functions use http.Get or external libraries directly (GetFullContent uses goose, ResolveFeedURL uses http.Get + goquery, imageProxyHandler uses http.Client). Refactor these to accept interfaces for HTTP fetching so they can be unit tested with mocks. This is the primary blocker for reaching 90% coverage.","type":"cleanup","status":"open","priority":3,"labels":null,"assignee":"","created":"2026-02-13T03:54:37.630148644Z","updated":"2026-02-13T03:54:37.630148644Z"}
{"id":"NK-7tzbql","title":"Fix TUI Content View Navigation and Interaction","description":"The TUI content view (reading a single item) is currently non-functional or severely limited. Users cannot easily navigate back, scroll, or interact with the content. This task involves improving the 'viewContent' state in the TUI.","type":"bug","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-13T05:02:57.382793121Z","updated":"2026-02-13T05:06:15.144485446Z"}
{"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-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-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":"open","priority":3,"labels":null,"assignee":"","created":"2026-02-13T04:26:55.908243985Z","updated":"2026-02-13T04:26:55.908243985Z"}
+{"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":"open","priority":1,"labels":null,"assignee":"","created":"2026-02-13T14:55:14.825454967Z","updated":"2026-02-13T14:55:14.825454967Z"}
{"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":"open","priority":3,"labels":null,"assignee":"","created":"2026-02-13T03:54:30.298141982Z","updated":"2026-02-13T03:54:30.298141982Z"}
{"id":"NK-op5594","title":"Ensure 80% Frontend Test Coverage","description":"Configure coverage reporting in vitest and ensure the frontend codebase maintains at least 80% test coverage.","type":"task","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-13T05:46:24.13314466Z","updated":"2026-02-13T05:50:46.728239299Z"}
{"id":"NK-ric1zs","title":"Migrate frontend to /api/ endpoints","description":"The backend now provides a clean REST API at /api/. Update the frontend UI to use these new endpoints instead of the legacy backward-compatibility routes (/stream/, /feed/, etc.). This will allow for cleaner separation and better utilization of proper REST patterns.","type":"cleanup","status":"open","priority":3,"labels":null,"assignee":"","created":"2026-02-13T04:26:55.864725765Z","updated":"2026-02-13T04:26:55.864725765Z"}
@@ -24,4 +25,5 @@
{"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-dm75oc8","from_ticket_id":"NK-3om7x2","to_ticket_id":"NK-zt4e32","type":"created_from","created":"2026-02-13T05:59:46.169842933Z"}
{"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-dw8luqe","from_ticket_id":"NK-ek0cox","to_ticket_id":"NK-3om7x2","type":"created_from","created":"2026-02-13T14:55:14.832352853Z"}
{"id":"NK-dwav3hh","from_ticket_id":"NK-6q9nyg","to_ticket_id":"NK-x924bu","type":"created_from","created":"2026-02-13T03:54:37.639569082Z"}
diff --git a/frontend/coverage/clover.xml b/frontend/coverage/clover.xml
index 5e830d2..8251808 100644
--- a/frontend/coverage/clover.xml
+++ b/frontend/coverage/clover.xml
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
-<coverage generated="1770962373767" clover="3.2.0">
- <project timestamp="1770962373767" name="All files">
- <metrics statements="48" coveredstatements="44" conditionals="24" coveredconditionals="19" methods="15" coveredmethods="14" elements="87" coveredelements="77" complexity="0" loc="48" ncloc="48" packages="2" files="6" classes="6"/>
+<coverage generated="1770962571342" clover="3.2.0">
+ <project timestamp="1770962571342" name="All files">
+ <metrics statements="68" coveredstatements="61" conditionals="42" coveredconditionals="30" methods="21" coveredmethods="19" elements="131" coveredelements="110" complexity="0" loc="68" ncloc="68" packages="2" files="8" classes="8"/>
<package name="src">
<metrics statements="15" coveredstatements="12" conditionals="6" coveredconditionals="4" methods="6" coveredmethods="5"/>
<file name="App.css" path="/Users/adam/workspace/vibecode/neko/frontend/src/App.css">
@@ -22,33 +22,59 @@
<line num="27" count="1" type="cond" truecount="1" falsecount="1"/>
<line num="28" count="0" type="stmt"/>
<line num="31" count="1" type="stmt"/>
- <line num="37" count="1" type="stmt"/>
- <line num="58" count="2" type="stmt"/>
+ <line num="38" count="1" type="stmt"/>
+ <line num="62" count="2" type="stmt"/>
</file>
</package>
<package name="src.components">
- <metrics statements="33" coveredstatements="32" conditionals="18" coveredconditionals="15" methods="9" coveredmethods="9"/>
+ <metrics statements="53" coveredstatements="49" conditionals="36" coveredconditionals="26" methods="15" coveredmethods="14"/>
+ <file name="FeedItems.css" path="/Users/adam/workspace/vibecode/neko/frontend/src/components/FeedItems.css">
+ <metrics statements="0" coveredstatements="0" conditionals="0" coveredconditionals="0" methods="0" coveredmethods="0"/>
+ </file>
+ <file name="FeedItems.tsx" path="/Users/adam/workspace/vibecode/neko/frontend/src/components/FeedItems.tsx">
+ <metrics statements="20" coveredstatements="17" conditionals="18" coveredconditionals="11" methods="6" coveredmethods="5"/>
+ <line num="7" count="3" type="stmt"/>
+ <line num="8" count="3" type="stmt"/>
+ <line num="9" count="3" type="stmt"/>
+ <line num="10" count="3" type="stmt"/>
+ <line num="12" count="3" type="stmt"/>
+ <line num="13" count="2" type="stmt"/>
+ <line num="14" count="2" type="stmt"/>
+ <line num="16" count="2" type="cond" truecount="1" falsecount="1"/>
+ <line num="20" count="2" type="stmt"/>
+ <line num="22" count="1" type="cond" truecount="1" falsecount="1"/>
+ <line num="23" count="0" type="stmt"/>
+ <line num="25" count="1" type="stmt"/>
+ <line num="28" count="1" type="stmt"/>
+ <line num="29" count="1" type="stmt"/>
+ <line num="32" count="0" type="stmt"/>
+ <line num="33" count="0" type="stmt"/>
+ <line num="37" count="3" type="cond" truecount="2" falsecount="0"/>
+ <line num="38" count="1" type="cond" truecount="1" falsecount="1"/>
+ <line num="40" count="1" type="stmt"/>
+ <line num="49" count="2" type="cond" truecount="2" falsecount="0"/>
+ </file>
<file name="FeedList.css" path="/Users/adam/workspace/vibecode/neko/frontend/src/components/FeedList.css">
<metrics statements="0" coveredstatements="0" conditionals="0" coveredconditionals="0" methods="0" coveredmethods="0"/>
</file>
<file name="FeedList.tsx" path="/Users/adam/workspace/vibecode/neko/frontend/src/components/FeedList.tsx">
<metrics statements="16" coveredstatements="15" conditionals="12" coveredconditionals="10" methods="6" coveredmethods="6"/>
- <line num="6" count="9" type="stmt"/>
- <line num="7" count="9" type="stmt"/>
- <line num="8" count="9" type="stmt"/>
- <line num="10" count="9" type="stmt"/>
- <line num="11" count="5" type="stmt"/>
- <line num="13" count="3" type="cond" truecount="1" falsecount="1"/>
- <line num="14" count="0" type="stmt"/>
- <line num="16" count="3" type="stmt"/>
- <line num="19" count="3" type="stmt"/>
+ <line num="7" count="8" type="stmt"/>
+ <line num="8" count="8" type="stmt"/>
+ <line num="9" count="8" type="stmt"/>
+ <line num="11" count="8" type="stmt"/>
+ <line num="12" count="5" type="stmt"/>
+ <line num="14" count="3" type="cond" truecount="1" falsecount="1"/>
+ <line num="15" count="0" type="stmt"/>
+ <line num="17" count="3" type="stmt"/>
<line num="20" count="3" type="stmt"/>
- <line num="23" count="1" type="stmt"/>
+ <line num="21" count="3" type="stmt"/>
<line num="24" count="1" type="stmt"/>
- <line num="28" count="9" type="cond" truecount="2" falsecount="0"/>
- <line num="29" count="4" type="cond" truecount="2" falsecount="0"/>
- <line num="31" count="3" type="stmt"/>
- <line num="39" count="2" type="stmt"/>
+ <line num="25" count="1" type="stmt"/>
+ <line num="29" count="8" type="cond" truecount="2" falsecount="0"/>
+ <line num="30" count="3" type="cond" truecount="2" falsecount="0"/>
+ <line num="32" count="2" type="stmt"/>
+ <line num="40" count="2" type="stmt"/>
</file>
<file name="Login.css" path="/Users/adam/workspace/vibecode/neko/frontend/src/components/Login.css">
<metrics statements="0" coveredstatements="0" conditionals="0" coveredconditionals="0" methods="0" coveredmethods="0"/>
diff --git a/frontend/coverage/coverage-final.json b/frontend/coverage/coverage-final.json
index 1fe79ba..8fc3ade 100644
--- a/frontend/coverage/coverage-final.json
+++ b/frontend/coverage/coverage-final.json
@@ -1,7 +1,9 @@
{"/Users/adam/workspace/vibecode/neko/frontend/src/App.css": {"path":"/Users/adam/workspace/vibecode/neko/frontend/src/App.css","statementMap":{},"fnMap":{},"branchMap":{},"s":{},"f":{},"b":{},"meta":{"lastBranch":0,"lastFunction":0,"lastStatement":0,"seen":{}}}
-,"/Users/adam/workspace/vibecode/neko/frontend/src/App.tsx": {"path":"/Users/adam/workspace/vibecode/neko/frontend/src/App.tsx","statementMap":{"0":{"start":{"line":8,"column":22},"end":{"line":8,"column":null}},"1":{"start":{"line":9,"column":8},"end":{"line":9,"column":null}},"2":{"start":{"line":11,"column":2},"end":{"line":21,"column":null}},"3":{"start":{"line":12,"column":4},"end":{"line":20,"column":null}},"4":{"start":{"line":14,"column":8},"end":{"line":18,"column":null}},"5":{"start":{"line":15,"column":10},"end":{"line":15,"column":null}},"6":{"start":{"line":17,"column":10},"end":{"line":17,"column":null}},"7":{"start":{"line":20,"column":19},"end":{"line":20,"column":33}},"8":{"start":{"line":23,"column":2},"end":{"line":25,"column":null}},"9":{"start":{"line":24,"column":4},"end":{"line":24,"column":null}},"10":{"start":{"line":27,"column":2},"end":{"line":29,"column":null}},"11":{"start":{"line":28,"column":4},"end":{"line":28,"column":null}},"12":{"start":{"line":31,"column":2},"end":{"line":31,"column":null}},"13":{"start":{"line":37,"column":2},"end":{"line":53,"column":null}},"14":{"start":{"line":58,"column":2},"end":{"line":71,"column":null}}},"fnMap":{"0":{"name":"RequireAuth","decl":{"start":{"line":7,"column":9},"end":{"line":7,"column":21}},"loc":{"start":{"line":7,"column":69},"end":{"line":32,"column":null}},"line":7},"1":{"name":"(anonymous_1)","decl":{"start":{"line":11,"column":12},"end":{"line":11,"column":18}},"loc":{"start":{"line":11,"column":18},"end":{"line":21,"column":5}},"line":11},"2":{"name":"(anonymous_2)","decl":{"start":{"line":13,"column":12},"end":{"line":13,"column":13}},"loc":{"start":{"line":13,"column":21},"end":{"line":19,"column":7}},"line":13},"3":{"name":"(anonymous_3)","decl":{"start":{"line":20,"column":13},"end":{"line":20,"column":19}},"loc":{"start":{"line":20,"column":19},"end":{"line":20,"column":33}},"line":20},"4":{"name":"Dashboard","decl":{"start":{"line":36,"column":9},"end":{"line":36,"column":21}},"loc":{"start":{"line":36,"column":21},"end":{"line":55,"column":null}},"line":36},"5":{"name":"App","decl":{"start":{"line":57,"column":9},"end":{"line":57,"column":15}},"loc":{"start":{"line":57,"column":15},"end":{"line":73,"column":null}},"line":57}},"branchMap":{"0":{"loc":{"start":{"line":14,"column":8},"end":{"line":18,"column":null}},"type":"if","locations":[{"start":{"line":14,"column":8},"end":{"line":18,"column":null}},{"start":{"line":16,"column":15},"end":{"line":18,"column":null}}],"line":14},"1":{"loc":{"start":{"line":23,"column":2},"end":{"line":25,"column":null}},"type":"if","locations":[{"start":{"line":23,"column":2},"end":{"line":25,"column":null}},{"start":{},"end":{}}],"line":23},"2":{"loc":{"start":{"line":27,"column":2},"end":{"line":29,"column":null}},"type":"if","locations":[{"start":{"line":27,"column":2},"end":{"line":29,"column":null}},{"start":{},"end":{}}],"line":27}},"s":{"0":2,"1":2,"2":2,"3":1,"4":1,"5":1,"6":0,"7":0,"8":2,"9":1,"10":1,"11":0,"12":1,"13":1,"14":2},"f":{"0":2,"1":1,"2":1,"3":0,"4":1,"5":2},"b":{"0":[1,0],"1":[1,1],"2":[0,1]},"meta":{"lastBranch":3,"lastFunction":6,"lastStatement":15,"seen":{"f:7:9:7:21":0,"s:8:22:8:Infinity":0,"s:9:8:9:Infinity":1,"s:11:2:21:Infinity":2,"f:11:12:11:18":1,"s:12:4:20:Infinity":3,"f:13:12:13:13":2,"b:14:8:18:Infinity:16:15:18:Infinity":0,"s:14:8:18:Infinity":4,"s:15:10:15:Infinity":5,"s:17:10:17:Infinity":6,"f:20:13:20:19":3,"s:20:19:20:33":7,"b:23:2:25:Infinity:undefined:undefined:undefined:undefined":1,"s:23:2:25:Infinity":8,"s:24:4:24:Infinity":9,"b:27:2:29:Infinity:undefined:undefined:undefined:undefined":2,"s:27:2:29:Infinity":10,"s:28:4:28:Infinity":11,"s:31:2:31:Infinity":12,"f:36:9:36:21":4,"s:37:2:53:Infinity":13,"f:57:9:57:15":5,"s:58:2:71:Infinity":14}}}
+,"/Users/adam/workspace/vibecode/neko/frontend/src/App.tsx": {"path":"/Users/adam/workspace/vibecode/neko/frontend/src/App.tsx","statementMap":{"0":{"start":{"line":8,"column":22},"end":{"line":8,"column":null}},"1":{"start":{"line":9,"column":8},"end":{"line":9,"column":null}},"2":{"start":{"line":11,"column":2},"end":{"line":21,"column":null}},"3":{"start":{"line":12,"column":4},"end":{"line":20,"column":null}},"4":{"start":{"line":14,"column":8},"end":{"line":18,"column":null}},"5":{"start":{"line":15,"column":10},"end":{"line":15,"column":null}},"6":{"start":{"line":17,"column":10},"end":{"line":17,"column":null}},"7":{"start":{"line":20,"column":19},"end":{"line":20,"column":33}},"8":{"start":{"line":23,"column":2},"end":{"line":25,"column":null}},"9":{"start":{"line":24,"column":4},"end":{"line":24,"column":null}},"10":{"start":{"line":27,"column":2},"end":{"line":29,"column":null}},"11":{"start":{"line":28,"column":4},"end":{"line":28,"column":null}},"12":{"start":{"line":31,"column":2},"end":{"line":31,"column":null}},"13":{"start":{"line":38,"column":2},"end":{"line":57,"column":null}},"14":{"start":{"line":62,"column":2},"end":{"line":75,"column":null}}},"fnMap":{"0":{"name":"RequireAuth","decl":{"start":{"line":7,"column":9},"end":{"line":7,"column":21}},"loc":{"start":{"line":7,"column":69},"end":{"line":32,"column":null}},"line":7},"1":{"name":"(anonymous_1)","decl":{"start":{"line":11,"column":12},"end":{"line":11,"column":18}},"loc":{"start":{"line":11,"column":18},"end":{"line":21,"column":5}},"line":11},"2":{"name":"(anonymous_2)","decl":{"start":{"line":13,"column":12},"end":{"line":13,"column":13}},"loc":{"start":{"line":13,"column":21},"end":{"line":19,"column":7}},"line":13},"3":{"name":"(anonymous_3)","decl":{"start":{"line":20,"column":13},"end":{"line":20,"column":19}},"loc":{"start":{"line":20,"column":19},"end":{"line":20,"column":33}},"line":20},"4":{"name":"Dashboard","decl":{"start":{"line":37,"column":9},"end":{"line":37,"column":21}},"loc":{"start":{"line":37,"column":21},"end":{"line":59,"column":null}},"line":37},"5":{"name":"App","decl":{"start":{"line":61,"column":9},"end":{"line":61,"column":15}},"loc":{"start":{"line":61,"column":15},"end":{"line":77,"column":null}},"line":61}},"branchMap":{"0":{"loc":{"start":{"line":14,"column":8},"end":{"line":18,"column":null}},"type":"if","locations":[{"start":{"line":14,"column":8},"end":{"line":18,"column":null}},{"start":{"line":16,"column":15},"end":{"line":18,"column":null}}],"line":14},"1":{"loc":{"start":{"line":23,"column":2},"end":{"line":25,"column":null}},"type":"if","locations":[{"start":{"line":23,"column":2},"end":{"line":25,"column":null}},{"start":{},"end":{}}],"line":23},"2":{"loc":{"start":{"line":27,"column":2},"end":{"line":29,"column":null}},"type":"if","locations":[{"start":{"line":27,"column":2},"end":{"line":29,"column":null}},{"start":{},"end":{}}],"line":27}},"s":{"0":2,"1":2,"2":2,"3":1,"4":1,"5":1,"6":0,"7":0,"8":2,"9":1,"10":1,"11":0,"12":1,"13":1,"14":2},"f":{"0":2,"1":1,"2":1,"3":0,"4":1,"5":2},"b":{"0":[1,0],"1":[1,1],"2":[0,1]},"meta":{"lastBranch":3,"lastFunction":6,"lastStatement":15,"seen":{"f:7:9:7:21":0,"s:8:22:8:Infinity":0,"s:9:8:9:Infinity":1,"s:11:2:21:Infinity":2,"f:11:12:11:18":1,"s:12:4:20:Infinity":3,"f:13:12:13:13":2,"b:14:8:18:Infinity:16:15:18:Infinity":0,"s:14:8:18:Infinity":4,"s:15:10:15:Infinity":5,"s:17:10:17:Infinity":6,"f:20:13:20:19":3,"s:20:19:20:33":7,"b:23:2:25:Infinity:undefined:undefined:undefined:undefined":1,"s:23:2:25:Infinity":8,"s:24:4:24:Infinity":9,"b:27:2:29:Infinity:undefined:undefined:undefined:undefined":2,"s:27:2:29:Infinity":10,"s:28:4:28:Infinity":11,"s:31:2:31:Infinity":12,"f:37:9:37:21":4,"s:38:2:57:Infinity":13,"f:61:9:61:15":5,"s:62:2:75:Infinity":14}}}
+,"/Users/adam/workspace/vibecode/neko/frontend/src/components/FeedItems.css": {"path":"/Users/adam/workspace/vibecode/neko/frontend/src/components/FeedItems.css","statementMap":{},"fnMap":{},"branchMap":{},"s":{},"f":{},"b":{},"meta":{"lastBranch":0,"lastFunction":0,"lastStatement":0,"seen":{}}}
+,"/Users/adam/workspace/vibecode/neko/frontend/src/components/FeedItems.tsx": {"path":"/Users/adam/workspace/vibecode/neko/frontend/src/components/FeedItems.tsx","statementMap":{"0":{"start":{"line":7,"column":19},"end":{"line":7,"column":null}},"1":{"start":{"line":8,"column":26},"end":{"line":8,"column":null}},"2":{"start":{"line":9,"column":30},"end":{"line":9,"column":null}},"3":{"start":{"line":10,"column":26},"end":{"line":10,"column":null}},"4":{"start":{"line":12,"column":4},"end":{"line":35,"column":null}},"5":{"start":{"line":13,"column":8},"end":{"line":13,"column":null}},"6":{"start":{"line":14,"column":8},"end":{"line":14,"column":null}},"7":{"start":{"line":16,"column":20},"end":{"line":18,"column":null}},"8":{"start":{"line":20,"column":8},"end":{"line":34,"column":null}},"9":{"start":{"line":22,"column":16},"end":{"line":24,"column":null}},"10":{"start":{"line":23,"column":20},"end":{"line":23,"column":null}},"11":{"start":{"line":25,"column":16},"end":{"line":25,"column":null}},"12":{"start":{"line":28,"column":16},"end":{"line":28,"column":null}},"13":{"start":{"line":29,"column":16},"end":{"line":29,"column":null}},"14":{"start":{"line":32,"column":16},"end":{"line":32,"column":null}},"15":{"start":{"line":33,"column":16},"end":{"line":33,"column":null}},"16":{"start":{"line":37,"column":4},"end":{"line":37,"column":null}},"17":{"start":{"line":37,"column":17},"end":{"line":37,"column":null}},"18":{"start":{"line":38,"column":4},"end":{"line":38,"column":null}},"19":{"start":{"line":38,"column":15},"end":{"line":38,"column":null}},"20":{"start":{"line":40,"column":4},"end":{"line":64,"column":null}},"21":{"start":{"line":49,"column":24},"end":{"line":60,"column":null}}},"fnMap":{"0":{"name":"FeedItems","decl":{"start":{"line":6,"column":24},"end":{"line":6,"column":36}},"loc":{"start":{"line":6,"column":36},"end":{"line":66,"column":null}},"line":6},"1":{"name":"(anonymous_1)","decl":{"start":{"line":12,"column":14},"end":{"line":12,"column":20}},"loc":{"start":{"line":12,"column":20},"end":{"line":35,"column":7}},"line":12},"2":{"name":"(anonymous_2)","decl":{"start":{"line":21,"column":18},"end":{"line":21,"column":19}},"loc":{"start":{"line":21,"column":27},"end":{"line":26,"column":13}},"line":21},"3":{"name":"(anonymous_3)","decl":{"start":{"line":27,"column":18},"end":{"line":27,"column":19}},"loc":{"start":{"line":27,"column":28},"end":{"line":30,"column":13}},"line":27},"4":{"name":"(anonymous_4)","decl":{"start":{"line":31,"column":19},"end":{"line":31,"column":20}},"loc":{"start":{"line":31,"column":28},"end":{"line":34,"column":13}},"line":31},"5":{"name":"(anonymous_5)","decl":{"start":{"line":48,"column":31},"end":{"line":48,"column":32}},"loc":{"start":{"line":49,"column":24},"end":{"line":60,"column":null}},"line":49}},"branchMap":{"0":{"loc":{"start":{"line":16,"column":20},"end":{"line":18,"column":null}},"type":"cond-expr","locations":[{"start":{"line":17,"column":14},"end":{"line":17,"column":null}},{"start":{"line":18,"column":14},"end":{"line":18,"column":null}}],"line":16},"1":{"loc":{"start":{"line":22,"column":16},"end":{"line":24,"column":null}},"type":"if","locations":[{"start":{"line":22,"column":16},"end":{"line":24,"column":null}},{"start":{},"end":{}}],"line":22},"2":{"loc":{"start":{"line":37,"column":4},"end":{"line":37,"column":null}},"type":"if","locations":[{"start":{"line":37,"column":4},"end":{"line":37,"column":null}},{"start":{},"end":{}}],"line":37},"3":{"loc":{"start":{"line":38,"column":4},"end":{"line":38,"column":null}},"type":"if","locations":[{"start":{"line":38,"column":4},"end":{"line":38,"column":null}},{"start":{},"end":{}}],"line":38},"4":{"loc":{"start":{"line":44,"column":13},"end":{"line":62,"column":null}},"type":"cond-expr","locations":[{"start":{"line":45,"column":16},"end":{"line":45,"column":null}},{"start":{"line":47,"column":16},"end":{"line":62,"column":null}}],"line":44},"5":{"loc":{"start":{"line":49,"column":62},"end":{"line":49,"column":91}},"type":"cond-expr","locations":[{"start":{"line":49,"column":74},"end":{"line":49,"column":83}},{"start":{"line":49,"column":83},"end":{"line":49,"column":91}}],"line":49},"6":{"loc":{"start":{"line":51,"column":33},"end":{"line":51,"column":null}},"type":"binary-expr","locations":[{"start":{"line":51,"column":33},"end":{"line":51,"column":47}},{"start":{"line":51,"column":47},"end":{"line":51,"column":null}}],"line":51},"7":{"loc":{"start":{"line":55,"column":33},"end":{"line":55,"column":null}},"type":"binary-expr","locations":[{"start":{"line":55,"column":33},"end":{"line":55,"column":52}},{"start":{"line":55,"column":52},"end":{"line":55,"column":null}}],"line":55},"8":{"loc":{"start":{"line":57,"column":29},"end":{"line":58,"column":null}},"type":"binary-expr","locations":[{"start":{"line":57,"column":29},"end":{"line":57,"column":null}},{"start":{"line":58,"column":32},"end":{"line":58,"column":null}}],"line":57}},"s":{"0":3,"1":3,"2":3,"3":3,"4":3,"5":2,"6":2,"7":2,"8":2,"9":1,"10":0,"11":1,"12":1,"13":1,"14":0,"15":0,"16":3,"17":2,"18":1,"19":1,"20":1,"21":2},"f":{"0":3,"1":2,"2":1,"3":1,"4":0,"5":2},"b":{"0":[2,0],"1":[0,1],"2":[2,1],"3":[0,1],"4":[0,1],"5":[1,1],"6":[2,0],"7":[2,0],"8":[2,0]},"meta":{"lastBranch":9,"lastFunction":6,"lastStatement":22,"seen":{"f:6:24:6:36":0,"s:7:19:7:Infinity":0,"s:8:26:8:Infinity":1,"s:9:30:9:Infinity":2,"s:10:26:10:Infinity":3,"s:12:4:35:Infinity":4,"f:12:14:12:20":1,"s:13:8:13:Infinity":5,"s:14:8:14:Infinity":6,"s:16:20:18:Infinity":7,"b:17:14:17:Infinity:18:14:18:Infinity":0,"s:20:8:34:Infinity":8,"f:21:18:21:19":2,"b:22:16:24:Infinity:undefined:undefined:undefined:undefined":1,"s:22:16:24:Infinity":9,"s:23:20:23:Infinity":10,"s:25:16:25:Infinity":11,"f:27:18:27:19":3,"s:28:16:28:Infinity":12,"s:29:16:29:Infinity":13,"f:31:19:31:20":4,"s:32:16:32:Infinity":14,"s:33:16:33:Infinity":15,"b:37:4:37:Infinity:undefined:undefined:undefined:undefined":2,"s:37:4:37:Infinity":16,"s:37:17:37:Infinity":17,"b:38:4:38:Infinity:undefined:undefined:undefined:undefined":3,"s:38:4:38:Infinity":18,"s:38:15:38:Infinity":19,"s:40:4:64:Infinity":20,"b:45:16:45:Infinity:47:16:62:Infinity":4,"f:48:31:48:32":5,"s:49:24:60:Infinity":21,"b:49:74:49:83:49:83:49:91":5,"b:51:33:51:47:51:47:51:Infinity":6,"b:55:33:55:52:55:52:55:Infinity":7,"b:57:29:57:Infinity:58:32:58:Infinity":8}}}
,"/Users/adam/workspace/vibecode/neko/frontend/src/components/FeedList.css": {"path":"/Users/adam/workspace/vibecode/neko/frontend/src/components/FeedList.css","statementMap":{},"fnMap":{},"branchMap":{},"s":{},"f":{},"b":{},"meta":{"lastBranch":0,"lastFunction":0,"lastStatement":0,"seen":{}}}
-,"/Users/adam/workspace/vibecode/neko/frontend/src/components/FeedList.tsx": {"path":"/Users/adam/workspace/vibecode/neko/frontend/src/components/FeedList.tsx","statementMap":{"0":{"start":{"line":6,"column":26},"end":{"line":6,"column":null}},"1":{"start":{"line":7,"column":30},"end":{"line":7,"column":null}},"2":{"start":{"line":8,"column":26},"end":{"line":8,"column":null}},"3":{"start":{"line":10,"column":4},"end":{"line":26,"column":null}},"4":{"start":{"line":11,"column":8},"end":{"line":25,"column":null}},"5":{"start":{"line":13,"column":16},"end":{"line":15,"column":null}},"6":{"start":{"line":14,"column":20},"end":{"line":14,"column":null}},"7":{"start":{"line":16,"column":16},"end":{"line":16,"column":null}},"8":{"start":{"line":19,"column":16},"end":{"line":19,"column":null}},"9":{"start":{"line":20,"column":16},"end":{"line":20,"column":null}},"10":{"start":{"line":23,"column":16},"end":{"line":23,"column":null}},"11":{"start":{"line":24,"column":16},"end":{"line":24,"column":null}},"12":{"start":{"line":28,"column":4},"end":{"line":28,"column":null}},"13":{"start":{"line":28,"column":17},"end":{"line":28,"column":null}},"14":{"start":{"line":29,"column":4},"end":{"line":29,"column":null}},"15":{"start":{"line":29,"column":15},"end":{"line":29,"column":null}},"16":{"start":{"line":31,"column":4},"end":{"line":48,"column":null}},"17":{"start":{"line":39,"column":24},"end":{"line":44,"column":null}}},"fnMap":{"0":{"name":"FeedList","decl":{"start":{"line":5,"column":24},"end":{"line":5,"column":35}},"loc":{"start":{"line":5,"column":35},"end":{"line":50,"column":null}},"line":5},"1":{"name":"(anonymous_1)","decl":{"start":{"line":10,"column":14},"end":{"line":10,"column":20}},"loc":{"start":{"line":10,"column":20},"end":{"line":26,"column":7}},"line":10},"2":{"name":"(anonymous_2)","decl":{"start":{"line":12,"column":18},"end":{"line":12,"column":19}},"loc":{"start":{"line":12,"column":27},"end":{"line":17,"column":13}},"line":12},"3":{"name":"(anonymous_3)","decl":{"start":{"line":18,"column":18},"end":{"line":18,"column":19}},"loc":{"start":{"line":18,"column":28},"end":{"line":21,"column":13}},"line":18},"4":{"name":"(anonymous_4)","decl":{"start":{"line":22,"column":19},"end":{"line":22,"column":20}},"loc":{"start":{"line":22,"column":28},"end":{"line":25,"column":13}},"line":22},"5":{"name":"(anonymous_5)","decl":{"start":{"line":38,"column":31},"end":{"line":38,"column":32}},"loc":{"start":{"line":39,"column":24},"end":{"line":44,"column":null}},"line":39}},"branchMap":{"0":{"loc":{"start":{"line":13,"column":16},"end":{"line":15,"column":null}},"type":"if","locations":[{"start":{"line":13,"column":16},"end":{"line":15,"column":null}},{"start":{},"end":{}}],"line":13},"1":{"loc":{"start":{"line":28,"column":4},"end":{"line":28,"column":null}},"type":"if","locations":[{"start":{"line":28,"column":4},"end":{"line":28,"column":null}},{"start":{},"end":{}}],"line":28},"2":{"loc":{"start":{"line":29,"column":4},"end":{"line":29,"column":null}},"type":"if","locations":[{"start":{"line":29,"column":4},"end":{"line":29,"column":null}},{"start":{},"end":{}}],"line":29},"3":{"loc":{"start":{"line":34,"column":13},"end":{"line":46,"column":null}},"type":"cond-expr","locations":[{"start":{"line":35,"column":16},"end":{"line":35,"column":null}},{"start":{"line":37,"column":16},"end":{"line":46,"column":null}}],"line":34},"4":{"loc":{"start":{"line":41,"column":33},"end":{"line":41,"column":null}},"type":"binary-expr","locations":[{"start":{"line":41,"column":33},"end":{"line":41,"column":47}},{"start":{"line":41,"column":47},"end":{"line":41,"column":null}}],"line":41},"5":{"loc":{"start":{"line":43,"column":29},"end":{"line":43,"column":null}},"type":"binary-expr","locations":[{"start":{"line":43,"column":29},"end":{"line":43,"column":46}},{"start":{"line":43,"column":46},"end":{"line":43,"column":null}}],"line":43}},"s":{"0":9,"1":9,"2":9,"3":9,"4":5,"5":3,"6":0,"7":3,"8":3,"9":3,"10":1,"11":1,"12":9,"13":5,"14":4,"15":4,"16":3,"17":2},"f":{"0":9,"1":5,"2":3,"3":3,"4":1,"5":2},"b":{"0":[0,3],"1":[5,4],"2":[1,3],"3":[2,1],"4":[2,0],"5":[2,2]},"meta":{"lastBranch":6,"lastFunction":6,"lastStatement":18,"seen":{"f:5:24:5:35":0,"s:6:26:6:Infinity":0,"s:7:30:7:Infinity":1,"s:8:26:8:Infinity":2,"s:10:4:26:Infinity":3,"f:10:14:10:20":1,"s:11:8:25:Infinity":4,"f:12:18:12:19":2,"b:13:16:15:Infinity:undefined:undefined:undefined:undefined":0,"s:13:16:15:Infinity":5,"s:14:20:14:Infinity":6,"s:16:16:16:Infinity":7,"f:18:18:18:19":3,"s:19:16:19:Infinity":8,"s:20:16:20:Infinity":9,"f:22:19:22:20":4,"s:23:16:23:Infinity":10,"s:24:16:24:Infinity":11,"b:28:4:28:Infinity:undefined:undefined:undefined:undefined":1,"s:28:4:28:Infinity":12,"s:28:17:28:Infinity":13,"b:29:4:29:Infinity:undefined:undefined:undefined:undefined":2,"s:29:4:29:Infinity":14,"s:29:15:29:Infinity":15,"s:31:4:48:Infinity":16,"b:35:16:35:Infinity:37:16:46:Infinity":3,"f:38:31:38:32":5,"s:39:24:44:Infinity":17,"b:41:33:41:47:41:47:41:Infinity":4,"b:43:29:43:46:43:46:43:Infinity":5}}}
+,"/Users/adam/workspace/vibecode/neko/frontend/src/components/FeedList.tsx": {"path":"/Users/adam/workspace/vibecode/neko/frontend/src/components/FeedList.tsx","statementMap":{"0":{"start":{"line":7,"column":26},"end":{"line":7,"column":null}},"1":{"start":{"line":8,"column":30},"end":{"line":8,"column":null}},"2":{"start":{"line":9,"column":26},"end":{"line":9,"column":null}},"3":{"start":{"line":11,"column":4},"end":{"line":27,"column":null}},"4":{"start":{"line":12,"column":8},"end":{"line":26,"column":null}},"5":{"start":{"line":14,"column":16},"end":{"line":16,"column":null}},"6":{"start":{"line":15,"column":20},"end":{"line":15,"column":null}},"7":{"start":{"line":17,"column":16},"end":{"line":17,"column":null}},"8":{"start":{"line":20,"column":16},"end":{"line":20,"column":null}},"9":{"start":{"line":21,"column":16},"end":{"line":21,"column":null}},"10":{"start":{"line":24,"column":16},"end":{"line":24,"column":null}},"11":{"start":{"line":25,"column":16},"end":{"line":25,"column":null}},"12":{"start":{"line":29,"column":4},"end":{"line":29,"column":null}},"13":{"start":{"line":29,"column":17},"end":{"line":29,"column":null}},"14":{"start":{"line":30,"column":4},"end":{"line":30,"column":null}},"15":{"start":{"line":30,"column":15},"end":{"line":30,"column":null}},"16":{"start":{"line":32,"column":4},"end":{"line":49,"column":null}},"17":{"start":{"line":40,"column":24},"end":{"line":45,"column":null}}},"fnMap":{"0":{"name":"FeedList","decl":{"start":{"line":6,"column":24},"end":{"line":6,"column":35}},"loc":{"start":{"line":6,"column":35},"end":{"line":51,"column":null}},"line":6},"1":{"name":"(anonymous_1)","decl":{"start":{"line":11,"column":14},"end":{"line":11,"column":20}},"loc":{"start":{"line":11,"column":20},"end":{"line":27,"column":7}},"line":11},"2":{"name":"(anonymous_2)","decl":{"start":{"line":13,"column":18},"end":{"line":13,"column":19}},"loc":{"start":{"line":13,"column":27},"end":{"line":18,"column":13}},"line":13},"3":{"name":"(anonymous_3)","decl":{"start":{"line":19,"column":18},"end":{"line":19,"column":19}},"loc":{"start":{"line":19,"column":28},"end":{"line":22,"column":13}},"line":19},"4":{"name":"(anonymous_4)","decl":{"start":{"line":23,"column":19},"end":{"line":23,"column":20}},"loc":{"start":{"line":23,"column":28},"end":{"line":26,"column":13}},"line":23},"5":{"name":"(anonymous_5)","decl":{"start":{"line":39,"column":31},"end":{"line":39,"column":32}},"loc":{"start":{"line":40,"column":24},"end":{"line":45,"column":null}},"line":40}},"branchMap":{"0":{"loc":{"start":{"line":14,"column":16},"end":{"line":16,"column":null}},"type":"if","locations":[{"start":{"line":14,"column":16},"end":{"line":16,"column":null}},{"start":{},"end":{}}],"line":14},"1":{"loc":{"start":{"line":29,"column":4},"end":{"line":29,"column":null}},"type":"if","locations":[{"start":{"line":29,"column":4},"end":{"line":29,"column":null}},{"start":{},"end":{}}],"line":29},"2":{"loc":{"start":{"line":30,"column":4},"end":{"line":30,"column":null}},"type":"if","locations":[{"start":{"line":30,"column":4},"end":{"line":30,"column":null}},{"start":{},"end":{}}],"line":30},"3":{"loc":{"start":{"line":35,"column":13},"end":{"line":47,"column":null}},"type":"cond-expr","locations":[{"start":{"line":36,"column":16},"end":{"line":36,"column":null}},{"start":{"line":38,"column":16},"end":{"line":47,"column":null}}],"line":35},"4":{"loc":{"start":{"line":42,"column":33},"end":{"line":42,"column":null}},"type":"binary-expr","locations":[{"start":{"line":42,"column":33},"end":{"line":42,"column":47}},{"start":{"line":42,"column":47},"end":{"line":42,"column":null}}],"line":42},"5":{"loc":{"start":{"line":44,"column":29},"end":{"line":44,"column":null}},"type":"binary-expr","locations":[{"start":{"line":44,"column":29},"end":{"line":44,"column":46}},{"start":{"line":44,"column":46},"end":{"line":44,"column":null}}],"line":44}},"s":{"0":8,"1":8,"2":8,"3":8,"4":5,"5":3,"6":0,"7":3,"8":3,"9":3,"10":1,"11":1,"12":8,"13":5,"14":3,"15":3,"16":2,"17":2},"f":{"0":8,"1":5,"2":3,"3":3,"4":1,"5":2},"b":{"0":[0,3],"1":[5,3],"2":[1,2],"3":[1,1],"4":[2,0],"5":[2,2]},"meta":{"lastBranch":6,"lastFunction":6,"lastStatement":18,"seen":{"f:6:24:6:35":0,"s:7:26:7:Infinity":0,"s:8:30:8:Infinity":1,"s:9:26:9:Infinity":2,"s:11:4:27:Infinity":3,"f:11:14:11:20":1,"s:12:8:26:Infinity":4,"f:13:18:13:19":2,"b:14:16:16:Infinity:undefined:undefined:undefined:undefined":0,"s:14:16:16:Infinity":5,"s:15:20:15:Infinity":6,"s:17:16:17:Infinity":7,"f:19:18:19:19":3,"s:20:16:20:Infinity":8,"s:21:16:21:Infinity":9,"f:23:19:23:20":4,"s:24:16:24:Infinity":10,"s:25:16:25:Infinity":11,"b:29:4:29:Infinity:undefined:undefined:undefined:undefined":1,"s:29:4:29:Infinity":12,"s:29:17:29:Infinity":13,"b:30:4:30:Infinity:undefined:undefined:undefined:undefined":2,"s:30:4:30:Infinity":14,"s:30:15:30:Infinity":15,"s:32:4:49:Infinity":16,"b:36:16:36:Infinity:38:16:47:Infinity":3,"f:39:31:39:32":5,"s:40:24:45:Infinity":17,"b:42:33:42:47:42:47:42:Infinity":4,"b:44:29:44:46:44:46:44:Infinity":5}}}
,"/Users/adam/workspace/vibecode/neko/frontend/src/components/Login.css": {"path":"/Users/adam/workspace/vibecode/neko/frontend/src/components/Login.css","statementMap":{},"fnMap":{},"branchMap":{},"s":{},"f":{},"b":{},"meta":{"lastBranch":0,"lastFunction":0,"lastStatement":0,"seen":{}}}
,"/Users/adam/workspace/vibecode/neko/frontend/src/components/Login.tsx": {"path":"/Users/adam/workspace/vibecode/neko/frontend/src/components/Login.tsx","statementMap":{"0":{"start":{"line":6,"column":32},"end":{"line":6,"column":null}},"1":{"start":{"line":7,"column":26},"end":{"line":7,"column":null}},"2":{"start":{"line":8,"column":10},"end":{"line":8,"column":null}},"3":{"start":{"line":10,"column":25},"end":{"line":33,"column":null}},"4":{"start":{"line":11,"column":8},"end":{"line":11,"column":null}},"5":{"start":{"line":12,"column":8},"end":{"line":12,"column":null}},"6":{"start":{"line":14,"column":8},"end":{"line":32,"column":null}},"7":{"start":{"line":16,"column":27},"end":{"line":16,"column":null}},"8":{"start":{"line":17,"column":12},"end":{"line":17,"column":null}},"9":{"start":{"line":19,"column":24},"end":{"line":22,"column":null}},"10":{"start":{"line":24,"column":12},"end":{"line":29,"column":null}},"11":{"start":{"line":25,"column":16},"end":{"line":25,"column":null}},"12":{"start":{"line":27,"column":29},"end":{"line":27,"column":null}},"13":{"start":{"line":28,"column":16},"end":{"line":28,"column":null}},"14":{"start":{"line":31,"column":12},"end":{"line":31,"column":null}},"15":{"start":{"line":35,"column":4},"end":{"line":52,"column":null}},"16":{"start":{"line":45,"column":41},"end":{"line":45,"column":null}}},"fnMap":{"0":{"name":"Login","decl":{"start":{"line":5,"column":24},"end":{"line":5,"column":32}},"loc":{"start":{"line":5,"column":32},"end":{"line":54,"column":null}},"line":5},"1":{"name":"(anonymous_1)","decl":{"start":{"line":10,"column":25},"end":{"line":10,"column":32}},"loc":{"start":{"line":10,"column":49},"end":{"line":33,"column":null}},"line":10},"2":{"name":"(anonymous_2)","decl":{"start":{"line":45,"column":34},"end":{"line":45,"column":35}},"loc":{"start":{"line":45,"column":41},"end":{"line":45,"column":null}},"line":45}},"branchMap":{"0":{"loc":{"start":{"line":24,"column":12},"end":{"line":29,"column":null}},"type":"if","locations":[{"start":{"line":24,"column":12},"end":{"line":29,"column":null}},{"start":{"line":26,"column":19},"end":{"line":29,"column":null}}],"line":24},"1":{"loc":{"start":{"line":28,"column":25},"end":{"line":28,"column":55}},"type":"binary-expr","locations":[{"start":{"line":28,"column":25},"end":{"line":28,"column":41}},{"start":{"line":28,"column":41},"end":{"line":28,"column":55}}],"line":28},"2":{"loc":{"start":{"line":49,"column":17},"end":{"line":49,"column":null}},"type":"binary-expr","locations":[{"start":{"line":49,"column":17},"end":{"line":49,"column":26}},{"start":{"line":49,"column":26},"end":{"line":49,"column":null}}],"line":49}},"s":{"0":14,"1":14,"2":14,"3":14,"4":3,"5":3,"6":3,"7":3,"8":3,"9":3,"10":2,"11":1,"12":1,"13":1,"14":1,"15":14,"16":3},"f":{"0":14,"1":3,"2":3},"b":{"0":[1,1],"1":[1,0],"2":[14,2]},"meta":{"lastBranch":3,"lastFunction":3,"lastStatement":17,"seen":{"f:5:24:5:32":0,"s:6:32:6:Infinity":0,"s:7:26:7:Infinity":1,"s:8:10:8:Infinity":2,"s:10:25:33:Infinity":3,"f:10:25:10:32":1,"s:11:8:11:Infinity":4,"s:12:8:12:Infinity":5,"s:14:8:32:Infinity":6,"s:16:27:16:Infinity":7,"s:17:12:17:Infinity":8,"s:19:24:22:Infinity":9,"b:24:12:29:Infinity:26:19:29:Infinity":0,"s:24:12:29:Infinity":10,"s:25:16:25:Infinity":11,"s:27:29:27:Infinity":12,"s:28:16:28:Infinity":13,"b:28:25:28:41:28:41:28:55":1,"s:31:12:31:Infinity":14,"s:35:4:52:Infinity":15,"f:45:34:45:35":2,"s:45:41:45:Infinity":16,"b:49:17:49:26:49:26:49:Infinity":2}}}
}
diff --git a/frontend/coverage/index.html b/frontend/coverage/index.html
index e90ae39..8011000 100644
--- a/frontend/coverage/index.html
+++ b/frontend/coverage/index.html
@@ -23,30 +23,30 @@
<div class='clearfix'>
<div class='fl pad1y space-right2'>
- <span class="strong">92% </span>
+ <span class="strong">90.27% </span>
<span class="quiet">Statements</span>
- <span class='fraction'>46/50</span>
+ <span class='fraction'>65/72</span>
</div>
<div class='fl pad1y space-right2'>
- <span class="strong">79.16% </span>
+ <span class="strong">71.42% </span>
<span class="quiet">Branches</span>
- <span class='fraction'>19/24</span>
+ <span class='fraction'>30/42</span>
</div>
<div class='fl pad1y space-right2'>
- <span class="strong">93.33% </span>
+ <span class="strong">90.47% </span>
<span class="quiet">Functions</span>
- <span class='fraction'>14/15</span>
+ <span class='fraction'>19/21</span>
</div>
<div class='fl pad1y space-right2'>
- <span class="strong">91.66% </span>
+ <span class="strong">89.7% </span>
<span class="quiet">Lines</span>
- <span class='fraction'>44/48</span>
+ <span class='fraction'>61/68</span>
</div>
@@ -95,17 +95,17 @@
<tr>
<td class="file high" data-value="src/components"><a href="src/components/index.html">src/components</a></td>
- <td data-value="97.14" class="pic high">
- <div class="chart"><div class="cover-fill" style="width: 97%"></div><div class="cover-empty" style="width: 3%"></div></div>
+ <td data-value="92.98" class="pic high">
+ <div class="chart"><div class="cover-fill" style="width: 92%"></div><div class="cover-empty" style="width: 8%"></div></div>
</td>
- <td data-value="97.14" class="pct high">97.14%</td>
- <td data-value="35" class="abs high">34/35</td>
- <td data-value="83.33" class="pct high">83.33%</td>
- <td data-value="18" class="abs high">15/18</td>
- <td data-value="100" class="pct high">100%</td>
- <td data-value="9" class="abs high">9/9</td>
- <td data-value="96.96" class="pct high">96.96%</td>
- <td data-value="33" class="abs high">32/33</td>
+ <td data-value="92.98" class="pct high">92.98%</td>
+ <td data-value="57" class="abs high">53/57</td>
+ <td data-value="72.22" class="pct medium">72.22%</td>
+ <td data-value="36" class="abs medium">26/36</td>
+ <td data-value="93.33" class="pct high">93.33%</td>
+ <td data-value="15" class="abs high">14/15</td>
+ <td data-value="92.45" class="pct high">92.45%</td>
+ <td data-value="53" class="abs high">49/53</td>
</tr>
</tbody>
@@ -116,7 +116,7 @@
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
- at 2026-02-13T05:59:33.745Z
+ at 2026-02-13T06:02:51.320Z
</div>
<script src="prettify.js"></script>
<script>
diff --git a/frontend/coverage/src/App.css.html b/frontend/coverage/src/App.css.html
index 4ba93ef..21cb0dc 100644
--- a/frontend/coverage/src/App.css.html
+++ b/frontend/coverage/src/App.css.html
@@ -247,7 +247,7 @@ body {
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
- at 2026-02-13T05:59:33.745Z
+ at 2026-02-13T06:02:51.320Z
</div>
<script src="../prettify.js"></script>
<script>
diff --git a/frontend/coverage/src/App.tsx.html b/frontend/coverage/src/App.tsx.html
index 9d8fd6f..a233a8d 100644
--- a/frontend/coverage/src/App.tsx.html
+++ b/frontend/coverage/src/App.tsx.html
@@ -138,7 +138,11 @@
<a name='L73'></a><a href='#L73'>73</a>
<a name='L74'></a><a href='#L74'>74</a>
<a name='L75'></a><a href='#L75'>75</a>
-<a name='L76'></a><a href='#L76'>76</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
+<a name='L76'></a><a href='#L76'>76</a>
+<a name='L77'></a><a href='#L77'>77</a>
+<a name='L78'></a><a href='#L78'>78</a>
+<a name='L79'></a><a href='#L79'>79</a>
+<a name='L80'></a><a href='#L80'>80</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -174,6 +178,7 @@
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -195,6 +200,9 @@
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -247,6 +255,7 @@ function RequireAuth({ children }: { children: React.ReactElement }) {
}
&nbsp;
import FeedList from './components/FeedList';
+import FeedItems from './components/FeedItems';
&nbsp;
function Dashboard() {
return (
@@ -262,7 +271,10 @@ function Dashboard() {
&lt;FeedList /&gt;
&lt;/aside&gt;
&lt;main className="dashboard-main"&gt;
- &lt;p&gt;Select a feed to view items.&lt;/p&gt;
+ &lt;Routes&gt;
+ &lt;Route path="/feed/:feedId" element={&lt;FeedItems /&gt;} /&gt;
+ &lt;Route path="/" element={&lt;p&gt;Select a feed to view items.&lt;/p&gt;} /&gt;
+ &lt;/Routes&gt;
&lt;/main&gt;
&lt;/div&gt;
&lt;/div&gt;
@@ -295,7 +307,7 @@ export default App;
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
- at 2026-02-13T05:59:33.745Z
+ at 2026-02-13T06:02:51.320Z
</div>
<script src="../prettify.js"></script>
<script>
diff --git a/frontend/coverage/src/components/FeedItems.css.html b/frontend/coverage/src/components/FeedItems.css.html
new file mode 100644
index 0000000..3951ead
--- /dev/null
+++ b/frontend/coverage/src/components/FeedItems.css.html
@@ -0,0 +1,262 @@
+
+<!doctype html>
+<html lang="en">
+
+<head>
+ <title>Code coverage report for src/components/FeedItems.css</title>
+ <meta charset="utf-8" />
+ <link rel="stylesheet" href="../../prettify.css" />
+ <link rel="stylesheet" href="../../base.css" />
+ <link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <style type='text/css'>
+ .coverage-summary .sorter {
+ background-image: url(../../sort-arrow-sprite.png);
+ }
+ </style>
+</head>
+
+<body>
+<div class='wrapper'>
+ <div class='pad1'>
+ <h1><a href="../../index.html">All files</a> / <a href="index.html">src/components</a> FeedItems.css</h1>
+ <div class='clearfix'>
+
+ <div class='fl pad1y space-right2'>
+ <span class="strong">0% </span>
+ <span class="quiet">Statements</span>
+ <span class='fraction'>0/0</span>
+ </div>
+
+
+ <div class='fl pad1y space-right2'>
+ <span class="strong">0% </span>
+ <span class="quiet">Branches</span>
+ <span class='fraction'>0/0</span>
+ </div>
+
+
+ <div class='fl pad1y space-right2'>
+ <span class="strong">0% </span>
+ <span class="quiet">Functions</span>
+ <span class='fraction'>0/0</span>
+ </div>
+
+
+ <div class='fl pad1y space-right2'>
+ <span class="strong">0% </span>
+ <span class="quiet">Lines</span>
+ <span class='fraction'>0/0</span>
+ </div>
+
+
+ </div>
+ <p class="quiet">
+ Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
+ </p>
+ <template id="filterTemplate">
+ <div class="quiet">
+ Filter:
+ <input type="search" id="fileSearch">
+ </div>
+ </template>
+ </div>
+ <div class='status-line low'></div>
+ <pre><table class="coverage">
+<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
+<a name='L2'></a><a href='#L2'>2</a>
+<a name='L3'></a><a href='#L3'>3</a>
+<a name='L4'></a><a href='#L4'>4</a>
+<a name='L5'></a><a href='#L5'>5</a>
+<a name='L6'></a><a href='#L6'>6</a>
+<a name='L7'></a><a href='#L7'>7</a>
+<a name='L8'></a><a href='#L8'>8</a>
+<a name='L9'></a><a href='#L9'>9</a>
+<a name='L10'></a><a href='#L10'>10</a>
+<a name='L11'></a><a href='#L11'>11</a>
+<a name='L12'></a><a href='#L12'>12</a>
+<a name='L13'></a><a href='#L13'>13</a>
+<a name='L14'></a><a href='#L14'>14</a>
+<a name='L15'></a><a href='#L15'>15</a>
+<a name='L16'></a><a href='#L16'>16</a>
+<a name='L17'></a><a href='#L17'>17</a>
+<a name='L18'></a><a href='#L18'>18</a>
+<a name='L19'></a><a href='#L19'>19</a>
+<a name='L20'></a><a href='#L20'>20</a>
+<a name='L21'></a><a href='#L21'>21</a>
+<a name='L22'></a><a href='#L22'>22</a>
+<a name='L23'></a><a href='#L23'>23</a>
+<a name='L24'></a><a href='#L24'>24</a>
+<a name='L25'></a><a href='#L25'>25</a>
+<a name='L26'></a><a href='#L26'>26</a>
+<a name='L27'></a><a href='#L27'>27</a>
+<a name='L28'></a><a href='#L28'>28</a>
+<a name='L29'></a><a href='#L29'>29</a>
+<a name='L30'></a><a href='#L30'>30</a>
+<a name='L31'></a><a href='#L31'>31</a>
+<a name='L32'></a><a href='#L32'>32</a>
+<a name='L33'></a><a href='#L33'>33</a>
+<a name='L34'></a><a href='#L34'>34</a>
+<a name='L35'></a><a href='#L35'>35</a>
+<a name='L36'></a><a href='#L36'>36</a>
+<a name='L37'></a><a href='#L37'>37</a>
+<a name='L38'></a><a href='#L38'>38</a>
+<a name='L39'></a><a href='#L39'>39</a>
+<a name='L40'></a><a href='#L40'>40</a>
+<a name='L41'></a><a href='#L41'>41</a>
+<a name='L42'></a><a href='#L42'>42</a>
+<a name='L43'></a><a href='#L43'>43</a>
+<a name='L44'></a><a href='#L44'>44</a>
+<a name='L45'></a><a href='#L45'>45</a>
+<a name='L46'></a><a href='#L46'>46</a>
+<a name='L47'></a><a href='#L47'>47</a>
+<a name='L48'></a><a href='#L48'>48</a>
+<a name='L49'></a><a href='#L49'>49</a>
+<a name='L50'></a><a href='#L50'>50</a>
+<a name='L51'></a><a href='#L51'>51</a>
+<a name='L52'></a><a href='#L52'>52</a>
+<a name='L53'></a><a href='#L53'>53</a>
+<a name='L54'></a><a href='#L54'>54</a>
+<a name='L55'></a><a href='#L55'>55</a>
+<a name='L56'></a><a href='#L56'>56</a>
+<a name='L57'></a><a href='#L57'>57</a>
+<a name='L58'></a><a href='#L58'>58</a>
+<a name='L59'></a><a href='#L59'>59</a>
+<a name='L60'></a><a href='#L60'>60</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js">.feed-items {
+ padding: 1rem;
+}
+&nbsp;
+.feed-items h2 {
+ margin-top: 0;
+ border-bottom: 2px solid #eee;
+ padding-bottom: 0.5rem;
+}
+&nbsp;
+.item-list {
+ list-style: none;
+ padding: 0;
+}
+&nbsp;
+.item {
+ border-bottom: 1px solid #f0f0f0;
+ padding: 1rem 0;
+}
+&nbsp;
+.item.read .item-title {
+ color: #888;
+ font-weight: normal;
+}
+&nbsp;
+.item.unread .item-title {
+ font-weight: bold;
+}
+&nbsp;
+.item-title {
+ font-size: 1.2rem;
+ text-decoration: none;
+ color: #333;
+ display: block;
+ margin-bottom: 0.5rem;
+}
+&nbsp;
+.item-title:hover {
+ text-decoration: underline;
+ color: #007bff;
+}
+&nbsp;
+.item-meta {
+ font-size: 0.85rem;
+ color: #666;
+ margin-bottom: 0.5rem;
+}
+&nbsp;
+.item-description {
+ color: #444;
+ line-height: 1.5;
+ font-size: 0.95rem;
+}
+&nbsp;
+.item-description img {
+ max-width: 100%;
+ height: auto;
+ display: block;
+ margin: 1rem 0;
+}</pre></td></tr></table></pre>
+
+ <div class='push'></div><!-- for sticky footer -->
+ </div><!-- /wrapper -->
+ <div class='footer quiet pad2 space-top1 center small'>
+ Code coverage generated by
+ <a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
+ at 2026-02-13T06:02:51.320Z
+ </div>
+ <script src="../../prettify.js"></script>
+ <script>
+ window.onload = function () {
+ prettyPrint();
+ };
+ </script>
+ <script src="../../sorter.js"></script>
+ <script src="../../block-navigation.js"></script>
+ </body>
+</html>
+ \ No newline at end of file
diff --git a/frontend/coverage/src/components/FeedItems.tsx.html b/frontend/coverage/src/components/FeedItems.tsx.html
new file mode 100644
index 0000000..85c9705
--- /dev/null
+++ b/frontend/coverage/src/components/FeedItems.tsx.html
@@ -0,0 +1,283 @@
+
+<!doctype html>
+<html lang="en">
+
+<head>
+ <title>Code coverage report for src/components/FeedItems.tsx</title>
+ <meta charset="utf-8" />
+ <link rel="stylesheet" href="../../prettify.css" />
+ <link rel="stylesheet" href="../../base.css" />
+ <link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <style type='text/css'>
+ .coverage-summary .sorter {
+ background-image: url(../../sort-arrow-sprite.png);
+ }
+ </style>
+</head>
+
+<body>
+<div class='wrapper'>
+ <div class='pad1'>
+ <h1><a href="../../index.html">All files</a> / <a href="index.html">src/components</a> FeedItems.tsx</h1>
+ <div class='clearfix'>
+
+ <div class='fl pad1y space-right2'>
+ <span class="strong">86.36% </span>
+ <span class="quiet">Statements</span>
+ <span class='fraction'>19/22</span>
+ </div>
+
+
+ <div class='fl pad1y space-right2'>
+ <span class="strong">61.11% </span>
+ <span class="quiet">Branches</span>
+ <span class='fraction'>11/18</span>
+ </div>
+
+
+ <div class='fl pad1y space-right2'>
+ <span class="strong">83.33% </span>
+ <span class="quiet">Functions</span>
+ <span class='fraction'>5/6</span>
+ </div>
+
+
+ <div class='fl pad1y space-right2'>
+ <span class="strong">85% </span>
+ <span class="quiet">Lines</span>
+ <span class='fraction'>17/20</span>
+ </div>
+
+
+ </div>
+ <p class="quiet">
+ Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
+ </p>
+ <template id="filterTemplate">
+ <div class="quiet">
+ Filter:
+ <input type="search" id="fileSearch">
+ </div>
+ </template>
+ </div>
+ <div class='status-line high'></div>
+ <pre><table class="coverage">
+<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
+<a name='L2'></a><a href='#L2'>2</a>
+<a name='L3'></a><a href='#L3'>3</a>
+<a name='L4'></a><a href='#L4'>4</a>
+<a name='L5'></a><a href='#L5'>5</a>
+<a name='L6'></a><a href='#L6'>6</a>
+<a name='L7'></a><a href='#L7'>7</a>
+<a name='L8'></a><a href='#L8'>8</a>
+<a name='L9'></a><a href='#L9'>9</a>
+<a name='L10'></a><a href='#L10'>10</a>
+<a name='L11'></a><a href='#L11'>11</a>
+<a name='L12'></a><a href='#L12'>12</a>
+<a name='L13'></a><a href='#L13'>13</a>
+<a name='L14'></a><a href='#L14'>14</a>
+<a name='L15'></a><a href='#L15'>15</a>
+<a name='L16'></a><a href='#L16'>16</a>
+<a name='L17'></a><a href='#L17'>17</a>
+<a name='L18'></a><a href='#L18'>18</a>
+<a name='L19'></a><a href='#L19'>19</a>
+<a name='L20'></a><a href='#L20'>20</a>
+<a name='L21'></a><a href='#L21'>21</a>
+<a name='L22'></a><a href='#L22'>22</a>
+<a name='L23'></a><a href='#L23'>23</a>
+<a name='L24'></a><a href='#L24'>24</a>
+<a name='L25'></a><a href='#L25'>25</a>
+<a name='L26'></a><a href='#L26'>26</a>
+<a name='L27'></a><a href='#L27'>27</a>
+<a name='L28'></a><a href='#L28'>28</a>
+<a name='L29'></a><a href='#L29'>29</a>
+<a name='L30'></a><a href='#L30'>30</a>
+<a name='L31'></a><a href='#L31'>31</a>
+<a name='L32'></a><a href='#L32'>32</a>
+<a name='L33'></a><a href='#L33'>33</a>
+<a name='L34'></a><a href='#L34'>34</a>
+<a name='L35'></a><a href='#L35'>35</a>
+<a name='L36'></a><a href='#L36'>36</a>
+<a name='L37'></a><a href='#L37'>37</a>
+<a name='L38'></a><a href='#L38'>38</a>
+<a name='L39'></a><a href='#L39'>39</a>
+<a name='L40'></a><a href='#L40'>40</a>
+<a name='L41'></a><a href='#L41'>41</a>
+<a name='L42'></a><a href='#L42'>42</a>
+<a name='L43'></a><a href='#L43'>43</a>
+<a name='L44'></a><a href='#L44'>44</a>
+<a name='L45'></a><a href='#L45'>45</a>
+<a name='L46'></a><a href='#L46'>46</a>
+<a name='L47'></a><a href='#L47'>47</a>
+<a name='L48'></a><a href='#L48'>48</a>
+<a name='L49'></a><a href='#L49'>49</a>
+<a name='L50'></a><a href='#L50'>50</a>
+<a name='L51'></a><a href='#L51'>51</a>
+<a name='L52'></a><a href='#L52'>52</a>
+<a name='L53'></a><a href='#L53'>53</a>
+<a name='L54'></a><a href='#L54'>54</a>
+<a name='L55'></a><a href='#L55'>55</a>
+<a name='L56'></a><a href='#L56'>56</a>
+<a name='L57'></a><a href='#L57'>57</a>
+<a name='L58'></a><a href='#L58'>58</a>
+<a name='L59'></a><a href='#L59'>59</a>
+<a name='L60'></a><a href='#L60'>60</a>
+<a name='L61'></a><a href='#L61'>61</a>
+<a name='L62'></a><a href='#L62'>62</a>
+<a name='L63'></a><a href='#L63'>63</a>
+<a name='L64'></a><a href='#L64'>64</a>
+<a name='L65'></a><a href='#L65'>65</a>
+<a name='L66'></a><a href='#L66'>66</a>
+<a name='L67'></a><a href='#L67'>67</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">3x</span>
+<span class="cline-any cline-yes">3x</span>
+<span class="cline-any cline-yes">3x</span>
+<span class="cline-any cline-yes">3x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">3x</span>
+<span class="cline-any cline-yes">2x</span>
+<span class="cline-any cline-yes">2x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">2x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">2x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">3x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">2x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js">import { useEffect, useState } from 'react';
+import { useParams } from 'react-router-dom';
+import type { Item } from '../types';
+import './FeedItems.css';
+&nbsp;
+export default function FeedItems() {
+ const { feedId } = useParams&lt;{ feedId: string }&gt;();
+ const [items, setItems] = useState&lt;Item[]&gt;([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+&nbsp;
+ useEffect(() =&gt; {
+ setLoading(true);
+ setError('');
+&nbsp;
+ const url = feedId
+ ? `/api/stream?feed_id=${feedId}`
+ : <span class="branch-1 cbranch-no" title="branch not covered" >'/api/stream'; // Default or "all" view? For now let's assume we need a feedId or handle "all" logic later</span>
+&nbsp;
+ fetch(url)
+ .then((res) =&gt; {
+ <span class="missing-if-branch" title="if path not taken" >I</span>if (!res.ok) {
+<span class="cstat-no" title="statement not covered" > throw new Error('Failed to fetch items');</span>
+ }
+ return res.json();
+ })
+ .then((data) =&gt; {
+ setItems(data);
+ setLoading(false);
+ })
+ .catch(<span class="fstat-no" title="function not covered" >(e</span>rr) =&gt; {
+<span class="cstat-no" title="statement not covered" > setError(err.message);</span>
+<span class="cstat-no" title="statement not covered" > setLoading(false);</span>
+ });
+ }, [feedId]);
+&nbsp;
+ if (loading) return &lt;div className="feed-items-loading"&gt;Loading items...&lt;/div&gt;;
+ <span class="missing-if-branch" title="if path not taken" >I</span>if (error) return &lt;div className="feed-items-error"&gt;Error: {error}&lt;/div&gt;;
+&nbsp;
+ return (
+ &lt;div className="feed-items"&gt;
+ &lt;h2&gt;Items&lt;/h2&gt;
+ {/* TODO: Add Feed Title here if possible, maybe pass from location state or fetch feed details */}
+ {items.length === 0 ? (
+<span class="branch-0 cbranch-no" title="branch not covered" > &lt;p&gt;No items found.&lt;/p&gt;</span>
+ ) : (
+ &lt;ul className="item-list"&gt;
+ {items.map((item) =&gt; (
+ &lt;li key={item._id} className={`item ${item.read ? 'read' : 'unread'}`}&gt;
+ &lt;a href={item.url} target="_blank" rel="noopener noreferrer" className="item-title"&gt;
+ {item.title || <span class="branch-1 cbranch-no" title="branch not covered" >'(No Title)'}</span>
+ &lt;/a&gt;
+ &lt;div className="item-meta"&gt;
+ &lt;span className="item-date"&gt;{new Date(item.publish_date).toLocaleDateString()}&lt;/span&gt;
+ {item.feed_title &amp;&amp; <span class="branch-1 cbranch-no" title="branch not covered" >&lt;span className="item-feed"&gt; - {item.feed_title}&lt;/span&gt;}</span>
+ &lt;/div&gt;
+ {item.description &amp;&amp; (
+<span class="branch-1 cbranch-no" title="branch not covered" > &lt;div className="item-description" dangerouslySetInnerHTML={{ __html: item.description }} /&gt;</span>
+ )}
+ &lt;/li&gt;
+ ))}
+ &lt;/ul&gt;
+ )}
+ &lt;/div&gt;
+ );
+}
+&nbsp;</pre></td></tr></table></pre>
+
+ <div class='push'></div><!-- for sticky footer -->
+ </div><!-- /wrapper -->
+ <div class='footer quiet pad2 space-top1 center small'>
+ Code coverage generated by
+ <a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
+ at 2026-02-13T06:02:51.320Z
+ </div>
+ <script src="../../prettify.js"></script>
+ <script>
+ window.onload = function () {
+ prettyPrint();
+ };
+ </script>
+ <script src="../../sorter.js"></script>
+ <script src="../../block-navigation.js"></script>
+ </body>
+</html>
+ \ No newline at end of file
diff --git a/frontend/coverage/src/components/FeedList.css.html b/frontend/coverage/src/components/FeedList.css.html
index cd14b1b..f86a2d4 100644
--- a/frontend/coverage/src/components/FeedList.css.html
+++ b/frontend/coverage/src/components/FeedList.css.html
@@ -211,7 +211,7 @@
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
- at 2026-02-13T05:59:33.745Z
+ at 2026-02-13T06:02:51.320Z
</div>
<script src="../../prettify.js"></script>
<script>
diff --git a/frontend/coverage/src/components/FeedList.tsx.html b/frontend/coverage/src/components/FeedList.tsx.html
index d7344a2..e220912 100644
--- a/frontend/coverage/src/components/FeedList.tsx.html
+++ b/frontend/coverage/src/components/FeedList.tsx.html
@@ -113,16 +113,18 @@
<a name='L48'></a><a href='#L48'>48</a>
<a name='L49'></a><a href='#L49'>49</a>
<a name='L50'></a><a href='#L50'>50</a>
-<a name='L51'></a><a href='#L51'>51</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
+<a name='L51'></a><a href='#L51'>51</a>
+<a name='L52'></a><a href='#L52'>52</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
-<span class="cline-any cline-yes">9x</span>
-<span class="cline-any cline-yes">9x</span>
-<span class="cline-any cline-yes">9x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
-<span class="cline-any cline-yes">9x</span>
+<span class="cline-any cline-yes">8x</span>
+<span class="cline-any cline-yes">8x</span>
+<span class="cline-any cline-yes">8x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-yes">5x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">3x</span>
@@ -140,11 +142,11 @@
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
-<span class="cline-any cline-yes">9x</span>
-<span class="cline-any cline-yes">4x</span>
-<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">2x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -164,6 +166,7 @@
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js">import { useEffect, useState } from 'react';
+import { Link } from 'react-router-dom';
import type { Feed } from '../types';
import './FeedList.css';
&nbsp;
@@ -202,9 +205,9 @@ export default function FeedList() {
&lt;ul className="feed-list-items"&gt;
{feeds.map((feed) =&gt; (
&lt;li key={feed._id} className="feed-item"&gt;
- &lt;a href={feed.web_url} target="_blank" rel="noopener noreferrer" className="feed-title"&gt;
+ &lt;Link to={`/feed/${feed._id}`} className="feed-title"&gt;
{feed.title || <span class="branch-1 cbranch-no" title="branch not covered" >feed.url}</span>
- &lt;/a&gt;
+ &lt;/Link&gt;
{feed.category &amp;&amp; &lt;span className="feed-category"&gt;{feed.category}&lt;/span&gt;}
&lt;/li&gt;
))}
@@ -220,7 +223,7 @@ export default function FeedList() {
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
- at 2026-02-13T05:59:33.745Z
+ at 2026-02-13T06:02:51.320Z
</div>
<script src="../../prettify.js"></script>
<script>
diff --git a/frontend/coverage/src/components/Login.css.html b/frontend/coverage/src/components/Login.css.html
index 2fb506b..5ef3e6c 100644
--- a/frontend/coverage/src/components/Login.css.html
+++ b/frontend/coverage/src/components/Login.css.html
@@ -259,7 +259,7 @@ button[type="submit"]:hover {
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
- at 2026-02-13T05:59:33.745Z
+ at 2026-02-13T06:02:51.320Z
</div>
<script src="../../prettify.js"></script>
<script>
diff --git a/frontend/coverage/src/components/Login.tsx.html b/frontend/coverage/src/components/Login.tsx.html
index 9d1052f..e58efff 100644
--- a/frontend/coverage/src/components/Login.tsx.html
+++ b/frontend/coverage/src/components/Login.tsx.html
@@ -232,7 +232,7 @@ export default function Login() {
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
- at 2026-02-13T05:59:33.745Z
+ at 2026-02-13T06:02:51.320Z
</div>
<script src="../../prettify.js"></script>
<script>
diff --git a/frontend/coverage/src/components/index.html b/frontend/coverage/src/components/index.html
index d9947b6..94dbb66 100644
--- a/frontend/coverage/src/components/index.html
+++ b/frontend/coverage/src/components/index.html
@@ -23,30 +23,30 @@
<div class='clearfix'>
<div class='fl pad1y space-right2'>
- <span class="strong">97.14% </span>
+ <span class="strong">92.98% </span>
<span class="quiet">Statements</span>
- <span class='fraction'>34/35</span>
+ <span class='fraction'>53/57</span>
</div>
<div class='fl pad1y space-right2'>
- <span class="strong">83.33% </span>
+ <span class="strong">72.22% </span>
<span class="quiet">Branches</span>
- <span class='fraction'>15/18</span>
+ <span class='fraction'>26/36</span>
</div>
<div class='fl pad1y space-right2'>
- <span class="strong">100% </span>
+ <span class="strong">93.33% </span>
<span class="quiet">Functions</span>
- <span class='fraction'>9/9</span>
+ <span class='fraction'>14/15</span>
</div>
<div class='fl pad1y space-right2'>
- <span class="strong">96.96% </span>
+ <span class="strong">92.45% </span>
<span class="quiet">Lines</span>
- <span class='fraction'>32/33</span>
+ <span class='fraction'>49/53</span>
</div>
@@ -79,6 +79,36 @@
</tr>
</thead>
<tbody><tr>
+ <td class="file empty" data-value="FeedItems.css"><a href="FeedItems.css.html">FeedItems.css</a></td>
+ <td data-value="0" class="pic empty">
+ <div class="chart"><div class="cover-fill" style="width: 0%"></div><div class="cover-empty" style="width: 100%"></div></div>
+ </td>
+ <td data-value="0" class="pct empty">0%</td>
+ <td data-value="0" class="abs empty">0/0</td>
+ <td data-value="0" class="pct empty">0%</td>
+ <td data-value="0" class="abs empty">0/0</td>
+ <td data-value="0" class="pct empty">0%</td>
+ <td data-value="0" class="abs empty">0/0</td>
+ <td data-value="0" class="pct empty">0%</td>
+ <td data-value="0" class="abs empty">0/0</td>
+ </tr>
+
+<tr>
+ <td class="file high" data-value="FeedItems.tsx"><a href="FeedItems.tsx.html">FeedItems.tsx</a></td>
+ <td data-value="86.36" class="pic high">
+ <div class="chart"><div class="cover-fill" style="width: 86%"></div><div class="cover-empty" style="width: 14%"></div></div>
+ </td>
+ <td data-value="86.36" class="pct high">86.36%</td>
+ <td data-value="22" class="abs high">19/22</td>
+ <td data-value="61.11" class="pct medium">61.11%</td>
+ <td data-value="18" class="abs medium">11/18</td>
+ <td data-value="83.33" class="pct high">83.33%</td>
+ <td data-value="6" class="abs high">5/6</td>
+ <td data-value="85" class="pct high">85%</td>
+ <td data-value="20" class="abs high">17/20</td>
+ </tr>
+
+<tr>
<td class="file empty" data-value="FeedList.css"><a href="FeedList.css.html">FeedList.css</a></td>
<td data-value="0" class="pic empty">
<div class="chart"><div class="cover-fill" style="width: 0%"></div><div class="cover-empty" style="width: 100%"></div></div>
@@ -146,7 +176,7 @@
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
- at 2026-02-13T05:59:33.745Z
+ at 2026-02-13T06:02:51.320Z
</div>
<script src="../../prettify.js"></script>
<script>
diff --git a/frontend/coverage/src/index.html b/frontend/coverage/src/index.html
index 864b5d1..01547dd 100644
--- a/frontend/coverage/src/index.html
+++ b/frontend/coverage/src/index.html
@@ -116,7 +116,7 @@
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
- at 2026-02-13T05:59:33.745Z
+ at 2026-02-13T06:02:51.320Z
</div>
<script src="../prettify.js"></script>
<script>
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 7d26025..bc4e097 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -32,6 +32,7 @@ function RequireAuth({ children }: { children: React.ReactElement }) {
}
import FeedList from './components/FeedList';
+import FeedItems from './components/FeedItems';
function Dashboard() {
return (
@@ -47,7 +48,10 @@ function Dashboard() {
<FeedList />
</aside>
<main className="dashboard-main">
- <p>Select a feed to view items.</p>
+ <Routes>
+ <Route path="/feed/:feedId" element={<FeedItems />} />
+ <Route path="/" element={<p>Select a feed to view items.</p>} />
+ </Routes>
</main>
</div>
</div>
diff --git a/frontend/src/components/FeedItems.css b/frontend/src/components/FeedItems.css
new file mode 100644
index 0000000..a057a40
--- /dev/null
+++ b/frontend/src/components/FeedItems.css
@@ -0,0 +1,60 @@
+.feed-items {
+ padding: 1rem;
+}
+
+.feed-items h2 {
+ margin-top: 0;
+ border-bottom: 2px solid #eee;
+ padding-bottom: 0.5rem;
+}
+
+.item-list {
+ list-style: none;
+ padding: 0;
+}
+
+.item {
+ border-bottom: 1px solid #f0f0f0;
+ padding: 1rem 0;
+}
+
+.item.read .item-title {
+ color: #888;
+ font-weight: normal;
+}
+
+.item.unread .item-title {
+ font-weight: bold;
+}
+
+.item-title {
+ font-size: 1.2rem;
+ text-decoration: none;
+ color: #333;
+ display: block;
+ margin-bottom: 0.5rem;
+}
+
+.item-title:hover {
+ text-decoration: underline;
+ color: #007bff;
+}
+
+.item-meta {
+ font-size: 0.85rem;
+ color: #666;
+ margin-bottom: 0.5rem;
+}
+
+.item-description {
+ color: #444;
+ line-height: 1.5;
+ font-size: 0.95rem;
+}
+
+.item-description img {
+ max-width: 100%;
+ height: auto;
+ display: block;
+ margin: 1rem 0;
+} \ No newline at end of file
diff --git a/frontend/src/components/FeedItems.test.tsx b/frontend/src/components/FeedItems.test.tsx
new file mode 100644
index 0000000..9c271c4
--- /dev/null
+++ b/frontend/src/components/FeedItems.test.tsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import '@testing-library/jest-dom';
+import { render, screen, waitFor } from '@testing-library/react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import FeedItems from './FeedItems';
+
+describe('FeedItems Component', () => {
+ beforeEach(() => {
+ vi.resetAllMocks();
+ global.fetch = vi.fn();
+ });
+
+ it('renders loading state', () => {
+ (global.fetch as any).mockImplementation(() => new Promise(() => { }));
+ render(
+ <MemoryRouter initialEntries={['/feed/1']}>
+ <Routes>
+ <Route path="/feed/:feedId" element={<FeedItems />} />
+ </Routes>
+ </MemoryRouter>
+ );
+ expect(screen.getByText(/loading items/i)).toBeInTheDocument();
+ });
+
+ it('renders items for a feed', async () => {
+ const mockItems = [
+ { _id: 101, title: 'Item One', url: 'http://example.com/1', publish_date: '2023-01-01', read: false },
+ { _id: 102, title: 'Item Two', url: 'http://example.com/2', publish_date: '2023-01-02', read: true },
+ ];
+
+ (global.fetch as any).mockResolvedValueOnce({
+ ok: true,
+ json: async () => mockItems,
+ });
+
+ render(
+ <MemoryRouter initialEntries={['/feed/1']}>
+ <Routes>
+ <Route path="/feed/:feedId" element={<FeedItems />} />
+ </Routes>
+ </MemoryRouter>
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Item One')).toBeInTheDocument();
+ expect(screen.getByText('Item Two')).toBeInTheDocument();
+ });
+
+ expect(global.fetch).toHaveBeenCalledWith('/api/stream?feed_id=1');
+ });
+});
diff --git a/frontend/src/components/FeedItems.tsx b/frontend/src/components/FeedItems.tsx
new file mode 100644
index 0000000..048bed7
--- /dev/null
+++ b/frontend/src/components/FeedItems.tsx
@@ -0,0 +1,66 @@
+import { useEffect, useState } from 'react';
+import { useParams } from 'react-router-dom';
+import type { Item } from '../types';
+import './FeedItems.css';
+
+export default function FeedItems() {
+ const { feedId } = useParams<{ feedId: string }>();
+ const [items, setItems] = useState<Item[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ setLoading(true);
+ setError('');
+
+ const url = feedId
+ ? `/api/stream?feed_id=${feedId}`
+ : '/api/stream'; // Default or "all" view? For now let's assume we need a feedId or handle "all" logic later
+
+ fetch(url)
+ .then((res) => {
+ if (!res.ok) {
+ throw new Error('Failed to fetch items');
+ }
+ return res.json();
+ })
+ .then((data) => {
+ setItems(data);
+ setLoading(false);
+ })
+ .catch((err) => {
+ setError(err.message);
+ setLoading(false);
+ });
+ }, [feedId]);
+
+ if (loading) return <div className="feed-items-loading">Loading items...</div>;
+ if (error) return <div className="feed-items-error">Error: {error}</div>;
+
+ return (
+ <div className="feed-items">
+ <h2>Items</h2>
+ {/* TODO: Add Feed Title here if possible, maybe pass from location state or fetch feed details */}
+ {items.length === 0 ? (
+ <p>No items found.</p>
+ ) : (
+ <ul className="item-list">
+ {items.map((item) => (
+ <li key={item._id} className={`item ${item.read ? 'read' : 'unread'}`}>
+ <a href={item.url} target="_blank" rel="noopener noreferrer" className="item-title">
+ {item.title || '(No Title)'}
+ </a>
+ <div className="item-meta">
+ <span className="item-date">{new Date(item.publish_date).toLocaleDateString()}</span>
+ {item.feed_title && <span className="item-feed"> - {item.feed_title}</span>}
+ </div>
+ {item.description && (
+ <div className="item-description" dangerouslySetInnerHTML={{ __html: item.description }} />
+ )}
+ </li>
+ ))}
+ </ul>
+ )}
+ </div>
+ );
+}
diff --git a/frontend/src/components/FeedList.test.tsx b/frontend/src/components/FeedList.test.tsx
index 578e3c2..92ff345 100644
--- a/frontend/src/components/FeedList.test.tsx
+++ b/frontend/src/components/FeedList.test.tsx
@@ -4,6 +4,8 @@ import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import FeedList from './FeedList';
+import { BrowserRouter } from 'react-router-dom';
+
describe('FeedList Component', () => {
beforeEach(() => {
vi.resetAllMocks();
@@ -12,7 +14,11 @@ describe('FeedList Component', () => {
it('renders loading state initially', () => {
(global.fetch as any).mockImplementation(() => new Promise(() => { }));
- render(<FeedList />);
+ render(
+ <BrowserRouter>
+ <FeedList />
+ </BrowserRouter>
+ );
expect(screen.getByText(/loading feeds/i)).toBeInTheDocument();
});
@@ -27,7 +33,11 @@ describe('FeedList Component', () => {
json: async () => mockFeeds,
});
- render(<FeedList />);
+ render(
+ <BrowserRouter>
+ <FeedList />
+ </BrowserRouter>
+ );
await waitFor(() => {
expect(screen.getByText('Feed One')).toBeInTheDocument();
@@ -39,7 +49,11 @@ describe('FeedList Component', () => {
it('handles fetch error', async () => {
(global.fetch as any).mockRejectedValueOnce(new Error('API Error'));
- render(<FeedList />);
+ render(
+ <BrowserRouter>
+ <FeedList />
+ </BrowserRouter>
+ );
await waitFor(() => {
expect(screen.getByText(/error: api error/i)).toBeInTheDocument();
@@ -52,7 +66,11 @@ describe('FeedList Component', () => {
json: async () => [],
});
- render(<FeedList />);
+ render(
+ <BrowserRouter>
+ <FeedList />
+ </BrowserRouter>
+ );
await waitFor(() => {
expect(screen.getByText(/no feeds found/i)).toBeInTheDocument();
diff --git a/frontend/src/components/FeedList.tsx b/frontend/src/components/FeedList.tsx
index fb7c1de..f913293 100644
--- a/frontend/src/components/FeedList.tsx
+++ b/frontend/src/components/FeedList.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
+import { Link } from 'react-router-dom';
import type { Feed } from '../types';
import './FeedList.css';
@@ -37,9 +38,9 @@ export default function FeedList() {
<ul className="feed-list-items">
{feeds.map((feed) => (
<li key={feed._id} className="feed-item">
- <a href={feed.web_url} target="_blank" rel="noopener noreferrer" className="feed-title">
+ <Link to={`/feed/${feed._id}`} className="feed-title">
{feed.title || feed.url}
- </a>
+ </Link>
{feed.category && <span className="feed-category">{feed.category}</span>}
</li>
))}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 905b1dc..872e608 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -5,3 +5,17 @@ export interface Feed {
title: string;
category: string;
}
+
+export interface Item {
+ _id: number;
+ feed_id: number;
+ title: string;
+ url: string;
+ description: string;
+ publish_date: string;
+ read: boolean;
+ starred: boolean;
+ full_content?: string;
+ header_image?: string;
+ feed_title?: string;
+}