diff options
Diffstat (limited to 'frontend-vanilla/src')
| -rw-r--r-- | frontend-vanilla/src/main.ts | 119 | ||||
| -rw-r--r-- | frontend-vanilla/src/router.test.ts | 37 | ||||
| -rw-r--r-- | frontend-vanilla/src/router.ts | 30 | ||||
| -rw-r--r-- | frontend-vanilla/src/store.ts | 42 | ||||
| -rw-r--r-- | frontend-vanilla/src/style.css | 80 |
5 files changed, 268 insertions, 40 deletions
diff --git a/frontend-vanilla/src/main.ts b/frontend-vanilla/src/main.ts index 6846a67..4012386 100644 --- a/frontend-vanilla/src/main.ts +++ b/frontend-vanilla/src/main.ts @@ -1,8 +1,9 @@ import './style.css'; import { apiFetch } from './api'; import { store } from './store'; +import type { FilterType } from './store'; import { router } from './router'; -import type { Feed, Item } from './types'; +import type { Feed, Item, Category } from './types'; import { createFeedItem } from './components/FeedItem'; // Cache elements @@ -13,9 +14,26 @@ appEl.innerHTML = ` <div class="layout"> <aside class="sidebar"> <div class="sidebar-header"> - <h2>Neko v3</h2> + <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> - <ul id="feed-list" class="feed-list"></ul> </aside> <section class="item-list-pane"> <header class="top-bar"> @@ -32,6 +50,8 @@ appEl.innerHTML = ` `; 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')!; @@ -45,10 +65,28 @@ function renderFeeds() { ).join(''); } +function renderTags() { + const { tags, activeTagName } = store; + 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)}')"> + ${tag.title} + </a> + </li> + `).join(''); +} + +function renderFilters() { + const { filter } = store; + filterListEl.querySelectorAll('.filter-item').forEach(el => { + el.classList.toggle('active', el.getAttribute('data-filter') === filter); + }); +} + function renderItems() { const { items, loading } = store; - if (loading) { + if (loading && items.length === 0) { itemListEl.innerHTML = '<p class="loading">Loading items...</p>'; return; } @@ -67,6 +105,7 @@ function renderItems() { </li> `).join('')} </ul> + ${store.hasMore ? '<div id="load-more" class="load-more">Loading more...</div>' : ''} `; // Add click listeners to items @@ -76,6 +115,17 @@ function renderItems() { selectItem(id); }); }); + + // Infinite scroll observer + const loadMoreEl = document.getElementById('load-more'); + if (loadMoreEl) { + const observer = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && !store.loading && store.hasMore) { + loadMore(); + } + }, { threshold: 0.1 }); + observer.observe(loadMoreEl); + } } async function selectItem(id: number) { @@ -149,7 +199,18 @@ async function fetchFeeds() { } } -async function fetchItems(feedId?: string, tagName?: string) { +async function fetchTags() { + try { + const res = await apiFetch('/api/tag'); + if (!res.ok) throw new Error('Failed to fetch tags'); + const tags = await res.json(); + store.setTags(tags); + } catch (err) { + console.error(err); + } +} + +async function fetchItems(feedId?: string, tagName?: string, append: boolean = false) { store.setLoading(true); try { let url = '/api/stream'; @@ -157,24 +218,51 @@ async function fetchItems(feedId?: string, tagName?: string) { if (feedId) params.append('feed_id', feedId); if (tagName) params.append('tag', tagName); + // Add filter logic + if (store.filter === 'unread') params.append('read', 'false'); + if (store.filter === 'starred') params.append('starred', 'true'); + + if (append && store.items.length > 0) { + 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.setItems(items); - itemDetailEl.innerHTML = '<div class="empty-state">Select an item to read</div>'; + + store.setHasMore(items.length >= 50); // backend default page size is 50 + store.setItems(items, append); + + if (!append) { + itemDetailEl.innerHTML = '<div class="empty-state">Select an item to read</div>'; + } } catch (err) { console.error(err); - store.setItems([]); + if (!append) store.setItems([]); } finally { store.setLoading(false); } } +async function loadMore() { + const route = router.getCurrentRoute(); + fetchItems(route.params.feedId, route.params.tagName, true); +} + // --- App Logic --- 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. + } + if (route.path === '/feed' && route.params.feedId) { const id = parseInt(route.params.feedId); store.setActiveFeed(id); @@ -182,11 +270,12 @@ function handleRoute() { viewTitleEl.textContent = feed ? feed.title : `Feed ${id}`; fetchItems(route.params.feedId); } else if (route.path === '/tag' && route.params.tagName) { - store.setActiveFeed(null); + 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(); } @@ -194,7 +283,13 @@ function handleRoute() { // Subscribe to store store.on('feeds-updated', renderFeeds); +store.on('tags-updated', renderTags); store.on('active-feed-updated', renderFeeds); +store.on('active-tag-updated', renderTags); +store.on('filter-updated', () => { + renderFilters(); + handleRoute(); +}); store.on('items-updated', renderItems); store.on('loading-state-changed', renderItems); @@ -203,7 +298,8 @@ router.addEventListener('route-changed', handleRoute); // Global app object for inline handlers (window as any).app = { - navigate: (path: string) => router.navigate(path) + navigate: (path: string) => router.navigate(path), + setFilter: (filter: FilterType) => router.updateQuery({ filter }) }; // Start @@ -214,7 +310,8 @@ async function init() { return; } - await fetchFeeds(); + renderFilters(); + await Promise.all([fetchFeeds(), fetchTags()]); handleRoute(); // handles initial route } diff --git a/frontend-vanilla/src/router.test.ts b/frontend-vanilla/src/router.test.ts new file mode 100644 index 0000000..d79abc1 --- /dev/null +++ b/frontend-vanilla/src/router.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi } from 'vitest'; +import { router } from './router'; + +describe('Router', () => { + it('should parse simple paths', () => { + // Mock window.location + vi.stubGlobal('location', { + href: 'http://localhost/v3/feed/123', + pathname: '/v3/feed/123' + }); + + const route = router.getCurrentRoute(); + expect(route.path).toBe('/feed'); + expect(route.params.feedId).toBe('123'); + }); + + it('should parse tags correctly', () => { + vi.stubGlobal('location', { + href: 'http://localhost/v3/tag/Tech%20News', + pathname: '/v3/tag/Tech%20News' + }); + + const route = router.getCurrentRoute(); + expect(route.path).toBe('/tag'); + expect(route.params.tagName).toBe('Tech News'); + }); + + it('should parse query parameters', () => { + vi.stubGlobal('location', { + href: 'http://localhost/v3/?filter=starred', + pathname: '/v3/' + }); + + const route = router.getCurrentRoute(); + expect(route.query.get('filter')).toBe('starred'); + }); +}); diff --git a/frontend-vanilla/src/router.ts b/frontend-vanilla/src/router.ts index 08a9e02..46fbe06 100644 --- a/frontend-vanilla/src/router.ts +++ b/frontend-vanilla/src/router.ts @@ -1,6 +1,7 @@ export type Route = { path: string; params: Record<string, string>; + query: URLSearchParams; }; export class Router extends EventTarget { @@ -14,7 +15,8 @@ export class Router extends EventTarget { } getCurrentRoute(): Route { - const path = window.location.pathname.replace(/^\/v3\//, ''); + const url = new URL(window.location.href); + const path = url.pathname.replace(/^\/v3\//, ''); const segments = path.split('/').filter(Boolean); let routePath = '/'; @@ -25,14 +27,32 @@ export class Router extends EventTarget { params.feedId = segments[1]; } else if (segments[0] === 'tag' && segments[1]) { routePath = '/tag'; - params.tagName = segments[1]; + params.tagName = decodeURIComponent(segments[1]); } - return { path: routePath, params }; + return { path: routePath, params, query: url.searchParams }; } - navigate(path: string) { - window.history.pushState({}, '', `/v3${path}`); + navigate(path: string, query?: Record<string, string>) { + let url = `/v3${path}`; + if (query) { + const params = new URLSearchParams(query); + url += `?${params.toString()}`; + } + window.history.pushState({}, '', url); + this.handleRouteChange(); + } + + updateQuery(updates: Record<string, string>) { + const url = new URL(window.location.href); + for (const [key, value] of Object.entries(updates)) { + if (value) { + url.searchParams.set(key, value); + } else { + url.searchParams.delete(key); + } + } + window.history.pushState({}, '', url.toString()); this.handleRouteChange(); } } diff --git a/frontend-vanilla/src/store.ts b/frontend-vanilla/src/store.ts index d274c5d..c978fd2 100644 --- a/frontend-vanilla/src/store.ts +++ b/frontend-vanilla/src/store.ts @@ -1,38 +1,70 @@ -import type { Feed, Item } from './types.ts'; +import type { Feed, Item, Category } from './types.ts'; -export type StoreEvent = 'feeds-updated' | 'items-updated' | 'active-feed-updated' | 'loading-state-changed'; +export type StoreEvent = 'feeds-updated' | 'tags-updated' | 'items-updated' | 'active-feed-updated' | 'active-tag-updated' | 'loading-state-changed' | 'filter-updated'; + +export type FilterType = 'unread' | 'all' | 'starred'; export class Store extends EventTarget { feeds: Feed[] = []; + tags: Category[] = []; items: Item[] = []; activeFeedId: number | null = null; + activeTagName: string | null = null; + filter: FilterType = 'unread'; loading: boolean = false; + hasMore: boolean = true; setFeeds(feeds: Feed[]) { this.feeds = feeds; this.emit('feeds-updated'); } - setItems(items: Item[]) { - this.items = items; + setTags(tags: Category[]) { + this.tags = tags; + this.emit('tags-updated'); + } + + setItems(items: Item[], append: boolean = false) { + if (append) { + this.items = [...this.items, ...items]; + } else { + this.items = items; + } this.emit('items-updated'); } setActiveFeed(id: number | null) { this.activeFeedId = id; + this.activeTagName = null; this.emit('active-feed-updated'); } + setActiveTag(name: string | null) { + this.activeTagName = name; + this.activeFeedId = null; + this.emit('active-tag-updated'); + } + + setFilter(filter: FilterType) { + if (this.filter !== filter) { + this.filter = filter; + this.emit('filter-updated'); + } + } + setLoading(loading: boolean) { this.loading = loading; this.emit('loading-state-changed'); } + setHasMore(hasMore: boolean) { + this.hasMore = hasMore; + } + private emit(type: StoreEvent, detail?: any) { this.dispatchEvent(new CustomEvent(type, { detail })); } - // Helper to add typed listeners on(type: StoreEvent, callback: (e: CustomEvent) => void) { this.addEventListener(type, callback as EventListener); } diff --git a/frontend-vanilla/src/style.css b/frontend-vanilla/src/style.css index a9c1c61..f3523f3 100644 --- a/frontend-vanilla/src/style.css +++ b/frontend-vanilla/src/style.css @@ -18,10 +18,10 @@ :root { --bg-color: #1a1a1a; --text-color: #e9ecef; - --sidebar-bg: #2d2d2d; - --border-color: #444; + --sidebar-bg: #212529; + --border-color: #343a40; --accent-color: #375a7f; - --hover-color: #3e3e3e; + --hover-color: #2c3034; } } @@ -61,17 +61,34 @@ body { font-size: 1.1rem; } -.feed-list { +.sidebar-scroll { + flex: 1; + overflow-y: auto; + padding: 0.5rem 0; +} + +.sidebar-section { + margin-bottom: 1.5rem; +} + +.sidebar-section h3 { + padding: 0 1rem; + font-size: 0.75rem; + text-transform: uppercase; + color: #888; + margin: 0 0 0.5rem 0; + letter-spacing: 0.05rem; +} + +.sidebar-section ul { list-style: none; padding: 0; margin: 0; - overflow-y: auto; - flex: 1; } -.feed-link { +.sidebar-section li a { display: block; - padding: 0.5rem 1rem; + padding: 0.4rem 1rem; text-decoration: none; color: var(--text-color); font-size: 0.9rem; @@ -80,15 +97,19 @@ body { text-overflow: ellipsis; } -.feed-item:hover { +.sidebar-section li:hover { background-color: var(--hover-color); } -.feed-item.active { +.sidebar-section li.active { background-color: var(--hover-color); font-weight: bold; } +.sidebar-section li.active a { + color: var(--accent-color); +} + /* Item List Pane */ .item-list-pane { width: var(--item-list-width); @@ -101,6 +122,8 @@ body { .top-bar { padding: 0.75rem 1rem; border-bottom: 1px solid var(--border-color); + background-color: var(--bg-color); + z-index: 10; } .top-bar h1 { @@ -144,16 +167,23 @@ body { .item-title { font-weight: 600; - font-size: 0.95rem; + font-size: 0.9rem; margin-bottom: 0.2rem; line-height: 1.3; } .item-meta { - font-size: 0.8rem; + font-size: 0.75rem; color: #888; } +.load-more { + padding: 1.5rem; + text-align: center; + color: #888; + font-size: 0.85rem; +} + /* Item Detail Pane */ .item-detail-pane { flex: 1; @@ -162,7 +192,7 @@ body { } .item-detail-content { - max-width: 800px; + max-width: 700px; margin: 0 auto; padding: 2rem; } @@ -170,12 +200,13 @@ body { .item-detail header { margin-bottom: 2rem; border-bottom: 1px solid var(--border-color); - padding-bottom: 1rem; + padding-bottom: 1.5rem; } .item-detail h1 { - font-size: 1.8rem; - margin: 0 0 0.5rem 0; + font-size: 1.75rem; + margin: 0 0 0.75rem 0; + line-height: 1.2; } .item-detail h1 a { @@ -183,14 +214,25 @@ body { text-decoration: none; } +.item-detail h1 a:hover { + text-decoration: underline; +} + .full-content { font-size: 1.1rem; - line-height: 1.6; + line-height: 1.7; } .full-content img { max-width: 100%; height: auto; + display: block; + margin: 1.5rem 0; + border-radius: 4px; +} + +.full-content a { + color: var(--accent-color); } .empty-state { @@ -199,12 +241,12 @@ body { justify-content: center; height: 100%; color: #888; - font-size: 1.2rem; + font-size: 1.1rem; } .loading, .empty { - padding: 1rem; + padding: 2rem; text-align: center; color: #888; }
\ No newline at end of file |
