diff options
| author | Adam Mathes <adam@adammathes.com> | 2026-02-15 18:05:38 -0800 |
|---|---|---|
| committer | Adam Mathes <adam@adammathes.com> | 2026-02-15 18:05:38 -0800 |
| commit | a113bc13e569049c59baa2165d28a992d7bdde7b (patch) | |
| tree | 8a8ef4d856503c3c834b83b288faaa88ffc1b009 /frontend-vanilla | |
| parent | 50d01525ac9f67c5a3e680a3f807c204f6a1cdbd (diff) | |
| download | neko-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.ts | 280 | ||||
| -rw-r--r-- | frontend-vanilla/src/store.test.ts | 29 | ||||
| -rw-r--r-- | frontend-vanilla/src/store.ts | 24 | ||||
| -rw-r--r-- | frontend-vanilla/src/style.css | 114 |
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; |
