aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.thicket/tickets.jsonl3
-rw-r--r--frontend-vanilla/src/main.ts280
-rw-r--r--frontend-vanilla/src/store.test.ts29
-rw-r--r--frontend-vanilla/src/store.ts24
-rw-r--r--frontend-vanilla/src/style.css114
-rw-r--r--web/dist/v3/assets/index-A9upXj8Y.css1
-rw-r--r--web/dist/v3/assets/index-BoWfbp6N.js72
-rw-r--r--web/dist/v3/assets/index-CPnxXrEk.css1
-rw-r--r--web/dist/v3/assets/index-FNdWoCuA.js102
-rw-r--r--web/dist/v3/index.html4
10 files changed, 474 insertions, 156 deletions
diff --git a/.thicket/tickets.jsonl b/.thicket/tickets.jsonl
index b7aaf3d..531062d 100644
--- a/.thicket/tickets.jsonl
+++ b/.thicket/tickets.jsonl
@@ -23,7 +23,7 @@
{"id":"NK-6b4a2e","title":"v2 frontend BLUE LINKS","description":"Make most of the links BLUE and BOLD like in the old legacy version. Thanks","type":"bug","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-14T00:44:47.147880845Z","updated":"2026-02-14T01:09:26.770086073Z"}
{"id":"NK-6o87rr","title":"Vanilla JS: Implement Pagination","description":"Implement 'Load More' or infinite scroll for item list in vanilla JS prototype.","type":"feature","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-14T04:47:40.618957267Z","updated":"2026-02-14T04:47:40.618957267Z"}
{"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":"closed","priority":3,"labels":null,"assignee":"","created":"2026-02-13T03:54:37.630148644Z","updated":"2026-02-14T02:44:05.328784994Z"}
-{"id":"NK-7bha4u","title":"Vanilla JS (v3): Tags, Filters, and Paging","description":"","type":"feature","status":"open","priority":2,"labels":null,"assignee":"","created":"2026-02-16T01:44:59.371359809Z","updated":"2026-02-16T01:44:59.371359809Z"}
+{"id":"NK-7bha4u","title":"Vanilla JS (v3): Tags, Filters, and Paging","description":"","type":"feature","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-16T01:44:59.371359809Z","updated":"2026-02-16T02:01:57.891648743Z"}
{"id":"NK-7jh6re","title":"sidebar still ugly","description":"still very ugly, even compared to the original v1 static version\n\neither make it nicer or just copy the v1 version more directly","type":"feature","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-14T17:59:17.948112909Z","updated":"2026-02-14T18:01:26.48034794Z"}
{"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-7u97bb","title":"Freeing up space by purging very old items","description":"I have been running neko for so long that my production database is 1.4GB. Come up with a tool (ok to run it from command line) that purges some super old feed items to save space. Probably needs some variables on age, etc. Think carefully about the algorithm! it should be accessible from the CLI to start, although maybe we should show \"db size\" in settings too with an option to clean up.","type":"feature","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-15T03:01:05.643515805Z","updated":"2026-02-15T18:51:26.631274215Z"}
@@ -76,6 +76,7 @@
{"id":"NK-hidz4w","title":"Add Local Git Hooks","description":"Create a script/make target to install a pre-push hook that runs 'make check'. This enforces quality gates locally, keeping the CI clean.","type":"task","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-15T16:23:36.906173277Z","updated":"2026-02-15T17:25:28.129358055Z"}
{"id":"NK-hj6f9p","title":"Investigate E2E Tag View flakiness","description":"The E2E test for /v2/tag/Tech was temporarily disabled because it was timing out/failing. Investigate if it's a race condition or a routing issue.","type":"bug","status":"open","priority":4,"labels":null,"assignee":"","created":"2026-02-15T01:04:54.404114014Z","updated":"2026-02-15T19:14:17.974207248Z"}
{"id":"NK-hspao2","title":"Vanilla JS: Implement Test Infrastructure","description":"Setup testing infrastructure for vanilla JS prototype to ensure 80% coverage. Refactor app.js for testability and add unit tests.","type":"task","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-14T05:13:11.587767054Z","updated":"2026-02-14T05:13:11.587767054Z"}
+{"id":"NK-htk1zc","title":"Vanilla JS (v3): Settings, Search, and Keyboard Shortcuts","description":"","type":"feature","status":"open","priority":2,"labels":null,"assignee":"","created":"2026-02-16T02:02:01.850554958Z","updated":"2026-02-16T02:02:01.850554958Z"}
{"id":"NK-hy162w","title":"URLs in UI are api/feed output, not loadable HTML","description":"After clicking in the sidebar, you get to a URL like http://localhost:9001/feed/38?filter=all but if you hit \"reload\" in the browser that retuns a blob of JSON to the browser! Oops. Maybe just don't change the user visible URL at all. If we do change the URLs, maybe just use #/feed/38/filter=all or something similar that is just client side for the JS.","type":"bug","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-15T22:00:10.335296177Z","updated":"2026-02-15T22:22:41.252479327Z"}
{"id":"NK-hyej38","title":"[ui] when a left menu item is \"active\" make it bold","description":"The \"default\" is UNREAD - this should be in the \"bold\" state when you're seeing that. When you filter out to \"ALL\" that should instead be bold. Same with the individual feeds if one is selected. And Starred.","type":"epic","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-14T00:47:25.74838134Z","updated":"2026-02-14T01:25:07.267016355Z"}
{"id":"NK-iklxn4","title":"infinite scroll on ipad/mobile","description":"On a mobile device, the infinite scrooll didn't seem to be working properly and triggering as I scrolled to the bottom. Are the right triggers set up for mobile browsers as well as desktop -- this was on an ipad.","type":"bug","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-15T16:12:01.013697894Z","updated":"2026-02-15T16:35:49.542636756Z"}
diff --git a/frontend-vanilla/src/main.ts b/frontend-vanilla/src/main.ts
index 4012386..0d47575 100644
--- a/frontend-vanilla/src/main.ts
+++ b/frontend-vanilla/src/main.ts
@@ -6,48 +6,74 @@ import { router } from './router';
import type { Feed, Item, Category } from './types';
import { createFeedItem } from './components/FeedItem';
+// Extend Window interface for app object
+declare global {
+ interface Window {
+ app: any;
+ }
+}
+
// Cache elements
const appEl = document.querySelector<HTMLDivElement>('#app')!;
// Initial Layout
-appEl.innerHTML = `
- <div class="layout">
- <aside class="sidebar">
- <div class="sidebar-header">
- <h2 onclick="window.app.navigate('/')" style="cursor: pointer">Neko v3</h2>
- </div>
- <div class="sidebar-scroll">
- <section class="sidebar-section">
- <h3>Filters</h3>
- <ul id="filter-list" class="filter-list">
- <li class="filter-item" data-filter="unread"><a href="#" onclick="event.preventDefault(); window.app.setFilter('unread')">Unread</a></li>
- <li class="filter-item" data-filter="all"><a href="#" onclick="event.preventDefault(); window.app.setFilter('all')">All</a></li>
- <li class="filter-item" data-filter="starred"><a href="#" onclick="event.preventDefault(); window.app.setFilter('starred')">Starred</a></li>
- </ul>
- </section>
- <section class="sidebar-section">
- <h3>Tags</h3>
- <ul id="tag-list" class="tag-list"></ul>
- </section>
- <section class="sidebar-section">
- <h3>Feeds</h3>
- <ul id="feed-list" class="feed-list"></ul>
- </section>
- </div>
- </aside>
- <section class="item-list-pane">
- <header class="top-bar">
- <h1 id="view-title">All Items</h1>
- </header>
- <div id="item-list-container" class="item-list-container"></div>
- </section>
- <main class="item-detail-pane">
- <div id="item-detail-content" class="item-detail-content">
- <div class="empty-state">Select an item to read</div>
- </div>
- </main>
- </div>
-`;
+function renderLayout() {
+ appEl.className = `theme-${store.theme} font-${store.fontTheme}`;
+ appEl.innerHTML = `
+ <div class="layout">
+ <aside class="sidebar">
+ <div class="sidebar-header">
+ <h2 onclick="window.app.navigate('/')" style="cursor: pointer">Neko v3</h2>
+ </div>
+ <div class="sidebar-search">
+ <input type="search" id="search-input" placeholder="Search..." value="${store.searchQuery}">
+ </div>
+ <div class="sidebar-scroll">
+ <section class="sidebar-section">
+ <h3>Filters</h3>
+ <ul id="filter-list" class="filter-list">
+ <li class="filter-item" data-filter="unread"><a href="#" onclick="event.preventDefault(); window.app.setFilter('unread')">Unread</a></li>
+ <li class="filter-item" data-filter="all"><a href="#" onclick="event.preventDefault(); window.app.setFilter('all')">All</a></li>
+ <li class="filter-item" data-filter="starred"><a href="#" onclick="event.preventDefault(); window.app.setFilter('starred')">Starred</a></li>
+ </ul>
+ </section>
+ <section class="sidebar-section">
+ <h3>Tags</h3>
+ <ul id="tag-list" class="tag-list"></ul>
+ </section>
+ <section class="sidebar-section">
+ <h3>Feeds</h3>
+ <ul id="feed-list" class="feed-list"></ul>
+ </section>
+ </div>
+ <div class="sidebar-footer">
+ <a href="#" onclick="event.preventDefault(); window.app.navigate('/settings')">Settings</a>
+ <a href="#" onclick="event.preventDefault(); window.app.logout()">Logout</a>
+ </div>
+ </aside>
+ <section class="item-list-pane">
+ <header class="top-bar">
+ <h1 id="view-title">All Items</h1>
+ </header>
+ <div id="item-list-container" class="item-list-container"></div>
+ </section>
+ <main class="item-detail-pane" id="main-pane">
+ <div id="item-detail-content" class="item-detail-content">
+ <div class="empty-state">Select an item to read</div>
+ </div>
+ </main>
+ </div>
+ `;
+
+ // Attach search listener
+ const searchInput = document.getElementById('search-input') as HTMLInputElement;
+ searchInput?.addEventListener('input', (e) => {
+ const query = (e.target as HTMLInputElement).value;
+ window.app.setSearch(query);
+ });
+}
+
+renderLayout();
const feedListEl = document.getElementById('feed-list')!;
const tagListEl = document.getElementById('tag-list')!;
@@ -56,10 +82,13 @@ const viewTitleEl = document.getElementById('view-title')!;
const itemListEl = document.getElementById('item-list-container')!;
const itemDetailEl = document.getElementById('item-detail-content')!;
+let activeItemId: number | null = null;
+
// --- Rendering Functions ---
function renderFeeds() {
const { feeds, activeFeedId } = store;
+ if (!feedListEl) return;
feedListEl.innerHTML = feeds.map((feed: Feed) =>
createFeedItem(feed, feed._id === activeFeedId)
).join('');
@@ -67,6 +96,7 @@ function renderFeeds() {
function renderTags() {
const { tags, activeTagName } = store;
+ if (!tagListEl) return;
tagListEl.innerHTML = tags.map((tag: Category) => `
<li class="tag-item ${tag.title === activeTagName ? 'active' : ''}">
<a href="/v3/tag/${encodeURIComponent(tag.title)}" class="tag-link" onclick="event.preventDefault(); window.app.navigate('/tag/${encodeURIComponent(tag.title)}')">
@@ -78,6 +108,7 @@ function renderTags() {
function renderFilters() {
const { filter } = store;
+ if (!filterListEl) return;
filterListEl.querySelectorAll('.filter-item').forEach(el => {
el.classList.toggle('active', el.getAttribute('data-filter') === filter);
});
@@ -85,6 +116,7 @@ function renderFilters() {
function renderItems() {
const { items, loading } = store;
+ if (!itemListEl) return;
if (loading && items.length === 0) {
itemListEl.innerHTML = '<p class="loading">Loading items...</p>';
@@ -99,7 +131,7 @@ function renderItems() {
itemListEl.innerHTML = `
<ul class="item-list">
${items.map((item: Item) => `
- <li class="item-row ${item.read ? 'read' : ''}" data-id="${item._id}">
+ <li class="item-row ${item.read ? 'read' : ''} ${item._id === activeItemId ? 'active' : ''}" data-id="${item._id}">
<div class="item-title">${item.title}</div>
<div class="item-meta">${item.feed_title || ''}</div>
</li>
@@ -128,13 +160,18 @@ function renderItems() {
}
}
-async function selectItem(id: number) {
+async function selectItem(id: number, scroll: boolean = false) {
+ activeItemId = id;
const item = store.items.find((i: Item) => i._id === id);
if (!item) return;
// Mark active row
itemListEl.querySelectorAll('.item-row').forEach(row => {
- row.classList.toggle('active', parseInt(row.getAttribute('data-id') || '0') === id);
+ const rowId = parseInt(row.getAttribute('data-id') || '0');
+ row.classList.toggle('active', rowId === id);
+ if (scroll && rowId === id) {
+ row.scrollIntoView({ block: 'nearest' });
+ }
});
// Render basic detail
@@ -145,6 +182,10 @@ async function selectItem(id: number) {
<div class="item-meta">
From ${item.feed_title || 'Unknown'} on ${new Date(item.publish_date).toLocaleString()}
</div>
+ <div class="item-actions">
+ <button onclick="window.app.toggleStar(${item._id})">${item.starred ? '★ Unstar' : '☆ Star'}</button>
+ <button onclick="window.app.toggleRead(${item._id})">${item.read ? 'Unread' : 'Read'}</button>
+ </div>
</header>
<div id="full-content" class="full-content">
${item.description || 'No description available.'}
@@ -154,18 +195,7 @@ async function selectItem(id: number) {
// Mark as read if not already
if (!item.read) {
- try {
- await apiFetch(`/api/item/${item._id}`, {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ read: true })
- });
- item.read = true;
- const row = itemListEl.querySelector(`.item-row[data-id="${id}"]`);
- if (row) row.classList.add('read');
- } catch (err) {
- console.error('Failed to mark as read', err);
- }
+ updateItem(item._id, { read: true });
}
// Fetch full content if missing
@@ -186,6 +216,60 @@ async function selectItem(id: number) {
}
}
+async function updateItem(id: number, updates: Partial<Item>) {
+ try {
+ const res = await apiFetch(`/api/item/${id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(updates)
+ });
+ if (res.ok) {
+ const item = store.items.find(i => i._id === id);
+ if (item) {
+ Object.assign(item, updates);
+ const row = itemListEl.querySelector(`.item-row[data-id="${id}"]`);
+ if (row) {
+ if (updates.read !== undefined) row.classList.toggle('read', updates.read);
+ }
+ // Update detail view if active
+ if (activeItemId === id) {
+ const starBtn = itemDetailEl.querySelector('.item-actions button');
+ if (starBtn && updates.starred !== undefined) {
+ starBtn.textContent = updates.starred ? '★ Unstar' : '☆ Star';
+ }
+ }
+ }
+ }
+ } catch (err) {
+ console.error('Failed to update item', err);
+ }
+}
+
+function renderSettings() {
+ viewTitleEl.textContent = 'Settings';
+ itemListEl.innerHTML = '';
+ itemDetailEl.innerHTML = `
+ <div class="settings-view">
+ <h2>Settings</h2>
+ <section class="settings-section">
+ <h3>Theme</h3>
+ <div class="theme-options">
+ <button class="${store.theme === 'light' ? 'active' : ''}" onclick="window.app.setTheme('light')">Light</button>
+ <button class="${store.theme === 'dark' ? 'active' : ''}" onclick="window.app.setTheme('dark')">Dark</button>
+ </div>
+ </section>
+ <section class="settings-section">
+ <h3>Font</h3>
+ <select onchange="window.app.setFontTheme(this.value)">
+ <option value="default" ${store.fontTheme === 'default' ? 'selected' : ''}>Default</option>
+ <option value="serif" ${store.fontTheme === 'serif' ? 'selected' : ''}>Serif</option>
+ <option value="mono" ${store.fontTheme === 'mono' ? 'selected' : ''}>Monospace</option>
+ </select>
+ </section>
+ </div>
+ `;
+}
+
// --- Data Actions ---
async function fetchFeeds() {
@@ -217,8 +301,8 @@ async function fetchItems(feedId?: string, tagName?: string, append: boolean = f
const params = new URLSearchParams();
if (feedId) params.append('feed_id', feedId);
if (tagName) params.append('tag', tagName);
+ if (store.searchQuery) params.append('q', store.searchQuery);
- // Add filter logic
if (store.filter === 'unread') params.append('read', 'false');
if (store.filter === 'starred') params.append('starred', 'true');
@@ -230,10 +314,11 @@ async function fetchItems(feedId?: string, tagName?: string, append: boolean = f
if (!res.ok) throw new Error('Failed to fetch items');
const items = await res.json();
- store.setHasMore(items.length >= 50); // backend default page size is 50
+ store.setHasMore(items.length >= 50);
store.setItems(items, append);
if (!append) {
+ activeItemId = null;
itemDetailEl.innerHTML = '<div class="empty-state">Select an item to read</div>';
}
} catch (err) {
@@ -254,13 +339,19 @@ async function loadMore() {
function handleRoute() {
const route = router.getCurrentRoute();
- // Update filter from query if present
const filterFromQuery = route.query.get('filter') as FilterType;
if (filterFromQuery && ['unread', 'all', 'starred'].includes(filterFromQuery)) {
store.setFilter(filterFromQuery);
- } else {
- // Default to unread if not specified in URL and not already set
- // But actually, we want the URL to be the source of truth if possible.
+ }
+
+ const qFromQuery = route.query.get('q');
+ if (qFromQuery !== null) {
+ store.setSearchQuery(qFromQuery);
+ }
+
+ if (route.path === '/settings') {
+ renderSettings();
+ return;
}
if (route.path === '/feed' && route.params.feedId) {
@@ -281,6 +372,45 @@ function handleRoute() {
}
}
+// Keyboard shortcuts
+window.addEventListener('keydown', (e) => {
+ if (['INPUT', 'TEXTAREA'].includes((e.target as any).tagName)) return;
+
+ switch (e.key) {
+ case 'j':
+ navigateItems(1);
+ break;
+ case 'k':
+ navigateItems(-1);
+ break;
+ case 'r':
+ if (activeItemId) {
+ const item = store.items.find(i => i._id === activeItemId);
+ if (item) updateItem(item._id, { read: !item.read });
+ }
+ break;
+ case 's':
+ if (activeItemId) {
+ const item = store.items.find(i => i._id === activeItemId);
+ if (item) updateItem(item._id, { starred: !item.starred });
+ }
+ break;
+ case '/':
+ e.preventDefault();
+ document.getElementById('search-input')?.focus();
+ break;
+ }
+});
+
+function navigateItems(direction: number) {
+ if (store.items.length === 0) return;
+ let index = store.items.findIndex(i => i._id === activeItemId);
+ index += direction;
+ if (index >= 0 && index < store.items.length) {
+ selectItem(store.items[index]._id, true);
+ }
+}
+
// Subscribe to store
store.on('feeds-updated', renderFeeds);
store.on('tags-updated', renderTags);
@@ -290,6 +420,17 @@ store.on('filter-updated', () => {
renderFilters();
handleRoute();
});
+store.on('search-updated', () => {
+ const searchInput = document.getElementById('search-input') as HTMLInputElement;
+ if (searchInput && searchInput.value !== store.searchQuery) {
+ searchInput.value = store.searchQuery;
+ }
+ handleRoute();
+});
+store.on('theme-updated', () => {
+ appEl.className = `theme-${store.theme} font-${store.fontTheme}`;
+});
+
store.on('items-updated', renderItems);
store.on('loading-state-changed', renderItems);
@@ -297,9 +438,26 @@ store.on('loading-state-changed', renderItems);
router.addEventListener('route-changed', handleRoute);
// Global app object for inline handlers
-(window as any).app = {
+window.app = {
navigate: (path: string) => router.navigate(path),
- setFilter: (filter: FilterType) => router.updateQuery({ filter })
+ setFilter: (filter: FilterType) => router.updateQuery({ filter }),
+ setSearch: (q: string) => {
+ router.updateQuery({ q });
+ },
+ setTheme: (t: string) => store.setTheme(t),
+ setFontTheme: (f: string) => store.setFontTheme(f),
+ toggleStar: (id: number) => {
+ const item = store.items.find(i => i._id === id);
+ if (item) updateItem(id, { starred: !item.starred });
+ },
+ toggleRead: (id: number) => {
+ const item = store.items.find(i => i._id === id);
+ if (item) updateItem(id, { read: !item.read });
+ },
+ logout: async () => {
+ await apiFetch('/api/logout', { method: 'POST' });
+ window.location.href = '/login/';
+ }
};
// Start
diff --git a/frontend-vanilla/src/store.test.ts b/frontend-vanilla/src/store.test.ts
index 688e43e..ccf9a1d 100644
--- a/frontend-vanilla/src/store.test.ts
+++ b/frontend-vanilla/src/store.test.ts
@@ -9,7 +9,7 @@ describe('Store', () => {
];
const callback = vi.fn();
- store.addEventListener('feeds-updated', callback);
+ store.on('feeds-updated', callback);
store.setFeeds(mockFeeds);
@@ -24,8 +24,8 @@ describe('Store', () => {
const itemCallback = vi.fn();
const loadingCallback = vi.fn();
- store.addEventListener('items-updated', itemCallback);
- store.addEventListener('loading-state-changed', loadingCallback);
+ store.on('items-updated', itemCallback);
+ store.on('loading-state-changed', loadingCallback);
store.setLoading(true);
expect(store.loading).toBe(true);
@@ -39,10 +39,31 @@ describe('Store', () => {
it('should notify when active feed changes', () => {
const store = new Store();
const callback = vi.fn();
- store.addEventListener('active-feed-updated', callback);
+ store.on('active-feed-updated', callback);
store.setActiveFeed(123);
expect(store.activeFeedId).toBe(123);
expect(callback).toHaveBeenCalled();
});
+
+ it('should handle search query', () => {
+ const store = new Store();
+ const callback = vi.fn();
+ store.on('search-updated', callback);
+
+ store.setSearchQuery('test query');
+ expect(store.searchQuery).toBe('test query');
+ expect(callback).toHaveBeenCalled();
+ });
+
+ it('should handle theme changes', () => {
+ const store = new Store();
+ const callback = vi.fn();
+ store.on('theme-updated', callback);
+
+ store.setTheme('dark');
+ expect(store.theme).toBe('dark');
+ expect(localStorage.getItem('neko-theme')).toBe('dark');
+ expect(callback).toHaveBeenCalled();
+ });
});
diff --git a/frontend-vanilla/src/store.ts b/frontend-vanilla/src/store.ts
index c978fd2..a7a99b0 100644
--- a/frontend-vanilla/src/store.ts
+++ b/frontend-vanilla/src/store.ts
@@ -1,6 +1,6 @@
import type { Feed, Item, Category } from './types.ts';
-export type StoreEvent = 'feeds-updated' | 'tags-updated' | 'items-updated' | 'active-feed-updated' | 'active-tag-updated' | 'loading-state-changed' | 'filter-updated';
+export type StoreEvent = 'feeds-updated' | 'tags-updated' | 'items-updated' | 'active-feed-updated' | 'active-tag-updated' | 'loading-state-changed' | 'filter-updated' | 'search-updated' | 'theme-updated';
export type FilterType = 'unread' | 'all' | 'starred';
@@ -11,8 +11,11 @@ export class Store extends EventTarget {
activeFeedId: number | null = null;
activeTagName: string | null = null;
filter: FilterType = 'unread';
+ searchQuery: string = '';
loading: boolean = false;
hasMore: boolean = true;
+ theme: string = localStorage.getItem('neko-theme') || 'light';
+ fontTheme: string = localStorage.getItem('neko-font-theme') || 'default';
setFeeds(feeds: Feed[]) {
this.feeds = feeds;
@@ -52,6 +55,13 @@ export class Store extends EventTarget {
}
}
+ setSearchQuery(query: string) {
+ if (this.searchQuery !== query) {
+ this.searchQuery = query;
+ this.emit('search-updated');
+ }
+ }
+
setLoading(loading: boolean) {
this.loading = loading;
this.emit('loading-state-changed');
@@ -61,6 +71,18 @@ export class Store extends EventTarget {
this.hasMore = hasMore;
}
+ setTheme(theme: string) {
+ this.theme = theme;
+ localStorage.setItem('neko-theme', theme);
+ this.emit('theme-updated');
+ }
+
+ setFontTheme(fontTheme: string) {
+ this.fontTheme = fontTheme;
+ localStorage.setItem('neko-font-theme', fontTheme);
+ this.emit('theme-updated');
+ }
+
private emit(type: StoreEvent, detail?: any) {
this.dispatchEvent(new CustomEvent(type, { detail }));
}
diff --git a/frontend-vanilla/src/style.css b/frontend-vanilla/src/style.css
index f3523f3..c79fd3d 100644
--- a/frontend-vanilla/src/style.css
+++ b/frontend-vanilla/src/style.css
@@ -14,15 +14,21 @@
--item-list-width: 350px;
}
-@media (prefers-color-scheme: dark) {
- :root {
- --bg-color: #1a1a1a;
- --text-color: #e9ecef;
- --sidebar-bg: #212529;
- --border-color: #343a40;
- --accent-color: #375a7f;
- --hover-color: #2c3034;
- }
+.theme-dark {
+ --bg-color: #1a1a1a;
+ --text-color: #e9ecef;
+ --sidebar-bg: #212529;
+ --border-color: #343a40;
+ --accent-color: #375a7f;
+ --hover-color: #2c3034;
+}
+
+.font-serif {
+ font-family: Georgia, serif;
+}
+
+.font-mono {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
body {
@@ -61,19 +67,34 @@ body {
font-size: 1.1rem;
}
+.sidebar-search {
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.sidebar-search input {
+ width: 100%;
+ padding: 0.4rem 0.6rem;
+ background-color: var(--bg-color);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ color: var(--text-color);
+ font-size: 0.85rem;
+}
+
.sidebar-scroll {
flex: 1;
overflow-y: auto;
- padding: 0.5rem 0;
+ padding: 1rem 0;
}
.sidebar-section {
- margin-bottom: 1.5rem;
+ margin-bottom: 2rem;
}
.sidebar-section h3 {
padding: 0 1rem;
- font-size: 0.75rem;
+ font-size: 0.7rem;
text-transform: uppercase;
color: #888;
margin: 0 0 0.5rem 0;
@@ -110,6 +131,24 @@ body {
color: var(--accent-color);
}
+.sidebar-footer {
+ padding: 1rem;
+ border-top: 1px solid var(--border-color);
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.85rem;
+}
+
+.sidebar-footer a {
+ color: var(--text-color);
+ text-decoration: none;
+ opacity: 0.7;
+}
+
+.sidebar-footer a:hover {
+ opacity: 1;
+}
+
/* Item List Pane */
.item-list-pane {
width: var(--item-list-width);
@@ -123,12 +162,14 @@ body {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
background-color: var(--bg-color);
- z-index: 10;
+ height: 40px;
+ display: flex;
+ align-items: center;
}
.top-bar h1 {
margin: 0;
- font-size: 1rem;
+ font-size: 0.95rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -218,6 +259,26 @@ body {
text-decoration: underline;
}
+.item-actions {
+ display: flex;
+ gap: 0.5rem;
+ margin-top: 1rem;
+}
+
+.item-actions button {
+ padding: 0.3rem 0.6rem;
+ font-size: 0.8rem;
+ cursor: pointer;
+ background-color: var(--bg-color);
+ border: 1px solid var(--border-color);
+ color: var(--text-color);
+ border-radius: 4px;
+}
+
+.item-actions button:hover {
+ background-color: var(--hover-color);
+}
+
.full-content {
font-size: 1.1rem;
line-height: 1.7;
@@ -235,6 +296,31 @@ body {
color: var(--accent-color);
}
+.settings-view {
+ padding: 2rem;
+}
+
+.settings-section {
+ margin-bottom: 2rem;
+}
+
+.settings-section h3 {
+ font-size: 1rem;
+ margin-bottom: 1rem;
+ border-bottom: 1px solid var(--border-color);
+ padding-bottom: 0.5rem;
+}
+
+.theme-options {
+ display: flex;
+ gap: 1rem;
+}
+
+.theme-options button.active {
+ border-color: var(--accent-color);
+ background-color: var(--hover-color);
+}
+
.empty-state {
display: flex;
align-items: center;
diff --git a/web/dist/v3/assets/index-A9upXj8Y.css b/web/dist/v3/assets/index-A9upXj8Y.css
deleted file mode 100644
index ea66315..0000000
--- a/web/dist/v3/assets/index-A9upXj8Y.css
+++ /dev/null
@@ -1 +0,0 @@
-:root{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;line-height:1.5;font-weight:400;color-scheme:light dark;--bg-color: #ffffff;--text-color: #213547;--sidebar-bg: #f8f9fa;--border-color: #e9ecef;--accent-color: #007bff;--hover-color: #e2e6ea;--sidebar-width: 250px;--item-list-width: 350px}@media(prefers-color-scheme:dark){:root{--bg-color: #1a1a1a;--text-color: #e9ecef;--sidebar-bg: #212529;--border-color: #343a40;--accent-color: #375a7f;--hover-color: #2c3034}}body{margin:0;color:var(--text-color);background-color:var(--bg-color);height:100vh;overflow:hidden}#app{height:100%}.layout{display:flex;height:100%}.sidebar{width:var(--sidebar-width);background-color:var(--sidebar-bg);border-right:1px solid var(--border-color);display:flex;flex-direction:column}.sidebar-header{padding:1rem;border-bottom:1px solid var(--border-color)}.sidebar-header h2{margin:0;font-size:1.1rem}.sidebar-scroll{flex:1;overflow-y:auto;padding:.5rem 0}.sidebar-section{margin-bottom:1.5rem}.sidebar-section h3{padding:0 1rem;font-size:.75rem;text-transform:uppercase;color:#888;margin:0 0 .5rem;letter-spacing:.05rem}.sidebar-section ul{list-style:none;padding:0;margin:0}.sidebar-section li a{display:block;padding:.4rem 1rem;text-decoration:none;color:var(--text-color);font-size:.9rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sidebar-section li:hover{background-color:var(--hover-color)}.sidebar-section li.active{background-color:var(--hover-color);font-weight:700}.sidebar-section li.active a{color:var(--accent-color)}.item-list-pane{width:var(--item-list-width);border-right:1px solid var(--border-color);display:flex;flex-direction:column;background-color:var(--bg-color)}.top-bar{padding:.75rem 1rem;border-bottom:1px solid var(--border-color);background-color:var(--bg-color);z-index:10}.top-bar h1{margin:0;font-size:1rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.item-list-container{flex:1;overflow-y:auto}.item-list{list-style:none;padding:0;margin:0}.item-row{padding:.75rem 1rem;border-bottom:1px solid var(--border-color);cursor:pointer;transition:background .1s}.item-row:hover{background-color:var(--hover-color)}.item-row.active{background-color:var(--hover-color);border-left:3px solid var(--accent-color)}.item-row.read{opacity:.6}.item-title{font-weight:600;font-size:.9rem;margin-bottom:.2rem;line-height:1.3}.item-meta{font-size:.75rem;color:#888}.load-more{padding:1.5rem;text-align:center;color:#888;font-size:.85rem}.item-detail-pane{flex:1;overflow-y:auto;background-color:var(--bg-color)}.item-detail-content{max-width:700px;margin:0 auto;padding:2rem}.item-detail header{margin-bottom:2rem;border-bottom:1px solid var(--border-color);padding-bottom:1.5rem}.item-detail h1{font-size:1.75rem;margin:0 0 .75rem;line-height:1.2}.item-detail h1 a{color:var(--text-color);text-decoration:none}.item-detail h1 a:hover{text-decoration:underline}.full-content{font-size:1.1rem;line-height:1.7}.full-content img{max-width:100%;height:auto;display:block;margin:1.5rem 0;border-radius:4px}.full-content a{color:var(--accent-color)}.empty-state{display:flex;align-items:center;justify-content:center;height:100%;color:#888;font-size:1.1rem}.loading,.empty{padding:2rem;text-align:center;color:#888}
diff --git a/web/dist/v3/assets/index-BoWfbp6N.js b/web/dist/v3/assets/index-BoWfbp6N.js
deleted file mode 100644
index f711d40..0000000
--- a/web/dist/v3/assets/index-BoWfbp6N.js
+++ /dev/null
@@ -1,72 +0,0 @@
-(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const a of document.querySelectorAll('link[rel="modulepreload"]'))s(a);new MutationObserver(a=>{for(const r of a)if(r.type==="childList")for(const o of r.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&s(o)}).observe(document,{childList:!0,subtree:!0});function i(a){const r={};return a.integrity&&(r.integrity=a.integrity),a.referrerPolicy&&(r.referrerPolicy=a.referrerPolicy),a.crossOrigin==="use-credentials"?r.credentials="include":a.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function s(a){if(a.ep)return;a.ep=!0;const r=i(a);fetch(a.href,r)}})();function y(t){const i=`; ${document.cookie}`.split(`; ${t}=`);if(i.length===2)return i.pop()?.split(";").shift()}async function d(t,e){const i=e?.method?.toUpperCase()||"GET",s=["POST","PUT","DELETE"].includes(i),a=new Headers(e?.headers||{});if(s){const r=y("csrf_token");r&&a.set("X-CSRF-Token",r)}return fetch(t,{...e,headers:a,credentials:"include"})}class E extends EventTarget{feeds=[];tags=[];items=[];activeFeedId=null;activeTagName=null;filter="unread";loading=!1;hasMore=!0;setFeeds(e){this.feeds=e,this.emit("feeds-updated")}setTags(e){this.tags=e,this.emit("tags-updated")}setItems(e,i=!1){i?this.items=[...this.items,...e]:this.items=e,this.emit("items-updated")}setActiveFeed(e){this.activeFeedId=e,this.activeTagName=null,this.emit("active-feed-updated")}setActiveTag(e){this.activeTagName=e,this.activeFeedId=null,this.emit("active-tag-updated")}setFilter(e){this.filter!==e&&(this.filter=e,this.emit("filter-updated"))}setLoading(e){this.loading=e,this.emit("loading-state-changed")}setHasMore(e){this.hasMore=e}emit(e,i){this.dispatchEvent(new CustomEvent(e,{detail:i}))}on(e,i){this.addEventListener(e,i)}}const n=new E;class L extends EventTarget{constructor(){super(),window.addEventListener("popstate",()=>this.handleRouteChange())}handleRouteChange(){this.dispatchEvent(new CustomEvent("route-changed",{detail:this.getCurrentRoute()}))}getCurrentRoute(){const e=new URL(window.location.href),s=e.pathname.replace(/^\/v3\//,"").split("/").filter(Boolean);let a="/";const r={};return s[0]==="feed"&&s[1]?(a="/feed",r.feedId=s[1]):s[0]==="tag"&&s[1]&&(a="/tag",r.tagName=decodeURIComponent(s[1])),{path:a,params:r,query:e.searchParams}}navigate(e,i){let s=`/v3${e}`;if(i){const a=new URLSearchParams(i);s+=`?${a.toString()}`}window.history.pushState({},"",s),this.handleRouteChange()}updateQuery(e){const i=new URL(window.location.href);for(const[s,a]of Object.entries(e))a?i.searchParams.set(s,a):i.searchParams.delete(s);window.history.pushState({},"",i.toString()),this.handleRouteChange()}}const c=new L;function I(t,e){return`
- <li class="feed-item ${e?"active":""}" data-id="${t._id}">
- <a href="/v3/feed/${t._id}" class="feed-link" onclick="event.preventDefault(); window.app.navigate('/feed/${t._id}')">
- ${t.title||t.url}
- </a>
- </li>
- `}const F=document.querySelector("#app");F.innerHTML=`
- <div class="layout">
- <aside class="sidebar">
- <div class="sidebar-header">
- <h2 onclick="window.app.navigate('/')" style="cursor: pointer">Neko v3</h2>
- </div>
- <div class="sidebar-scroll">
- <section class="sidebar-section">
- <h3>Filters</h3>
- <ul id="filter-list" class="filter-list">
- <li class="filter-item" data-filter="unread"><a href="#" onclick="event.preventDefault(); window.app.setFilter('unread')">Unread</a></li>
- <li class="filter-item" data-filter="all"><a href="#" onclick="event.preventDefault(); window.app.setFilter('all')">All</a></li>
- <li class="filter-item" data-filter="starred"><a href="#" onclick="event.preventDefault(); window.app.setFilter('starred')">Starred</a></li>
- </ul>
- </section>
- <section class="sidebar-section">
- <h3>Tags</h3>
- <ul id="tag-list" class="tag-list"></ul>
- </section>
- <section class="sidebar-section">
- <h3>Feeds</h3>
- <ul id="feed-list" class="feed-list"></ul>
- </section>
- </div>
- </aside>
- <section class="item-list-pane">
- <header class="top-bar">
- <h1 id="view-title">All Items</h1>
- </header>
- <div id="item-list-container" class="item-list-container"></div>
- </section>
- <main class="item-detail-pane">
- <div id="item-detail-content" class="item-detail-content">
- <div class="empty-state">Select an item to read</div>
- </div>
- </main>
- </div>
-`;const T=document.getElementById("feed-list"),$=document.getElementById("tag-list"),S=document.getElementById("filter-list"),f=document.getElementById("view-title"),l=document.getElementById("item-list-container"),h=document.getElementById("item-detail-content");function p(){const{feeds:t,activeFeedId:e}=n;T.innerHTML=t.map(i=>I(i,i._id===e)).join("")}function g(){const{tags:t,activeTagName:e}=n;$.innerHTML=t.map(i=>`
- <li class="tag-item ${i.title===e?"active":""}">
- <a href="/v3/tag/${encodeURIComponent(i.title)}" class="tag-link" onclick="event.preventDefault(); window.app.navigate('/tag/${encodeURIComponent(i.title)}')">
- ${i.title}
- </a>
- </li>
- `).join("")}function v(){const{filter:t}=n;S.querySelectorAll(".filter-item").forEach(e=>{e.classList.toggle("active",e.getAttribute("data-filter")===t)})}function w(){const{items:t,loading:e}=n;if(e&&t.length===0){l.innerHTML='<p class="loading">Loading items...</p>';return}if(t.length===0){l.innerHTML='<p class="empty">No items found.</p>';return}l.innerHTML=`
- <ul class="item-list">
- ${t.map(s=>`
- <li class="item-row ${s.read?"read":""}" data-id="${s._id}">
- <div class="item-title">${s.title}</div>
- <div class="item-meta">${s.feed_title||""}</div>
- </li>
- `).join("")}
- </ul>
- ${n.hasMore?'<div id="load-more" class="load-more">Loading more...</div>':""}
- `,l.querySelectorAll(".item-row").forEach(s=>{s.addEventListener("click",()=>{const a=parseInt(s.getAttribute("data-id")||"0");_(a)})});const i=document.getElementById("load-more");i&&new IntersectionObserver(a=>{a[0].isIntersecting&&!n.loading&&n.hasMore&&R()},{threshold:.1}).observe(i)}async function _(t){const e=n.items.find(i=>i._id===t);if(e){if(l.querySelectorAll(".item-row").forEach(i=>{i.classList.toggle("active",parseInt(i.getAttribute("data-id")||"0")===t)}),h.innerHTML=`
- <article class="item-detail">
- <header>
- <h1><a href="${e.url}" target="_blank">${e.title}</a></h1>
- <div class="item-meta">
- From ${e.feed_title||"Unknown"} on ${new Date(e.publish_date).toLocaleString()}
- </div>
- </header>
- <div id="full-content" class="full-content">
- ${e.description||"No description available."}
- </div>
- </article>
- `,!e.read)try{await d(`/api/item/${e._id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({read:!0})}),e.read=!0;const i=l.querySelector(`.item-row[data-id="${t}"]`);i&&i.classList.add("read")}catch(i){console.error("Failed to mark as read",i)}if(e.url&&(!e.full_content||e.full_content===e.description))try{const i=await d(`/api/item/${e._id}/content`);if(i.ok){const s=await i.json();if(s.full_content){e.full_content=s.full_content;const a=document.getElementById("full-content");a&&(a.innerHTML=s.full_content)}}}catch(i){console.error("Failed to fetch full content",i)}}}async function b(){try{const t=await d("/api/feed/");if(!t.ok)throw new Error("Failed to fetch feeds");const e=await t.json();n.setFeeds(e)}catch(t){console.error(t)}}async function C(){try{const t=await d("/api/tag");if(!t.ok)throw new Error("Failed to fetch tags");const e=await t.json();n.setTags(e)}catch(t){console.error(t)}}async function u(t,e,i=!1){n.setLoading(!0);try{let s="/api/stream";const a=new URLSearchParams;t&&a.append("feed_id",t),e&&a.append("tag",e),n.filter==="unread"&&a.append("read","false"),n.filter==="starred"&&a.append("starred","true"),i&&n.items.length>0&&a.append("max_id",String(n.items[n.items.length-1]._id));const r=await d(`${s}?${a.toString()}`);if(!r.ok)throw new Error("Failed to fetch items");const o=await r.json();n.setHasMore(o.length>=50),n.setItems(o,i),i||(h.innerHTML='<div class="empty-state">Select an item to read</div>')}catch(s){console.error(s),i||n.setItems([])}finally{n.setLoading(!1)}}async function R(){const t=c.getCurrentRoute();u(t.params.feedId,t.params.tagName,!0)}function m(){const t=c.getCurrentRoute(),e=t.query.get("filter");if(e&&["unread","all","starred"].includes(e)&&n.setFilter(e),t.path==="/feed"&&t.params.feedId){const i=parseInt(t.params.feedId);n.setActiveFeed(i);const s=n.feeds.find(a=>a._id===i);f.textContent=s?s.title:`Feed ${i}`,u(t.params.feedId)}else t.path==="/tag"&&t.params.tagName?(n.setActiveTag(t.params.tagName),f.textContent=`Tag: ${t.params.tagName}`,u(void 0,t.params.tagName)):(n.setActiveFeed(null),n.setActiveTag(null),f.textContent="All Items",u())}n.on("feeds-updated",p);n.on("tags-updated",g);n.on("active-feed-updated",p);n.on("active-tag-updated",g);n.on("filter-updated",()=>{v(),m()});n.on("items-updated",w);n.on("loading-state-changed",w);c.addEventListener("route-changed",m);window.app={navigate:t=>c.navigate(t),setFilter:t=>c.updateQuery({filter:t})};async function k(){if((await d("/api/auth")).status===401){window.location.href="/login/";return}v(),await Promise.all([b(),C()]),m()}k();
diff --git a/web/dist/v3/assets/index-CPnxXrEk.css b/web/dist/v3/assets/index-CPnxXrEk.css
new file mode 100644
index 0000000..98e580a
--- /dev/null
+++ b/web/dist/v3/assets/index-CPnxXrEk.css
@@ -0,0 +1 @@
+:root{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;line-height:1.5;font-weight:400;color-scheme:light dark;--bg-color: #ffffff;--text-color: #213547;--sidebar-bg: #f8f9fa;--border-color: #e9ecef;--accent-color: #007bff;--hover-color: #e2e6ea;--sidebar-width: 250px;--item-list-width: 350px}.theme-dark{--bg-color: #1a1a1a;--text-color: #e9ecef;--sidebar-bg: #212529;--border-color: #343a40;--accent-color: #375a7f;--hover-color: #2c3034}.font-serif{font-family:Georgia,serif}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}body{margin:0;color:var(--text-color);background-color:var(--bg-color);height:100vh;overflow:hidden}#app{height:100%}.layout{display:flex;height:100%}.sidebar{width:var(--sidebar-width);background-color:var(--sidebar-bg);border-right:1px solid var(--border-color);display:flex;flex-direction:column}.sidebar-header{padding:1rem;border-bottom:1px solid var(--border-color)}.sidebar-header h2{margin:0;font-size:1.1rem}.sidebar-search{padding:.75rem 1rem;border-bottom:1px solid var(--border-color)}.sidebar-search input{width:100%;padding:.4rem .6rem;background-color:var(--bg-color);border:1px solid var(--border-color);border-radius:4px;color:var(--text-color);font-size:.85rem}.sidebar-scroll{flex:1;overflow-y:auto;padding:1rem 0}.sidebar-section{margin-bottom:2rem}.sidebar-section h3{padding:0 1rem;font-size:.7rem;text-transform:uppercase;color:#888;margin:0 0 .5rem;letter-spacing:.05rem}.sidebar-section ul{list-style:none;padding:0;margin:0}.sidebar-section li a{display:block;padding:.4rem 1rem;text-decoration:none;color:var(--text-color);font-size:.9rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sidebar-section li:hover{background-color:var(--hover-color)}.sidebar-section li.active{background-color:var(--hover-color);font-weight:700}.sidebar-section li.active a{color:var(--accent-color)}.sidebar-footer{padding:1rem;border-top:1px solid var(--border-color);display:flex;justify-content:space-between;font-size:.85rem}.sidebar-footer a{color:var(--text-color);text-decoration:none;opacity:.7}.sidebar-footer a:hover{opacity:1}.item-list-pane{width:var(--item-list-width);border-right:1px solid var(--border-color);display:flex;flex-direction:column;background-color:var(--bg-color)}.top-bar{padding:.75rem 1rem;border-bottom:1px solid var(--border-color);background-color:var(--bg-color);height:40px;display:flex;align-items:center}.top-bar h1{margin:0;font-size:.95rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.item-list-container{flex:1;overflow-y:auto}.item-list{list-style:none;padding:0;margin:0}.item-row{padding:.75rem 1rem;border-bottom:1px solid var(--border-color);cursor:pointer;transition:background .1s}.item-row:hover{background-color:var(--hover-color)}.item-row.active{background-color:var(--hover-color);border-left:3px solid var(--accent-color)}.item-row.read{opacity:.6}.item-title{font-weight:600;font-size:.9rem;margin-bottom:.2rem;line-height:1.3}.item-meta{font-size:.75rem;color:#888}.load-more{padding:1.5rem;text-align:center;color:#888;font-size:.85rem}.item-detail-pane{flex:1;overflow-y:auto;background-color:var(--bg-color)}.item-detail-content{max-width:700px;margin:0 auto;padding:2rem}.item-detail header{margin-bottom:2rem;border-bottom:1px solid var(--border-color);padding-bottom:1.5rem}.item-detail h1{font-size:1.75rem;margin:0 0 .75rem;line-height:1.2}.item-detail h1 a{color:var(--text-color);text-decoration:none}.item-detail h1 a:hover{text-decoration:underline}.item-actions{display:flex;gap:.5rem;margin-top:1rem}.item-actions button{padding:.3rem .6rem;font-size:.8rem;cursor:pointer;background-color:var(--bg-color);border:1px solid var(--border-color);color:var(--text-color);border-radius:4px}.item-actions button:hover{background-color:var(--hover-color)}.full-content{font-size:1.1rem;line-height:1.7}.full-content img{max-width:100%;height:auto;display:block;margin:1.5rem 0;border-radius:4px}.full-content a{color:var(--accent-color)}.settings-view{padding:2rem}.settings-section{margin-bottom:2rem}.settings-section h3{font-size:1rem;margin-bottom:1rem;border-bottom:1px solid var(--border-color);padding-bottom:.5rem}.theme-options{display:flex;gap:1rem}.theme-options button.active{border-color:var(--accent-color);background-color:var(--hover-color)}.empty-state{display:flex;align-items:center;justify-content:center;height:100%;color:#888;font-size:1.1rem}.loading,.empty{padding:2rem;text-align:center;color:#888}
diff --git a/web/dist/v3/assets/index-FNdWoCuA.js b/web/dist/v3/assets/index-FNdWoCuA.js
new file mode 100644
index 0000000..96a65fc
--- /dev/null
+++ b/web/dist/v3/assets/index-FNdWoCuA.js
@@ -0,0 +1,102 @@
+(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const n of document.querySelectorAll('link[rel="modulepreload"]'))s(n);new MutationObserver(n=>{for(const r of n)if(r.type==="childList")for(const d of r.addedNodes)d.tagName==="LINK"&&d.rel==="modulepreload"&&s(d)}).observe(document,{childList:!0,subtree:!0});function i(n){const r={};return n.integrity&&(r.integrity=n.integrity),n.referrerPolicy&&(r.referrerPolicy=n.referrerPolicy),n.crossOrigin==="use-credentials"?r.credentials="include":n.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function s(n){if(n.ep)return;n.ep=!0;const r=i(n);fetch(n.href,r)}})();function k(t){const i=`; ${document.cookie}`.split(`; ${t}=`);if(i.length===2)return i.pop()?.split(";").shift()}async function c(t,e){const i=e?.method?.toUpperCase()||"GET",s=["POST","PUT","DELETE"].includes(i),n=new Headers(e?.headers||{});if(s){const r=k("csrf_token");r&&n.set("X-CSRF-Token",r)}return fetch(t,{...e,headers:n,credentials:"include"})}class F extends EventTarget{feeds=[];tags=[];items=[];activeFeedId=null;activeTagName=null;filter="unread";searchQuery="";loading=!1;hasMore=!0;theme=localStorage.getItem("neko-theme")||"light";fontTheme=localStorage.getItem("neko-font-theme")||"default";setFeeds(e){this.feeds=e,this.emit("feeds-updated")}setTags(e){this.tags=e,this.emit("tags-updated")}setItems(e,i=!1){i?this.items=[...this.items,...e]:this.items=e,this.emit("items-updated")}setActiveFeed(e){this.activeFeedId=e,this.activeTagName=null,this.emit("active-feed-updated")}setActiveTag(e){this.activeTagName=e,this.activeFeedId=null,this.emit("active-tag-updated")}setFilter(e){this.filter!==e&&(this.filter=e,this.emit("filter-updated"))}setSearchQuery(e){this.searchQuery!==e&&(this.searchQuery=e,this.emit("search-updated"))}setLoading(e){this.loading=e,this.emit("loading-state-changed")}setHasMore(e){this.hasMore=e}setTheme(e){this.theme=e,localStorage.setItem("neko-theme",e),this.emit("theme-updated")}setFontTheme(e){this.fontTheme=e,localStorage.setItem("neko-font-theme",e),this.emit("theme-updated")}emit(e,i){this.dispatchEvent(new CustomEvent(e,{detail:i}))}on(e,i){this.addEventListener(e,i)}}const a=new F;class _ extends EventTarget{constructor(){super(),window.addEventListener("popstate",()=>this.handleRouteChange())}handleRouteChange(){this.dispatchEvent(new CustomEvent("route-changed",{detail:this.getCurrentRoute()}))}getCurrentRoute(){const e=new URL(window.location.href),s=e.pathname.replace(/^\/v3\//,"").split("/").filter(Boolean);let n="/";const r={};return s[0]==="feed"&&s[1]?(n="/feed",r.feedId=s[1]):s[0]==="tag"&&s[1]&&(n="/tag",r.tagName=decodeURIComponent(s[1])),{path:n,params:r,query:e.searchParams}}navigate(e,i){let s=`/v3${e}`;if(i){const n=new URLSearchParams(i);s+=`?${n.toString()}`}window.history.pushState({},"",s),this.handleRouteChange()}updateQuery(e){const i=new URL(window.location.href);for(const[s,n]of Object.entries(e))n?i.searchParams.set(s,n):i.searchParams.delete(s);window.history.pushState({},"",i.toString()),this.handleRouteChange()}}const u=new _;function R(t,e){return`
+ <li class="feed-item ${e?"active":""}" data-id="${t._id}">
+ <a href="/v3/feed/${t._id}" class="feed-link" onclick="event.preventDefault(); window.app.navigate('/feed/${t._id}')">
+ ${t.title||t.url}
+ </a>
+ </li>
+ `}const v=document.querySelector("#app");function C(){v.className=`theme-${a.theme} font-${a.fontTheme}`,v.innerHTML=`
+ <div class="layout">
+ <aside class="sidebar">
+ <div class="sidebar-header">
+ <h2 onclick="window.app.navigate('/')" style="cursor: pointer">Neko v3</h2>
+ </div>
+ <div class="sidebar-search">
+ <input type="search" id="search-input" placeholder="Search..." value="${a.searchQuery}">
+ </div>
+ <div class="sidebar-scroll">
+ <section class="sidebar-section">
+ <h3>Filters</h3>
+ <ul id="filter-list" class="filter-list">
+ <li class="filter-item" data-filter="unread"><a href="#" onclick="event.preventDefault(); window.app.setFilter('unread')">Unread</a></li>
+ <li class="filter-item" data-filter="all"><a href="#" onclick="event.preventDefault(); window.app.setFilter('all')">All</a></li>
+ <li class="filter-item" data-filter="starred"><a href="#" onclick="event.preventDefault(); window.app.setFilter('starred')">Starred</a></li>
+ </ul>
+ </section>
+ <section class="sidebar-section">
+ <h3>Tags</h3>
+ <ul id="tag-list" class="tag-list"></ul>
+ </section>
+ <section class="sidebar-section">
+ <h3>Feeds</h3>
+ <ul id="feed-list" class="feed-list"></ul>
+ </section>
+ </div>
+ <div class="sidebar-footer">
+ <a href="#" onclick="event.preventDefault(); window.app.navigate('/settings')">Settings</a>
+ <a href="#" onclick="event.preventDefault(); window.app.logout()">Logout</a>
+ </div>
+ </aside>
+ <section class="item-list-pane">
+ <header class="top-bar">
+ <h1 id="view-title">All Items</h1>
+ </header>
+ <div id="item-list-container" class="item-list-container"></div>
+ </section>
+ <main class="item-detail-pane" id="main-pane">
+ <div id="item-detail-content" class="item-detail-content">
+ <div class="empty-state">Select an item to read</div>
+ </div>
+ </main>
+ </div>
+ `,document.getElementById("search-input")?.addEventListener("input",e=>{const i=e.target.value;window.app.setSearch(i)})}C();const w=document.getElementById("feed-list"),y=document.getElementById("tag-list"),T=document.getElementById("filter-list"),h=document.getElementById("view-title"),l=document.getElementById("item-list-container"),p=document.getElementById("item-detail-content");let o=null;function $(){const{feeds:t,activeFeedId:e}=a;w&&(w.innerHTML=t.map(i=>R(i,i._id===e)).join(""))}function E(){const{tags:t,activeTagName:e}=a;y&&(y.innerHTML=t.map(i=>`
+ <li class="tag-item ${i.title===e?"active":""}">
+ <a href="/v3/tag/${encodeURIComponent(i.title)}" class="tag-link" onclick="event.preventDefault(); window.app.navigate('/tag/${encodeURIComponent(i.title)}')">
+ ${i.title}
+ </a>
+ </li>
+ `).join(""))}function L(){const{filter:t}=a;T&&T.querySelectorAll(".filter-item").forEach(e=>{e.classList.toggle("active",e.getAttribute("data-filter")===t)})}function S(){const{items:t,loading:e}=a;if(!l)return;if(e&&t.length===0){l.innerHTML='<p class="loading">Loading items...</p>';return}if(t.length===0){l.innerHTML='<p class="empty">No items found.</p>';return}l.innerHTML=`
+ <ul class="item-list">
+ ${t.map(s=>`
+ <li class="item-row ${s.read?"read":""} ${s._id===o?"active":""}" data-id="${s._id}">
+ <div class="item-title">${s.title}</div>
+ <div class="item-meta">${s.feed_title||""}</div>
+ </li>
+ `).join("")}
+ </ul>
+ ${a.hasMore?'<div id="load-more" class="load-more">Loading more...</div>':""}
+ `,l.querySelectorAll(".item-row").forEach(s=>{s.addEventListener("click",()=>{const n=parseInt(s.getAttribute("data-id")||"0");b(n)})});const i=document.getElementById("load-more");i&&new IntersectionObserver(n=>{n[0].isIntersecting&&!a.loading&&a.hasMore&&P()},{threshold:.1}).observe(i)}async function b(t,e=!1){o=t;const i=a.items.find(s=>s._id===t);if(i&&(l.querySelectorAll(".item-row").forEach(s=>{const n=parseInt(s.getAttribute("data-id")||"0");s.classList.toggle("active",n===t),e&&n===t&&s.scrollIntoView({block:"nearest"})}),p.innerHTML=`
+ <article class="item-detail">
+ <header>
+ <h1><a href="${i.url}" target="_blank">${i.title}</a></h1>
+ <div class="item-meta">
+ From ${i.feed_title||"Unknown"} on ${new Date(i.publish_date).toLocaleString()}
+ </div>
+ <div class="item-actions">
+ <button onclick="window.app.toggleStar(${i._id})">${i.starred?"★ Unstar":"☆ Star"}</button>
+ <button onclick="window.app.toggleRead(${i._id})">${i.read?"Unread":"Read"}</button>
+ </div>
+ </header>
+ <div id="full-content" class="full-content">
+ ${i.description||"No description available."}
+ </div>
+ </article>
+ `,i.read||f(i._id,{read:!0}),i.url&&(!i.full_content||i.full_content===i.description)))try{const s=await c(`/api/item/${i._id}/content`);if(s.ok){const n=await s.json();if(n.full_content){i.full_content=n.full_content;const r=document.getElementById("full-content");r&&(r.innerHTML=n.full_content)}}}catch(s){console.error("Failed to fetch full content",s)}}async function f(t,e){try{if((await c(`/api/item/${t}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)})).ok){const s=a.items.find(n=>n._id===t);if(s){Object.assign(s,e);const n=l.querySelector(`.item-row[data-id="${t}"]`);if(n&&e.read!==void 0&&n.classList.toggle("read",e.read),o===t){const r=p.querySelector(".item-actions button");r&&e.starred!==void 0&&(r.textContent=e.starred?"★ Unstar":"☆ Star")}}}}catch(i){console.error("Failed to update item",i)}}function M(){h.textContent="Settings",l.innerHTML="",p.innerHTML=`
+ <div class="settings-view">
+ <h2>Settings</h2>
+ <section class="settings-section">
+ <h3>Theme</h3>
+ <div class="theme-options">
+ <button class="${a.theme==="light"?"active":""}" onclick="window.app.setTheme('light')">Light</button>
+ <button class="${a.theme==="dark"?"active":""}" onclick="window.app.setTheme('dark')">Dark</button>
+ </div>
+ </section>
+ <section class="settings-section">
+ <h3>Font</h3>
+ <select onchange="window.app.setFontTheme(this.value)">
+ <option value="default" ${a.fontTheme==="default"?"selected":""}>Default</option>
+ <option value="serif" ${a.fontTheme==="serif"?"selected":""}>Serif</option>
+ <option value="mono" ${a.fontTheme==="mono"?"selected":""}>Monospace</option>
+ </select>
+ </section>
+ </div>
+ `}async function N(){try{const t=await c("/api/feed/");if(!t.ok)throw new Error("Failed to fetch feeds");const e=await t.json();a.setFeeds(e)}catch(t){console.error(t)}}async function A(){try{const t=await c("/api/tag");if(!t.ok)throw new Error("Failed to fetch tags");const e=await t.json();a.setTags(e)}catch(t){console.error(t)}}async function m(t,e,i=!1){a.setLoading(!0);try{let s="/api/stream";const n=new URLSearchParams;t&&n.append("feed_id",t),e&&n.append("tag",e),a.searchQuery&&n.append("q",a.searchQuery),a.filter==="unread"&&n.append("read","false"),a.filter==="starred"&&n.append("starred","true"),i&&a.items.length>0&&n.append("max_id",String(a.items[a.items.length-1]._id));const r=await c(`${s}?${n.toString()}`);if(!r.ok)throw new Error("Failed to fetch items");const d=await r.json();a.setHasMore(d.length>=50),a.setItems(d,i),i||(o=null,p.innerHTML='<div class="empty-state">Select an item to read</div>')}catch(s){console.error(s),i||a.setItems([])}finally{a.setLoading(!1)}}async function P(){const t=u.getCurrentRoute();m(t.params.feedId,t.params.tagName,!0)}function g(){const t=u.getCurrentRoute(),e=t.query.get("filter");e&&["unread","all","starred"].includes(e)&&a.setFilter(e);const i=t.query.get("q");if(i!==null&&a.setSearchQuery(i),t.path==="/settings"){M();return}if(t.path==="/feed"&&t.params.feedId){const s=parseInt(t.params.feedId);a.setActiveFeed(s);const n=a.feeds.find(r=>r._id===s);h.textContent=n?n.title:`Feed ${s}`,m(t.params.feedId)}else t.path==="/tag"&&t.params.tagName?(a.setActiveTag(t.params.tagName),h.textContent=`Tag: ${t.params.tagName}`,m(void 0,t.params.tagName)):(a.setActiveFeed(null),a.setActiveTag(null),h.textContent="All Items",m())}window.addEventListener("keydown",t=>{if(!["INPUT","TEXTAREA"].includes(t.target.tagName))switch(t.key){case"j":I(1);break;case"k":I(-1);break;case"r":if(o){const e=a.items.find(i=>i._id===o);e&&f(e._id,{read:!e.read})}break;case"s":if(o){const e=a.items.find(i=>i._id===o);e&&f(e._id,{starred:!e.starred})}break;case"/":t.preventDefault(),document.getElementById("search-input")?.focus();break}});function I(t){if(a.items.length===0)return;let e=a.items.findIndex(i=>i._id===o);e+=t,e>=0&&e<a.items.length&&b(a.items[e]._id,!0)}a.on("feeds-updated",$);a.on("tags-updated",E);a.on("active-feed-updated",$);a.on("active-tag-updated",E);a.on("filter-updated",()=>{L(),g()});a.on("search-updated",()=>{const t=document.getElementById("search-input");t&&t.value!==a.searchQuery&&(t.value=a.searchQuery),g()});a.on("theme-updated",()=>{v.className=`theme-${a.theme} font-${a.fontTheme}`});a.on("items-updated",S);a.on("loading-state-changed",S);u.addEventListener("route-changed",g);window.app={navigate:t=>u.navigate(t),setFilter:t=>u.updateQuery({filter:t}),setSearch:t=>{u.updateQuery({q:t})},setTheme:t=>a.setTheme(t),setFontTheme:t=>a.setFontTheme(t),toggleStar:t=>{const e=a.items.find(i=>i._id===t);e&&f(t,{starred:!e.starred})},toggleRead:t=>{const e=a.items.find(i=>i._id===t);e&&f(t,{read:!e.read})},logout:async()=>{await c("/api/logout",{method:"POST"}),window.location.href="/login/"}};async function U(){if((await c("/api/auth")).status===401){window.location.href="/login/";return}L(),await Promise.all([N(),A()]),g()}U();
diff --git a/web/dist/v3/index.html b/web/dist/v3/index.html
index 4d9f10e..616f437 100644
--- a/web/dist/v3/index.html
+++ b/web/dist/v3/index.html
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend-vanilla</title>
- <script type="module" crossorigin src="/v3/assets/index-BoWfbp6N.js"></script>
- <link rel="stylesheet" crossorigin href="/v3/assets/index-A9upXj8Y.css">
+ <script type="module" crossorigin src="/v3/assets/index-FNdWoCuA.js"></script>
+ <link rel="stylesheet" crossorigin href="/v3/assets/index-CPnxXrEk.css">
</head>
<body>
<div id="app"></div>