aboutsummaryrefslogtreecommitdiffstats
path: root/frontend-vanilla/src
diff options
context:
space:
mode:
Diffstat (limited to 'frontend-vanilla/src')
-rw-r--r--frontend-vanilla/src/main.ts119
-rw-r--r--frontend-vanilla/src/router.test.ts37
-rw-r--r--frontend-vanilla/src/router.ts30
-rw-r--r--frontend-vanilla/src/store.ts42
-rw-r--r--frontend-vanilla/src/style.css80
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