diff options
Diffstat (limited to 'frontend-vanilla/src/main.ts')
| -rw-r--r-- | frontend-vanilla/src/main.ts | 280 |
1 files changed, 219 insertions, 61 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 |
