aboutsummaryrefslogtreecommitdiffstats
path: root/frontend-vanilla/src/main.ts
diff options
context:
space:
mode:
Diffstat (limited to 'frontend-vanilla/src/main.ts')
-rw-r--r--frontend-vanilla/src/main.ts280
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