aboutsummaryrefslogtreecommitdiffstats
path: root/frontend-vanilla
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-15 18:05:38 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-15 18:05:38 -0800
commita113bc13e569049c59baa2165d28a992d7bdde7b (patch)
tree8a8ef4d856503c3c834b83b288faaa88ffc1b009 /frontend-vanilla
parent50d01525ac9f67c5a3e680a3f807c204f6a1cdbd (diff)
downloadneko-a113bc13e569049c59baa2165d28a992d7bdde7b.tar.gz
neko-a113bc13e569049c59baa2165d28a992d7bdde7b.tar.bz2
neko-a113bc13e569049c59baa2165d28a992d7bdde7b.zip
Vanilla JS (v3): Final parity with React (Search, Settings, Shortcuts)
Diffstat (limited to 'frontend-vanilla')
-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
4 files changed, 367 insertions, 80 deletions
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;