diff options
| author | Adam Mathes <adam@adammathes.com> | 2026-02-15 19:36:03 -0800 |
|---|---|---|
| committer | Adam Mathes <adam@adammathes.com> | 2026-02-15 19:36:03 -0800 |
| commit | 59743dcaa87920a5125915454e0afa0a22b05ee7 (patch) | |
| tree | 11666ff75c615face8dee0f479d0b3c7c79bb187 /frontend-vanilla/src/main.ts | |
| parent | a113bc13e569049c59baa2165d28a992d7bdde7b (diff) | |
| download | neko-59743dcaa87920a5125915454e0afa0a22b05ee7.tar.gz neko-59743dcaa87920a5125915454e0afa0a22b05ee7.tar.bz2 neko-59743dcaa87920a5125915454e0afa0a22b05ee7.zip | |
Vanilla JS (v3): Redesign to 2-pane glassmorphism, fix CSP errors, fix Settings view, and achieve 80% test coverage
Diffstat (limited to 'frontend-vanilla/src/main.ts')
| -rw-r--r-- | frontend-vanilla/src/main.ts | 461 |
1 files changed, 251 insertions, 210 deletions
diff --git a/frontend-vanilla/src/main.ts b/frontend-vanilla/src/main.ts index 0d47575..5e14266 100644 --- a/frontend-vanilla/src/main.ts +++ b/frontend-vanilla/src/main.ts @@ -6,24 +6,29 @@ import { router } from './router'; import type { Feed, Item, Category } from './types'; import { createFeedItem } from './components/FeedItem'; -// Extend Window interface for app object +// Extend Window interface for app object (keeping for compatibility if needed, but removing inline dependencies) declare global { interface Window { app: any; } } -// Cache elements -const appEl = document.querySelector<HTMLDivElement>('#app')!; +// Global App State +let activeItemId: number | null = null; + +// Cache elements (initialized in renderLayout) +let appEl: HTMLDivElement | null = null; -// Initial Layout -function renderLayout() { +// Initial Layout (v2-style 2-pane) +export function renderLayout() { + appEl = document.querySelector<HTMLDivElement>('#app'); + if (!appEl) return; appEl.className = `theme-${store.theme} font-${store.fontTheme}`; appEl.innerHTML = ` <div class="layout"> - <aside class="sidebar"> + <aside class="sidebar" id="sidebar"> <div class="sidebar-header"> - <h2 onclick="window.app.navigate('/')" style="cursor: pointer">Neko v3</h2> + <h2 id="logo-link">Neko v3</h2> </div> <div class="sidebar-search"> <input type="search" id="search-input" placeholder="Search..." value="${store.searchQuery}"> @@ -31,192 +36,261 @@ function renderLayout() { <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 id="filter-list"> + <li class="filter-item" data-filter="unread"><a href="/v3/?filter=unread" data-nav="filter" data-value="unread">Unread</a></li> + <li class="filter-item" data-filter="all"><a href="/v3/?filter=all" data-nav="filter" data-value="all">All</a></li> + <li class="filter-item" data-filter="starred"><a href="/v3/?filter=starred" data-nav="filter" data-value="starred">Starred</a></li> </ul> </section> <section class="sidebar-section"> <h3>Tags</h3> - <ul id="tag-list" class="tag-list"></ul> + <ul id="tag-list"></ul> </section> <section class="sidebar-section"> <h3>Feeds</h3> - <ul id="feed-list" class="feed-list"></ul> + <ul id="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> + <a href="/v3/settings" id="settings-link">Settings</a> + <a href="#" id="logout-button">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 class="main-content" id="main-content"> + <div id="content-area"></div> </main> </div> `; - // Attach search listener + attachLayoutListeners(); +} + +export function attachLayoutListeners() { const searchInput = document.getElementById('search-input') as HTMLInputElement; searchInput?.addEventListener('input', (e) => { const query = (e.target as HTMLInputElement).value; - window.app.setSearch(query); + router.updateQuery({ q: query }); }); -} -renderLayout(); + const logoLink = document.getElementById('logo-link'); + logoLink?.addEventListener('click', () => router.navigate('/')); -const feedListEl = document.getElementById('feed-list')!; -const tagListEl = document.getElementById('tag-list')!; -const filterListEl = document.getElementById('filter-list')!; -const viewTitleEl = document.getElementById('view-title')!; -const itemListEl = document.getElementById('item-list-container')!; -const itemDetailEl = document.getElementById('item-detail-content')!; + const logoutBtn = document.getElementById('logout-button'); + logoutBtn?.addEventListener('click', (e) => { + e.preventDefault(); + logout(); + }); -let activeItemId: number | null = null; + const settingsLink = document.getElementById('settings-link'); + settingsLink?.addEventListener('click', (e) => { + e.preventDefault(); + router.navigate('/settings'); + }); + + // Event delegation for filters, tags, and feeds in sidebar + const sidebar = document.getElementById('sidebar'); + sidebar?.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + const link = target.closest('a'); + if (!link) return; + + const navType = link.getAttribute('data-nav'); + if (navType === 'filter') { + e.preventDefault(); + const filter = link.getAttribute('data-value') as FilterType; + router.updateQuery({ filter }); + } else if (navType === 'tag') { + e.preventDefault(); + const tag = link.getAttribute('data-value')!; + router.navigate(`/tag/${encodeURIComponent(tag)}`); + } else if (navType === 'feed') { + e.preventDefault(); + const feedId = link.getAttribute('data-value')!; + router.navigate(`/feed/${feedId}`); + } + }); + + // Event delegation for content area (items) + const contentArea = document.getElementById('content-area'); + contentArea?.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + + // Handle Toggle Star + const starBtn = target.closest('[data-action="toggle-star"]'); + if (starBtn) { + const itemRow = starBtn.closest('[data-id]'); + if (itemRow) { + const id = parseInt(itemRow.getAttribute('data-id')!); + toggleStar(id); + } + return; + } + + // Handle Scrape + const scrapeBtn = target.closest('[data-action="scrape"]'); + if (scrapeBtn) { + const itemRow = scrapeBtn.closest('[data-id]'); + if (itemRow) { + const id = parseInt(itemRow.getAttribute('data-id')!); + scrapeItem(id); + } + return; + } + + // Handle Item interaction (mark as read on click title or row) + const itemTitle = target.closest('[data-action="open"]'); + const itemRow = target.closest('.feed-item'); + if (itemRow && !itemTitle) { // Clicking the row itself (but not the link) + // We can add "expand" logic here if we want but v2 shows it by default if loaded + // For now, let's just mark as read if it's unread + const id = parseInt(itemRow.getAttribute('data-id')!); + const item = store.items.find(i => i._id === id); + if (item && !item.read) { + updateItem(id, { read: true }); + } + } + }); +} // --- Rendering Functions --- -function renderFeeds() { +export function renderFeeds() { const { feeds, activeFeedId } = store; + const feedListEl = document.getElementById('feed-list'); if (!feedListEl) return; - feedListEl.innerHTML = feeds.map((feed: Feed) => - createFeedItem(feed, feed._id === activeFeedId) - ).join(''); + feedListEl.innerHTML = feeds.map((feed: Feed) => ` + <li class="${feed._id === activeFeedId ? 'active' : ''}"> + <a href="/v3/feed/${feed._id}" data-nav="feed" data-value="${feed._id}"> + ${feed.title || feed.url} + </a> + </li> + `).join(''); } -function renderTags() { +export function renderTags() { const { tags, activeTagName } = store; + const tagListEl = document.getElementById('tag-list'); 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)}')"> + <li class="${tag.title === activeTagName ? 'active' : ''}"> + <a href="/v3/tag/${encodeURIComponent(tag.title)}" data-nav="tag" data-value="${tag.title}"> ${tag.title} </a> </li> `).join(''); } -function renderFilters() { +export function renderFilters() { const { filter } = store; + const filterListEl = document.getElementById('filter-list'); if (!filterListEl) return; - filterListEl.querySelectorAll('.filter-item').forEach(el => { + filterListEl.querySelectorAll('li').forEach(el => { el.classList.toggle('active', el.getAttribute('data-filter') === filter); }); } -function renderItems() { +export function renderItems() { const { items, loading } = store; - if (!itemListEl) return; + const contentArea = document.getElementById('content-area'); + if (!contentArea || router.getCurrentRoute().path === '/settings') return; if (loading && items.length === 0) { - itemListEl.innerHTML = '<p class="loading">Loading items...</p>'; + contentArea.innerHTML = '<p class="loading">Loading items...</p>'; return; } if (items.length === 0) { - itemListEl.innerHTML = '<p class="empty">No items found.</p>'; + contentArea.innerHTML = '<p class="empty">No items found.</p>'; return; } - itemListEl.innerHTML = ` + contentArea.innerHTML = ` <ul class="item-list"> - ${items.map((item: Item) => ` - <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> - `).join('')} + ${items.map((item: Item) => createFeedItem(item)).join('')} </ul> - ${store.hasMore ? '<div id="load-more" class="load-more">Loading more...</div>' : ''} + ${store.hasMore ? '<div id="load-more-sentinel" class="loading-more">Loading more...</div>' : ''} `; - // Add click listeners to items - itemListEl.querySelectorAll('.item-row').forEach(row => { - row.addEventListener('click', () => { - const id = parseInt(row.getAttribute('data-id') || '0'); - selectItem(id); - }); - }); - - // Infinite scroll observer - const loadMoreEl = document.getElementById('load-more'); - if (loadMoreEl) { + // Setup infinite scroll + const sentinel = document.getElementById('load-more-sentinel'); + if (sentinel) { const observer = new IntersectionObserver((entries) => { if (entries[0].isIntersecting && !store.loading && store.hasMore) { loadMore(); } }, { threshold: 0.1 }); - observer.observe(loadMoreEl); + observer.observe(sentinel); } } -async function selectItem(id: number, scroll: boolean = false) { - activeItemId = id; - const item = store.items.find((i: Item) => i._id === id); - if (!item) return; +export function renderSettings() { + const contentArea = document.getElementById('content-area'); + if (!contentArea) return; + contentArea.innerHTML = ` + <div class="settings-view"> + <h2>Settings</h2> + <section class="settings-section"> + <h3>Theme</h3> + <div class="theme-options" id="theme-options"> + <button class="${store.theme === 'light' ? 'active' : ''}" data-theme="light">Light</button> + <button class="${store.theme === 'dark' ? 'active' : ''}" data-theme="dark">Dark</button> + </div> + </section> + <section class="settings-section"> + <h3>Font</h3> + <select id="font-selector"> + <option value="default" ${store.fontTheme === 'default' ? 'selected' : ''}>Default (Serif)</option> + <option value="serif" ${store.fontTheme === 'serif' ? 'selected' : ''}>Serif (Georgia)</option> + <option value="mono" ${store.fontTheme === 'mono' ? 'selected' : ''}>Monospace</option> + </select> + </section> + </div> + `; - // Mark active row - itemListEl.querySelectorAll('.item-row').forEach(row => { - const rowId = parseInt(row.getAttribute('data-id') || '0'); - row.classList.toggle('active', rowId === id); - if (scroll && rowId === id) { - row.scrollIntoView({ block: 'nearest' }); + // Attach settings listeners + const themeOptions = document.getElementById('theme-options'); + themeOptions?.addEventListener('click', (e) => { + const btn = (e.target as HTMLElement).closest('button'); + if (btn) { + const theme = btn.getAttribute('data-theme')!; + store.setTheme(theme); + renderSettings(); // Re-render to show active } }); - // Render basic detail - itemDetailEl.innerHTML = ` - <article class="item-detail"> - <header> - <h1><a href="${item.url}" target="_blank">${item.title}</a></h1> - <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.'} - </div> - </article> - `; + const fontSelector = document.getElementById('font-selector') as HTMLSelectElement; + fontSelector?.addEventListener('change', () => { + store.setFontTheme(fontSelector.value); + }); +} + +// --- Data Actions --- - // Mark as read if not already - if (!item.read) { - updateItem(item._id, { read: true }); +export async function toggleStar(id: number) { + const item = store.items.find(i => i._id === id); + if (item) { + updateItem(id, { starred: !item.starred }); } +} - // Fetch full content if missing - if (item.url && (!item.full_content || item.full_content === item.description)) { - try { - const res = await apiFetch(`/api/item/${item._id}/content`); - if (res.ok) { - const data = await res.json(); - if (data.full_content) { - item.full_content = data.full_content; - const contentEl = document.getElementById('full-content'); - if (contentEl) contentEl.innerHTML = data.full_content; - } +export async function scrapeItem(id: number) { + const item = store.items.find(i => i._id === id); + if (!item) return; + + try { + const res = await apiFetch(`/api/item/${id}/content`); + if (res.ok) { + const data = await res.json(); + if (data.full_content) { + updateItem(id, { full_content: data.full_content }); } - } catch (err) { - console.error('Failed to fetch full content', err); } + } catch (err) { + console.error('Failed to fetch full content', err); } } -async function updateItem(id: number, updates: Partial<Item>) { +export async function updateItem(id: number, updates: Partial<Item>) { try { const res = await apiFetch(`/api/item/${id}`, { method: 'PUT', @@ -227,15 +301,21 @@ async function updateItem(id: number, updates: Partial<Item>) { 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'; + // Selective DOM update to avoid full re-render + const el = document.querySelector(`.feed-item[data-id="${id}"]`); + if (el) { + if (updates.read !== undefined) el.classList.toggle('read', updates.read); + if (updates.starred !== undefined) { + const starBtn = el.querySelector('.star-btn'); + if (starBtn) { + starBtn.classList.toggle('is-starred', updates.starred); + starBtn.classList.toggle('is-unstarred', !updates.starred); + starBtn.setAttribute('title', updates.starred ? 'Unstar' : 'Star'); + } + } + if (updates.full_content) { + // If full content was scraped, we might need to update description or re-render chunk + renderItems(); // Full re-render is safer for content injection } } } @@ -245,64 +325,29 @@ async function updateItem(id: number, updates: Partial<Item>) { } } -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() { - try { - const res = await apiFetch('/api/feed/'); - if (!res.ok) throw new Error('Failed to fetch feeds'); +export async function fetchFeeds() { + const res = await apiFetch('/api/feed/'); + if (res.ok) { const feeds = await res.json(); store.setFeeds(feeds); - } catch (err) { - console.error(err); } } -async function fetchTags() { - try { - const res = await apiFetch('/api/tag'); - if (!res.ok) throw new Error('Failed to fetch tags'); +export async function fetchTags() { + const res = await apiFetch('/api/tag'); + if (res.ok) { const tags = await res.json(); store.setTags(tags); - } catch (err) { - console.error(err); } } -async function fetchItems(feedId?: string, tagName?: string, append: boolean = false) { +export async function fetchItems(feedId?: string, tagName?: string, append: boolean = false) { store.setLoading(true); try { - let url = '/api/stream'; 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); - if (store.filter === 'unread') params.append('read', 'false'); if (store.filter === 'starred') params.append('starred', 'true'); @@ -310,30 +355,27 @@ async function fetchItems(feedId?: string, tagName?: string, append: boolean = f params.append('max_id', String(store.items[store.items.length - 1]._id)); } - const res = await apiFetch(`${url}?${params.toString()}`); - if (!res.ok) throw new Error('Failed to fetch items'); - const items = await res.json(); - - 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>'; + const res = await apiFetch(`/api/stream?${params.toString()}`); + if (res.ok) { + const items = await res.json(); + store.setHasMore(items.length >= 50); + store.setItems(items, append); } - } catch (err) { - console.error(err); - if (!append) store.setItems([]); } finally { store.setLoading(false); } } -async function loadMore() { +export async function loadMore() { const route = router.getCurrentRoute(); fetchItems(route.params.feedId, route.params.tagName, true); } +export async function logout() { + await apiFetch('/api/logout', { method: 'POST' }); + window.location.href = '/login/'; +} + // --- App Logic --- function handleRoute() { @@ -357,17 +399,13 @@ function handleRoute() { if (route.path === '/feed' && route.params.feedId) { const id = parseInt(route.params.feedId); store.setActiveFeed(id); - const feed = store.feeds.find((f: Feed) => f._id === id); - viewTitleEl.textContent = feed ? feed.title : `Feed ${id}`; fetchItems(route.params.feedId); } else if (route.path === '/tag' && route.params.tagName) { store.setActiveTag(route.params.tagName); - viewTitleEl.textContent = `Tag: ${route.params.tagName}`; fetchItems(undefined, route.params.tagName); } else { store.setActiveFeed(null); store.setActiveTag(null); - viewTitleEl.textContent = 'All Items'; fetchItems(); } } @@ -407,7 +445,16 @@ function navigateItems(direction: number) { let index = store.items.findIndex(i => i._id === activeItemId); index += direction; if (index >= 0 && index < store.items.length) { - selectItem(store.items[index]._id, true); + activeItemId = store.items[index]._id; + const el = document.querySelector(`.feed-item[data-id="${activeItemId}"]`); + if (el) el.scrollIntoView({ block: 'nearest' }); + // Optional: mark as read when keyboard navigating + if (!store.items[index].read) updateItem(activeItemId, { read: true }); + // Since we are in 2-pane, we just scroll to it. + } else if (index === -1) { + activeItemId = store.items[0]._id; + const el = document.querySelector(`.feed-item[data-id="${activeItemId}"]`); + if (el) el.scrollIntoView({ block: 'nearest' }); } } @@ -428,7 +475,10 @@ store.on('search-updated', () => { handleRoute(); }); store.on('theme-updated', () => { - appEl.className = `theme-${store.theme} font-${store.fontTheme}`; + if (!appEl) appEl = document.querySelector<HTMLDivElement>('#app'); + if (appEl) { + appEl.className = `theme-${store.theme} font-${store.fontTheme}`; + } }); store.on('items-updated', renderItems); @@ -437,40 +487,31 @@ store.on('loading-state-changed', renderItems); // Subscribe to router router.addEventListener('route-changed', handleRoute); -// Global app object for inline handlers +// Compatibility app object (empty handlers, since we use delegation) window.app = { - navigate: (path: string) => router.navigate(path), - 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/'; - } + navigate: (path: string) => router.navigate(path) }; // Start -async function init() { +// Start +export async function init() { const authRes = await apiFetch('/api/auth'); - if (authRes.status === 401) { + if (!authRes || authRes.status === 401) { window.location.href = '/login/'; return; } + renderLayout(); renderFilters(); - await Promise.all([fetchFeeds(), fetchTags()]); - handleRoute(); // handles initial route + try { + await Promise.all([fetchFeeds(), fetchTags()]); + } catch (err) { + console.error('Initial fetch failed', err); + } + handleRoute(); } -init(); +// Only auto-init if not in a test environment +if (typeof window !== 'undefined' && !(window as any).__VITEST__) { + init(); +} |
