From a113bc13e569049c59baa2165d28a992d7bdde7b Mon Sep 17 00:00:00 2001 From: Adam Mathes Date: Sun, 15 Feb 2026 18:05:38 -0800 Subject: Vanilla JS (v3): Final parity with React (Search, Settings, Shortcuts) --- frontend-vanilla/src/main.ts | 280 +++++++++++++++++++++++++++++++++---------- 1 file changed, 219 insertions(+), 61 deletions(-) (limited to 'frontend-vanilla/src/main.ts') 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('#app')!; // Initial Layout -appEl.innerHTML = ` -
- -
-
-

All Items

-
-
-
-
-
-
Select an item to read
-
-
-
-`; +function renderLayout() { + appEl.className = `theme-${store.theme} font-${store.fontTheme}`; + appEl.innerHTML = ` +
+ +
+
+

All Items

+
+
+
+
+
+
Select an item to read
+
+
+
+ `; + + // 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) => `
  • @@ -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 = '

    Loading items...

    '; @@ -99,7 +131,7 @@ function renderItems() { itemListEl.innerHTML = `
      ${items.map((item: Item) => ` -
    • +
    • ${item.title}
      ${item.feed_title || ''}
    • @@ -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) {
      From ${item.feed_title || 'Unknown'} on ${new Date(item.publish_date).toLocaleString()}
      +
      + + +
      ${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) { + 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 = ` +
      +

      Settings

      +
      +

      Theme

      +
      + + +
      +
      +
      +

      Font

      + +
      +
      + `; +} + // --- 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 = '
      Select an item to read
      '; } } 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 -- cgit v1.2.3