aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-14 09:55:33 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-14 09:55:33 -0800
commitae2b06f7a702ea432b801985f534ade405d0299a (patch)
treefe1fe5d2defe33785d0f6a74bce75c6a27ca628d
parent6fa13a06411048f3217397f4285b3e64e7b9ee58 (diff)
downloadneko-ae2b06f7a702ea432b801985f534ade405d0299a.tar.gz
neko-ae2b06f7a702ea432b801985f534ade405d0299a.tar.bz2
neko-ae2b06f7a702ea432b801985f534ade405d0299a.zip
ui: redesign sidebar to match v1 aesthetic and fix navigation
-rw-r--r--.thicket/tickets.jsonl3
-rw-r--r--frontend/src/App.tsx42
-rw-r--r--frontend/src/components/FeedList.css183
-rw-r--r--frontend/src/components/FeedList.test.tsx14
-rw-r--r--frontend/src/components/FeedList.tsx82
5 files changed, 139 insertions, 185 deletions
diff --git a/.thicket/tickets.jsonl b/.thicket/tickets.jsonl
index 9d027bf..a8b3e75 100644
--- a/.thicket/tickets.jsonl
+++ b/.thicket/tickets.jsonl
@@ -28,6 +28,7 @@
{"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":"open","priority":1,"labels":null,"assignee":"","created":"2026-02-14T17:32:19.995215347Z","updated":"2026-02-14T17:32:19.995215347Z"}
{"id":"NK-acq08a","title":"update Makefile","description":"Ensure the Makefile builds things and works\nTest it by running it regularly before checking in!","type":"task","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-14T00:55:40.127322076Z","updated":"2026-02-14T01:26:31.564799193Z"}
{"id":"NK-ahzf5c","title":"drop \"mark read\" button","description":"there's no mark read/unread buttons, it's just by scrolling!","type":"task","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-13T19:28:20.708443259Z","updated":"2026-02-13T20:26:43.029168286Z"}
+{"id":"NK-aiaza3","title":"clean up root directory of project","description":"There are some scripts in the root directory like run_e2e.sh that probably should be in a subdirectory -- look into it and make things a little tidier where approopriate.","type":"task","status":"open","priority":2,"labels":null,"assignee":"","created":"2026-02-14T17:42:14.8736959Z","updated":"2026-02-14T17:42:14.8736959Z"}
{"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-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"}
@@ -66,7 +67,7 @@
{"id":"NK-p89hyt","title":"make new v2 UI the default and serve at /","description":"After we move the old UI to be served at v1, serve the new UI at /\n\nWe can keep serving it at v2/ as well if we want.","type":"task","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-14T16:42:20.13241547Z","updated":"2026-02-14T17:38:26.362895517Z"}
{"id":"NK-pumdm4","title":"get rid of the \"selected\" highlight thing","description":"the legacy version doesn't do that and i find it distracting, j/k just move things up/down","type":"bug","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-13T20:27:51.434041661Z","updated":"2026-02-13T22:37:06.185341246Z"}
{"id":"NK-qwef98","title":"UI Styling: Controls \u0026 Header","description":"","type":"","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-13T18:05:18.450759919Z","updated":"2026-02-13T18:11:46.291830432Z"}
-{"id":"NK-r6nhj0","title":"import/export","description":"Import/Export has only ever been partially implemented. Let's finish it up across OPML (de facto standard) but also simple txt line oriented input/output. We may need to file a ticket to deal with the async crawling as part of this.","type":"feature","status":"open","priority":1,"labels":null,"assignee":"","created":"2026-02-14T16:45:04.739162003Z","updated":"2026-02-14T16:45:04.739162003Z"}
+{"id":"NK-r6nhj0","title":"import/export","description":"Import/Export has only ever been partially implemented. Let's finish it up across OPML (de facto standard) but also simple txt line oriented input/output. We may need to file a ticket to deal with the async crawling as part of this.","type":"feature","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-14T16:45:04.739162003Z","updated":"2026-02-14T17:42:20.713094047Z"}
{"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":"closed","priority":3,"labels":null,"assignee":"","created":"2026-02-13T04:26:55.864725765Z","updated":"2026-02-13T04:26:55.864725765Z"}
{"id":"NK-rn4nzp","title":"font themes","description":"in the v2 ui, let's offer a few different font stacks the user can switch through. primarily this should just change font-face, maybe size, but don't worry about colors or anything right now.\n\nthe current default (helvetica neue, palatino)\na fancy all serif stack\na no-nonsense modern san-serif stack\na terminal inspired fixed width stack","type":"feature","status":"open","priority":2,"labels":null,"assignee":"","created":"2026-02-14T17:10:02.185477382Z","updated":"2026-02-14T17:10:02.185477382Z"}
{"id":"NK-rohuiq","title":"titles changing on read state and hover","description":"Titles are changing on read state from blue to grey. They should just stay blue all the time.\n\nTitles are getting underlined on hover. They should have no underline regardless of hover state.","type":"bug","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-14T03:36:26.36373162Z","updated":"2026-02-14T03:37:50.73870586Z"}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 1c023c4..732d9ef 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
-import { BrowserRouter, Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
+import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import Login from './components/Login';
import './App.css';
import { apiFetch } from './utils';
@@ -37,53 +37,15 @@ import FeedItems from './components/FeedItems';
import Settings from './components/Settings';
function Dashboard({ theme, setTheme }: { theme: string; setTheme: (t: string) => void }) {
- const navigate = useNavigate();
const [sidebarVisible, setSidebarVisible] = useState(true);
return (
<div
className={`dashboard ${sidebarVisible ? 'sidebar-visible' : 'sidebar-hidden'} theme-${theme}`}
>
- <header className="dashboard-header">
- <h1
- className="logo"
- onClick={() => setSidebarVisible(!sidebarVisible)}
- style={{ cursor: 'pointer' }}
- >
- 🐱
- </h1>
- <nav>
- <button
- onClick={() => navigate('/settings')}
- className="nav-link"
- style={{
- color: 'white',
- marginRight: '1rem',
- background: 'none',
- border: 'none',
- cursor: 'pointer',
- fontSize: 'inherit',
- fontFamily: 'inherit',
- }}
- >
- Settings
- </button>
-
- <button
- onClick={() => {
- apiFetch('/api/logout', { method: 'POST' }).then(
- () => (window.location.href = '/v2/login')
- );
- }}
- className="logout-btn"
- >
- Logout
- </button>
- </nav>
- </header>
<div className="dashboard-content">
<aside className={`dashboard-sidebar ${sidebarVisible ? '' : 'hidden'}`}>
- <FeedList theme={theme} setTheme={setTheme} />
+ <FeedList theme={theme} setTheme={setTheme} setSidebarVisible={setSidebarVisible} />
</aside>
<main className="dashboard-main">
<Routes>
diff --git a/frontend/src/components/FeedList.css b/frontend/src/components/FeedList.css
index fa0278a..1b83aed 100644
--- a/frontend/src/components/FeedList.css
+++ b/frontend/src/components/FeedList.css
@@ -1,169 +1,134 @@
.feed-list {
- /* Removed card styling */
- padding: 0;
- background: transparent;
+ padding: 1rem;
+ font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ color: var(--text-color);
}
-.search-section {
- margin-bottom: 1.5rem;
+.feed-list h1.logo {
+ font-size: 3rem;
+ margin: 0 0 1rem 0;
+ line-height: 1;
+ cursor: pointer;
}
-.search-form {
- display: flex;
+.search-section {
+ margin-bottom: 1.5rem;
}
.search-input {
width: 100%;
- padding: 0.5rem;
+ padding: 0.25rem;
border: 1px solid var(--border-color, #999);
background: var(--bg-color);
color: var(--text-color);
- font-size: 1rem;
+ font-size: 0.9rem;
font-family: inherit;
}
-.search-input:focus {
- outline: none;
- background: var(--bg-color);
- border-color: var(--link-color);
-}
-
-.feed-list h2,
-.feed-section-header {
- font-size: 1.2rem;
- margin-bottom: 0.5rem;
- border-bottom: 1px solid var(--border-color, #999);
- padding-bottom: 0.25rem;
- text-transform: uppercase;
- letter-spacing: 1px;
+.section-header {
+ font-size: 1rem;
+ font-weight: bold;
+ margin: 1.5rem 0 0.5rem 0;
cursor: pointer;
user-select: none;
- display: flex;
- align-items: center;
-}
-
-.toggle-indicator {
- font-size: 0.8rem;
- margin-right: 0.5rem;
- display: inline-block;
- width: 1rem;
- text-align: center;
+ font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ color: var(--text-color);
}
-.feed-list-items,
+.filter-list,
.tag-list-items,
-.filter-list {
+.feed-list-items,
+.nav-list {
list-style: none;
padding: 0;
margin: 0;
}
-.sidebar-feed-item {
- padding: 0.25rem 0;
- border-bottom: none;
- /* Clean look */
- display: flex;
- justify-content: space-between;
- align-items: center;
+.filter-list li,
+.nav-list li {
+ margin-bottom: 0.25rem;
}
-.feed-title {
- color: var(--link-color);
- text-decoration: none;
- font-size: 0.9rem;
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
-}
-
-.feed-title:hover {
- text-decoration: underline;
- color: var(--link-color);
-}
-
-.feed-category {
- display: none;
- /* Hide category in sidebar list to save space */
-}
-
-.tag-section {
- margin-top: 2rem;
-}
-
-.tag-link {
- color: var(--link-color);
+.filter-list a,
+.nav-list a,
+.tag-link,
+.feed-title,
+.logout-link {
text-decoration: none;
+ color: var(--link-color, blue);
font-size: 0.9rem;
display: block;
- padding: 0.1rem 0;
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ cursor: pointer;
+ background: none;
+ border: none;
+ padding: 0;
+ font-family: inherit;
+ font-variant: small-caps;
+ text-transform: lowercase;
}
-.tag-link:hover {
+.filter-list a:hover,
+.nav-list a:hover,
+.tag-link:hover,
+.feed-title:hover,
+.logout-link:hover {
text-decoration: underline;
- background: transparent;
- color: var(--link-color);
}
-.filter-section {
- margin-bottom: 2rem;
+.filter-list a.active,
+.tag-link.active,
+.feed-title.active {
+ font-weight: bold;
+ color: var(--text-color);
}
-.filter-list {
- display: block;
- list-style: none;
- padding: 0;
- margin: 0;
+.tag-item,
+.sidebar-feed-item {
+ margin-bottom: 0.1rem;
}
-.filter-list li a {
- text-decoration: none;
- color: var(--text-color);
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
- font-variant: small-caps;
- text-transform: lowercase;
- font-size: 1.1rem;
- display: block;
- margin-bottom: 0.5rem;
+.feed-category {
+ display: none;
}
-.filter-list li a:hover {
- color: blue;
- background-color: transparent;
- text-decoration: underline;
+.nav-section {
+ margin-top: 2rem;
+ border-top: 1px solid var(--border-color, #ccc);
+ padding-top: 1rem;
}
-.feed-title.active,
-.tag-link.active,
-.filter-list li a.active,
-.theme-selector button.active {
- font-weight: bold !important;
+.logout-link {
+ text-align: left;
+ width: 100%;
}
.theme-section {
- margin-top: 2rem;
- padding-bottom: 2rem;
+ margin-top: 1rem;
}
.theme-selector {
display: flex;
- justify-content: space-between;
- gap: 5px;
+ gap: 0.5rem;
}
.theme-selector button {
- font-size: 1.2rem;
- padding: 0.5rem;
- width: 48%;
- background: var(--sidebar-bg);
+ background: transparent;
border: 1px solid var(--border-color, #ccc);
+ cursor: pointer;
+ padding: 0.25rem 0.5rem;
+ font-size: 1rem;
border-radius: 4px;
}
-.theme-selector button:hover {
- background: var(--bg-color);
+.theme-selector button.active {
+ background: var(--border-color, #ccc);
}
-.theme-selector button.active {
- background: var(--bg-color);
- border-color: var(--link-color);
- box-shadow: 0 0 5px var(--link-color);
+/* Scrollbar styling for webkit */
+.dashboard-sidebar::-webkit-scrollbar {
+ width: 6px;
+}
+
+.dashboard-sidebar::-webkit-scrollbar-thumb {
+ background-color: var(--border-color, #ccc);
} \ No newline at end of file
diff --git a/frontend/src/components/FeedList.test.tsx b/frontend/src/components/FeedList.test.tsx
index daa4d69..059d8a4 100644
--- a/frontend/src/components/FeedList.test.tsx
+++ b/frontend/src/components/FeedList.test.tsx
@@ -13,11 +13,11 @@ describe('FeedList Component', () => {
});
it('renders loading state initially', () => {
- (global.fetch as any).mockImplementation(() => new Promise(() => {}));
+ (global.fetch as any).mockImplementation(() => new Promise(() => { }));
render(
<BrowserRouter>
{/* @ts-ignore */}
- <FeedList theme="light" setTheme={() => {}} />
+ <FeedList theme="light" setTheme={() => { }} />
</BrowserRouter>
);
expect(screen.getByText(/loading feeds/i)).toBeInTheDocument();
@@ -60,7 +60,7 @@ describe('FeedList Component', () => {
render(
<BrowserRouter>
{/* @ts-ignore */}
- <FeedList theme="light" setTheme={() => {}} />
+ <FeedList theme="light" setTheme={() => { }} />
</BrowserRouter>
);
@@ -69,7 +69,7 @@ describe('FeedList Component', () => {
});
// Expand feeds
- fireEvent.click(screen.getByText(/feeds/i, { selector: 'h2' }));
+ fireEvent.click(screen.getByText(/feeds/i, { selector: 'h4' }));
await waitFor(() => {
expect(screen.getByText('Feed One')).toBeInTheDocument();
@@ -85,7 +85,7 @@ describe('FeedList Component', () => {
render(
<BrowserRouter>
{/* @ts-ignore */}
- <FeedList theme="light" setTheme={() => {}} />
+ <FeedList theme="light" setTheme={() => { }} setSidebarVisible={() => { }} />
</BrowserRouter>
);
@@ -114,7 +114,7 @@ describe('FeedList Component', () => {
render(
<BrowserRouter>
{/* @ts-ignore */}
- <FeedList theme="light" setTheme={() => {}} />
+ <FeedList theme="light" setTheme={() => { }} setSidebarVisible={() => { }} />
</BrowserRouter>
);
@@ -123,7 +123,7 @@ describe('FeedList Component', () => {
});
// Expand feeds
- fireEvent.click(screen.getByText(/feeds/i, { selector: 'h2' }));
+ fireEvent.click(screen.getByText(/feeds/i, { selector: 'h4' }));
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 1cd1bfd..4ce2d71 100644
--- a/frontend/src/components/FeedList.tsx
+++ b/frontend/src/components/FeedList.tsx
@@ -7,9 +7,11 @@ import { apiFetch } from '../utils';
export default function FeedList({
theme,
setTheme,
+ setSidebarVisible,
}: {
theme: string;
setTheme: (t: string) => void;
+ setSidebarVisible: (visible: boolean) => void;
}) {
const [feeds, setFeeds] = useState<Feed[]>([]);
const [tags, setTags] = useState<Category[]>([]);
@@ -62,42 +64,70 @@ export default function FeedList({
if (loading) return <div className="feed-list-loading">Loading feeds...</div>;
if (error) return <div className="feed-list-error">Error: {error}</div>;
+ const handleLogout = () => {
+ apiFetch('/api/logout', { method: 'POST' }).then(() => (window.location.href = '/v2/login'));
+ };
+
return (
<div className="feed-list">
+ <h1 className="logo" onClick={() => setSidebarVisible(false)}>
+ 🐱
+ </h1>
+
<div className="search-section">
<form onSubmit={handleSearch} className="search-form">
<input
type="search"
- placeholder="Search items..."
+ placeholder="search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="search-input"
/>
</form>
</div>
+
<div className="filter-section">
<ul className="filter-list">
- <li>
+ <li className="unread_filter">
<Link to="/?filter=unread" className={currentFilter === 'unread' ? 'active' : ''}>
- Unread
+ unread
</Link>
</li>
- <li>
+ <li className="all_filter">
<Link to="/?filter=all" className={currentFilter === 'all' ? 'active' : ''}>
- All
+ all
</Link>
</li>
- <li>
+ <li className="starred_filter">
<Link to="/?filter=starred" className={currentFilter === 'starred' ? 'active' : ''}>
- Starred
+ starred
</Link>
</li>
</ul>
</div>
+
+ <div className="tag-section">
+ <h4 onClick={() => { }} className="section-header">
+ Tags
+ </h4>
+ <ul className="tag-list-items">
+ {tags.map((tag) => (
+ <li key={tag.title} className="tag-item">
+ <Link
+ to={`/tag/${encodeURIComponent(tag.title)}`}
+ className={`tag-link ${tagName === tag.title ? 'active' : ''}`}
+ >
+ {tag.title}
+ </Link>
+ </li>
+ ))}
+ </ul>
+ </div>
+
<div className="feed-section">
- <h2 onClick={toggleFeeds} className="feed-section-header">
- <span className="toggle-indicator">{feedsExpanded ? '▼' : '▶'}</span> Feeds
- </h2>
+ <h4 onClick={toggleFeeds} className="section-header">
+ Feeds
+ </h4>
{feedsExpanded &&
(feeds.length === 0 ? (
<p>No feeds found.</p>
@@ -111,30 +141,26 @@ export default function FeedList({
>
{feed.title || feed.url}
</Link>
- {feed.category && <span className="feed-category">{feed.category}</span>}
</li>
))}
</ul>
))}
</div>
- {tags && tags.length > 0 && (
- <div className="tag-section">
- <h2>Tags</h2>
- <ul className="tag-list-items">
- {tags.map((tag) => (
- <li key={tag.title} className="tag-item">
- <Link
- to={`/tag/${encodeURIComponent(tag.title)}`}
- className={`tag-link ${tagName === tag.title ? 'active' : ''}`}
- >
- {tag.title}
- </Link>
- </li>
- ))}
- </ul>
- </div>
- )}
+ <div className="nav-section">
+ <ul className="nav-list">
+ <li>
+ <Link to="/settings" className="nav-link">
+ settings
+ </Link>
+ </li>
+ <li>
+ <button onClick={handleLogout} className="logout-link">
+ logout
+ </button>
+ </li>
+ </ul>
+ </div>
<div className="theme-section">
<div className="theme-selector">