diff options
| author | Adam Mathes <adam@adammathes.com> | 2026-02-15 18:01:57 -0800 |
|---|---|---|
| committer | Adam Mathes <adam@adammathes.com> | 2026-02-15 18:01:57 -0800 |
| commit | 50d01525ac9f67c5a3e680a3f807c204f6a1cdbd (patch) | |
| tree | 066df58d556ed8a7573f4bc8b7141a528957a3cf | |
| parent | c652ac6a2cd23ef29f48465be09c2b674783e8e9 (diff) | |
| download | neko-50d01525ac9f67c5a3e680a3f807c204f6a1cdbd.tar.gz neko-50d01525ac9f67c5a3e680a3f807c204f6a1cdbd.tar.bz2 neko-50d01525ac9f67c5a3e680a3f807c204f6a1cdbd.zip | |
Vanilla JS (v3): Implement Tags, Filters, and Infinite Scroll
| -rw-r--r-- | .thicket/tickets.jsonl | 3 | ||||
| -rw-r--r-- | Makefile | 1 | ||||
| -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 | ||||
| -rw-r--r-- | web/dist/v3/assets/index-A9upXj8Y.css | 1 | ||||
| -rw-r--r-- | web/dist/v3/assets/index-BoWfbp6N.js | 72 | ||||
| -rw-r--r-- | web/dist/v3/assets/index-Ca6lOcOY.css | 1 | ||||
| -rw-r--r-- | web/dist/v3/assets/index-DLUux7xH.js | 48 | ||||
| -rw-r--r-- | web/dist/v3/index.html | 4 |
12 files changed, 346 insertions, 92 deletions
diff --git a/.thicket/tickets.jsonl b/.thicket/tickets.jsonl index 034a6e7..b7aaf3d 100644 --- a/.thicket/tickets.jsonl +++ b/.thicket/tickets.jsonl @@ -23,6 +23,7 @@ {"id":"NK-6b4a2e","title":"v2 frontend BLUE LINKS","description":"Make most of the links BLUE and BOLD like in the old legacy version. Thanks","type":"bug","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-14T00:44:47.147880845Z","updated":"2026-02-14T01:09:26.770086073Z"} {"id":"NK-6o87rr","title":"Vanilla JS: Implement Pagination","description":"Implement 'Load More' or infinite scroll for item list in vanilla JS prototype.","type":"feature","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-14T04:47:40.618957267Z","updated":"2026-02-14T04:47:40.618957267Z"} {"id":"NK-6q9nyg","title":"Refactor HTTP-dependent functions for testability","description":"Several functions use http.Get or external libraries directly (GetFullContent uses goose, ResolveFeedURL uses http.Get + goquery, imageProxyHandler uses http.Client). Refactor these to accept interfaces for HTTP fetching so they can be unit tested with mocks. This is the primary blocker for reaching 90% coverage.","type":"cleanup","status":"closed","priority":3,"labels":null,"assignee":"","created":"2026-02-13T03:54:37.630148644Z","updated":"2026-02-14T02:44:05.328784994Z"} +{"id":"NK-7bha4u","title":"Vanilla JS (v3): Tags, Filters, and Paging","description":"","type":"feature","status":"open","priority":2,"labels":null,"assignee":"","created":"2026-02-16T01:44:59.371359809Z","updated":"2026-02-16T01:44:59.371359809Z"} {"id":"NK-7jh6re","title":"sidebar still ugly","description":"still very ugly, even compared to the original v1 static version\n\neither make it nicer or just copy the v1 version more directly","type":"feature","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-14T17:59:17.948112909Z","updated":"2026-02-14T18:01:26.48034794Z"} {"id":"NK-7tzbql","title":"Fix TUI Content View Navigation and Interaction","description":"The TUI content view (reading a single item) is currently non-functional or severely limited. Users cannot easily navigate back, scroll, or interact with the content. This task involves improving the 'viewContent' state in the TUI.","type":"bug","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-13T05:02:57.382793121Z","updated":"2026-02-13T05:06:15.144485446Z"} {"id":"NK-7u97bb","title":"Freeing up space by purging very old items","description":"I have been running neko for so long that my production database is 1.4GB. Come up with a tool (ok to run it from command line) that purges some super old feed items to save space. Probably needs some variables on age, etc. Think carefully about the algorithm! it should be accessible from the CLI to start, although maybe we should show \"db size\" in settings too with an option to clean up.","type":"feature","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-15T03:01:05.643515805Z","updated":"2026-02-15T18:51:26.631274215Z"} @@ -126,7 +127,7 @@ {"id":"NK-thq2oq","title":"v2 ui - font size adjustments","description":"Move font-size: 18px to :root so rem units resolve correctly. Adjust title size to ~24px.","type":"bug","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-14T03:30:58.751447802Z","updated":"2026-02-14T03:31:56.358775833Z"} {"id":"NK-tw0nga","title":"E2E Testing","description":"Set up E2E testing with Playwright or Cypress to verify full flows: Login -\u003e View Feeds -\u003e View Items -\u003e Logout","type":"","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-13T15:01:33.817314728Z","updated":"2026-02-13T15:46:57.094062908Z"} {"id":"NK-ucckki","title":"security changes broke legacy","description":"I think some of the security policies make it so the old legacy one doesn't work. this may just be WAI but have a look\n\n[Warning] jQuery.Deferred exception: Refused to evaluate a string as JavaScript because 'unsafe-eval' or 'trusted-types-eval' is not an allowed source of script in the following Content Security Policy directive: \"script-src 'self'\". (jquery-3.3.1.min.js, line 2)\n (2)\n\"Function@[native code]\no@http://localhost:4994/static/jquery.tmpl.min.js:10:3543\ntemplate@http://localhost:4994/static/jquery.tmpl.min.js:10:1914\ntmpl@http://localhost:4994/static/jquery.tmpl.min.js:10:1422\nrender@http://localhost:4994/static/ui.js:208:23\nnr@http://localhost:4994/static/underscore-1.13.1.min.js:6:7308\n@http://localhost:4994/static/underscore-1.13.1.min.js:6:7733\n@http://localhost:4994/static/underscore-1.13.1.min.js:6:786\nboot@http://localhost:4994/static/ui.js:598:28\n@http://localhost:4994/static/ui.js:8:9\nl@http://localhost:4994/static/jquery-3.3.1.min.js:2:29380\n@http://localhost:4994/static/jquery-3.3.1.min.js:2:29678\"\nundefined","type":"bug","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-14T17:41:02.255772514Z","updated":"2026-02-14T17:41:02.255772514Z"} -{"id":"NK-uq032i","title":"Vanilla JS (v3): Basic Fetch and Feed List","description":"","type":"feature","status":"open","priority":1,"labels":null,"assignee":"","created":"2026-02-16T01:30:56.279645601Z","updated":"2026-02-16T01:30:56.279645601Z"} +{"id":"NK-uq032i","title":"Vanilla JS (v3): Basic Fetch and Feed List","description":"","type":"feature","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-16T01:30:56.279645601Z","updated":"2026-02-16T01:44:55.986160145Z"} {"id":"NK-uxnbu7","title":"Scaffold Vanilla JS Frontend (v3)","description":"","type":"feature","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-16T01:26:12.132325593Z","updated":"2026-02-16T01:30:44.808305994Z"} {"id":"NK-uy90he","title":"UI Styling: Feed Items (Spacing, Dateline)","description":"","type":"","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-13T18:05:17.289457994Z","updated":"2026-02-13T18:11:46.255816698Z"} {"id":"NK-uywybr","title":"https://computer.rip/rss.xml fails to importa","description":"running neko -a https://computer.rip/rss.xml gave an error. debug it and add test case to catch.","type":"bug","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-13T20:12:28.54350403Z","updated":"2026-02-14T01:03:02.755247954Z"} @@ -37,6 +37,7 @@ install: build test: ${GO} test -cover ./... cd frontend && ${NPM} test -- --run + cd frontend-vanilla && ${NPM} test -- --run test-race: ${GO} test -race ./... 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 diff --git a/web/dist/v3/assets/index-A9upXj8Y.css b/web/dist/v3/assets/index-A9upXj8Y.css new file mode 100644 index 0000000..ea66315 --- /dev/null +++ b/web/dist/v3/assets/index-A9upXj8Y.css @@ -0,0 +1 @@ +:root{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;line-height:1.5;font-weight:400;color-scheme:light dark;--bg-color: #ffffff;--text-color: #213547;--sidebar-bg: #f8f9fa;--border-color: #e9ecef;--accent-color: #007bff;--hover-color: #e2e6ea;--sidebar-width: 250px;--item-list-width: 350px}@media(prefers-color-scheme:dark){:root{--bg-color: #1a1a1a;--text-color: #e9ecef;--sidebar-bg: #212529;--border-color: #343a40;--accent-color: #375a7f;--hover-color: #2c3034}}body{margin:0;color:var(--text-color);background-color:var(--bg-color);height:100vh;overflow:hidden}#app{height:100%}.layout{display:flex;height:100%}.sidebar{width:var(--sidebar-width);background-color:var(--sidebar-bg);border-right:1px solid var(--border-color);display:flex;flex-direction:column}.sidebar-header{padding:1rem;border-bottom:1px solid var(--border-color)}.sidebar-header h2{margin:0;font-size:1.1rem}.sidebar-scroll{flex:1;overflow-y:auto;padding:.5rem 0}.sidebar-section{margin-bottom:1.5rem}.sidebar-section h3{padding:0 1rem;font-size:.75rem;text-transform:uppercase;color:#888;margin:0 0 .5rem;letter-spacing:.05rem}.sidebar-section ul{list-style:none;padding:0;margin:0}.sidebar-section li a{display:block;padding:.4rem 1rem;text-decoration:none;color:var(--text-color);font-size:.9rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sidebar-section li:hover{background-color:var(--hover-color)}.sidebar-section li.active{background-color:var(--hover-color);font-weight:700}.sidebar-section li.active a{color:var(--accent-color)}.item-list-pane{width:var(--item-list-width);border-right:1px solid var(--border-color);display:flex;flex-direction:column;background-color:var(--bg-color)}.top-bar{padding:.75rem 1rem;border-bottom:1px solid var(--border-color);background-color:var(--bg-color);z-index:10}.top-bar h1{margin:0;font-size:1rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.item-list-container{flex:1;overflow-y:auto}.item-list{list-style:none;padding:0;margin:0}.item-row{padding:.75rem 1rem;border-bottom:1px solid var(--border-color);cursor:pointer;transition:background .1s}.item-row:hover{background-color:var(--hover-color)}.item-row.active{background-color:var(--hover-color);border-left:3px solid var(--accent-color)}.item-row.read{opacity:.6}.item-title{font-weight:600;font-size:.9rem;margin-bottom:.2rem;line-height:1.3}.item-meta{font-size:.75rem;color:#888}.load-more{padding:1.5rem;text-align:center;color:#888;font-size:.85rem}.item-detail-pane{flex:1;overflow-y:auto;background-color:var(--bg-color)}.item-detail-content{max-width:700px;margin:0 auto;padding:2rem}.item-detail header{margin-bottom:2rem;border-bottom:1px solid var(--border-color);padding-bottom:1.5rem}.item-detail h1{font-size:1.75rem;margin:0 0 .75rem;line-height:1.2}.item-detail h1 a{color:var(--text-color);text-decoration:none}.item-detail h1 a:hover{text-decoration:underline}.full-content{font-size:1.1rem;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{display:flex;align-items:center;justify-content:center;height:100%;color:#888;font-size:1.1rem}.loading,.empty{padding:2rem;text-align:center;color:#888} diff --git a/web/dist/v3/assets/index-BoWfbp6N.js b/web/dist/v3/assets/index-BoWfbp6N.js new file mode 100644 index 0000000..f711d40 --- /dev/null +++ b/web/dist/v3/assets/index-BoWfbp6N.js @@ -0,0 +1,72 @@ +(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const a of document.querySelectorAll('link[rel="modulepreload"]'))s(a);new MutationObserver(a=>{for(const r of a)if(r.type==="childList")for(const o of r.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&s(o)}).observe(document,{childList:!0,subtree:!0});function i(a){const r={};return a.integrity&&(r.integrity=a.integrity),a.referrerPolicy&&(r.referrerPolicy=a.referrerPolicy),a.crossOrigin==="use-credentials"?r.credentials="include":a.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function s(a){if(a.ep)return;a.ep=!0;const r=i(a);fetch(a.href,r)}})();function y(t){const i=`; ${document.cookie}`.split(`; ${t}=`);if(i.length===2)return i.pop()?.split(";").shift()}async function d(t,e){const i=e?.method?.toUpperCase()||"GET",s=["POST","PUT","DELETE"].includes(i),a=new Headers(e?.headers||{});if(s){const r=y("csrf_token");r&&a.set("X-CSRF-Token",r)}return fetch(t,{...e,headers:a,credentials:"include"})}class E extends EventTarget{feeds=[];tags=[];items=[];activeFeedId=null;activeTagName=null;filter="unread";loading=!1;hasMore=!0;setFeeds(e){this.feeds=e,this.emit("feeds-updated")}setTags(e){this.tags=e,this.emit("tags-updated")}setItems(e,i=!1){i?this.items=[...this.items,...e]:this.items=e,this.emit("items-updated")}setActiveFeed(e){this.activeFeedId=e,this.activeTagName=null,this.emit("active-feed-updated")}setActiveTag(e){this.activeTagName=e,this.activeFeedId=null,this.emit("active-tag-updated")}setFilter(e){this.filter!==e&&(this.filter=e,this.emit("filter-updated"))}setLoading(e){this.loading=e,this.emit("loading-state-changed")}setHasMore(e){this.hasMore=e}emit(e,i){this.dispatchEvent(new CustomEvent(e,{detail:i}))}on(e,i){this.addEventListener(e,i)}}const n=new E;class L extends EventTarget{constructor(){super(),window.addEventListener("popstate",()=>this.handleRouteChange())}handleRouteChange(){this.dispatchEvent(new CustomEvent("route-changed",{detail:this.getCurrentRoute()}))}getCurrentRoute(){const e=new URL(window.location.href),s=e.pathname.replace(/^\/v3\//,"").split("/").filter(Boolean);let a="/";const r={};return s[0]==="feed"&&s[1]?(a="/feed",r.feedId=s[1]):s[0]==="tag"&&s[1]&&(a="/tag",r.tagName=decodeURIComponent(s[1])),{path:a,params:r,query:e.searchParams}}navigate(e,i){let s=`/v3${e}`;if(i){const a=new URLSearchParams(i);s+=`?${a.toString()}`}window.history.pushState({},"",s),this.handleRouteChange()}updateQuery(e){const i=new URL(window.location.href);for(const[s,a]of Object.entries(e))a?i.searchParams.set(s,a):i.searchParams.delete(s);window.history.pushState({},"",i.toString()),this.handleRouteChange()}}const c=new L;function I(t,e){return` + <li class="feed-item ${e?"active":""}" data-id="${t._id}"> + <a href="/v3/feed/${t._id}" class="feed-link" onclick="event.preventDefault(); window.app.navigate('/feed/${t._id}')"> + ${t.title||t.url} + </a> + </li> + `}const F=document.querySelector("#app");F.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> +`;const T=document.getElementById("feed-list"),$=document.getElementById("tag-list"),S=document.getElementById("filter-list"),f=document.getElementById("view-title"),l=document.getElementById("item-list-container"),h=document.getElementById("item-detail-content");function p(){const{feeds:t,activeFeedId:e}=n;T.innerHTML=t.map(i=>I(i,i._id===e)).join("")}function g(){const{tags:t,activeTagName:e}=n;$.innerHTML=t.map(i=>` + <li class="tag-item ${i.title===e?"active":""}"> + <a href="/v3/tag/${encodeURIComponent(i.title)}" class="tag-link" onclick="event.preventDefault(); window.app.navigate('/tag/${encodeURIComponent(i.title)}')"> + ${i.title} + </a> + </li> + `).join("")}function v(){const{filter:t}=n;S.querySelectorAll(".filter-item").forEach(e=>{e.classList.toggle("active",e.getAttribute("data-filter")===t)})}function w(){const{items:t,loading:e}=n;if(e&&t.length===0){l.innerHTML='<p class="loading">Loading items...</p>';return}if(t.length===0){l.innerHTML='<p class="empty">No items found.</p>';return}l.innerHTML=` + <ul class="item-list"> + ${t.map(s=>` + <li class="item-row ${s.read?"read":""}" data-id="${s._id}"> + <div class="item-title">${s.title}</div> + <div class="item-meta">${s.feed_title||""}</div> + </li> + `).join("")} + </ul> + ${n.hasMore?'<div id="load-more" class="load-more">Loading more...</div>':""} + `,l.querySelectorAll(".item-row").forEach(s=>{s.addEventListener("click",()=>{const a=parseInt(s.getAttribute("data-id")||"0");_(a)})});const i=document.getElementById("load-more");i&&new IntersectionObserver(a=>{a[0].isIntersecting&&!n.loading&&n.hasMore&&R()},{threshold:.1}).observe(i)}async function _(t){const e=n.items.find(i=>i._id===t);if(e){if(l.querySelectorAll(".item-row").forEach(i=>{i.classList.toggle("active",parseInt(i.getAttribute("data-id")||"0")===t)}),h.innerHTML=` + <article class="item-detail"> + <header> + <h1><a href="${e.url}" target="_blank">${e.title}</a></h1> + <div class="item-meta"> + From ${e.feed_title||"Unknown"} on ${new Date(e.publish_date).toLocaleString()} + </div> + </header> + <div id="full-content" class="full-content"> + ${e.description||"No description available."} + </div> + </article> + `,!e.read)try{await d(`/api/item/${e._id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({read:!0})}),e.read=!0;const i=l.querySelector(`.item-row[data-id="${t}"]`);i&&i.classList.add("read")}catch(i){console.error("Failed to mark as read",i)}if(e.url&&(!e.full_content||e.full_content===e.description))try{const i=await d(`/api/item/${e._id}/content`);if(i.ok){const s=await i.json();if(s.full_content){e.full_content=s.full_content;const a=document.getElementById("full-content");a&&(a.innerHTML=s.full_content)}}}catch(i){console.error("Failed to fetch full content",i)}}}async function b(){try{const t=await d("/api/feed/");if(!t.ok)throw new Error("Failed to fetch feeds");const e=await t.json();n.setFeeds(e)}catch(t){console.error(t)}}async function C(){try{const t=await d("/api/tag");if(!t.ok)throw new Error("Failed to fetch tags");const e=await t.json();n.setTags(e)}catch(t){console.error(t)}}async function u(t,e,i=!1){n.setLoading(!0);try{let s="/api/stream";const a=new URLSearchParams;t&&a.append("feed_id",t),e&&a.append("tag",e),n.filter==="unread"&&a.append("read","false"),n.filter==="starred"&&a.append("starred","true"),i&&n.items.length>0&&a.append("max_id",String(n.items[n.items.length-1]._id));const r=await d(`${s}?${a.toString()}`);if(!r.ok)throw new Error("Failed to fetch items");const o=await r.json();n.setHasMore(o.length>=50),n.setItems(o,i),i||(h.innerHTML='<div class="empty-state">Select an item to read</div>')}catch(s){console.error(s),i||n.setItems([])}finally{n.setLoading(!1)}}async function R(){const t=c.getCurrentRoute();u(t.params.feedId,t.params.tagName,!0)}function m(){const t=c.getCurrentRoute(),e=t.query.get("filter");if(e&&["unread","all","starred"].includes(e)&&n.setFilter(e),t.path==="/feed"&&t.params.feedId){const i=parseInt(t.params.feedId);n.setActiveFeed(i);const s=n.feeds.find(a=>a._id===i);f.textContent=s?s.title:`Feed ${i}`,u(t.params.feedId)}else t.path==="/tag"&&t.params.tagName?(n.setActiveTag(t.params.tagName),f.textContent=`Tag: ${t.params.tagName}`,u(void 0,t.params.tagName)):(n.setActiveFeed(null),n.setActiveTag(null),f.textContent="All Items",u())}n.on("feeds-updated",p);n.on("tags-updated",g);n.on("active-feed-updated",p);n.on("active-tag-updated",g);n.on("filter-updated",()=>{v(),m()});n.on("items-updated",w);n.on("loading-state-changed",w);c.addEventListener("route-changed",m);window.app={navigate:t=>c.navigate(t),setFilter:t=>c.updateQuery({filter:t})};async function k(){if((await d("/api/auth")).status===401){window.location.href="/login/";return}v(),await Promise.all([b(),C()]),m()}k(); diff --git a/web/dist/v3/assets/index-Ca6lOcOY.css b/web/dist/v3/assets/index-Ca6lOcOY.css deleted file mode 100644 index 6259461..0000000 --- a/web/dist/v3/assets/index-Ca6lOcOY.css +++ /dev/null @@ -1 +0,0 @@ -:root{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;line-height:1.5;font-weight:400;color-scheme:light dark;--bg-color: #ffffff;--text-color: #213547;--sidebar-bg: #f8f9fa;--border-color: #e9ecef;--accent-color: #007bff;--hover-color: #e2e6ea;--sidebar-width: 250px;--item-list-width: 350px}@media(prefers-color-scheme:dark){:root{--bg-color: #1a1a1a;--text-color: #e9ecef;--sidebar-bg: #2d2d2d;--border-color: #444;--accent-color: #375a7f;--hover-color: #3e3e3e}}body{margin:0;color:var(--text-color);background-color:var(--bg-color);height:100vh;overflow:hidden}#app{height:100%}.layout{display:flex;height:100%}.sidebar{width:var(--sidebar-width);background-color:var(--sidebar-bg);border-right:1px solid var(--border-color);display:flex;flex-direction:column}.sidebar-header{padding:1rem;border-bottom:1px solid var(--border-color)}.sidebar-header h2{margin:0;font-size:1.1rem}.feed-list{list-style:none;padding:0;margin:0;overflow-y:auto;flex:1}.feed-link{display:block;padding:.5rem 1rem;text-decoration:none;color:var(--text-color);font-size:.9rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.feed-item:hover{background-color:var(--hover-color)}.feed-item.active{background-color:var(--hover-color);font-weight:700}.item-list-pane{width:var(--item-list-width);border-right:1px solid var(--border-color);display:flex;flex-direction:column;background-color:var(--bg-color)}.top-bar{padding:.75rem 1rem;border-bottom:1px solid var(--border-color)}.top-bar h1{margin:0;font-size:1rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.item-list-container{flex:1;overflow-y:auto}.item-list{list-style:none;padding:0;margin:0}.item-row{padding:.75rem 1rem;border-bottom:1px solid var(--border-color);cursor:pointer;transition:background .1s}.item-row:hover{background-color:var(--hover-color)}.item-row.active{background-color:var(--hover-color);border-left:3px solid var(--accent-color)}.item-row.read{opacity:.6}.item-title{font-weight:600;font-size:.95rem;margin-bottom:.2rem;line-height:1.3}.item-meta{font-size:.8rem;color:#888}.item-detail-pane{flex:1;overflow-y:auto;background-color:var(--bg-color)}.item-detail-content{max-width:800px;margin:0 auto;padding:2rem}.item-detail header{margin-bottom:2rem;border-bottom:1px solid var(--border-color);padding-bottom:1rem}.item-detail h1{font-size:1.8rem;margin:0 0 .5rem}.item-detail h1 a{color:var(--text-color);text-decoration:none}.full-content{font-size:1.1rem;line-height:1.6}.full-content img{max-width:100%;height:auto}.empty-state{display:flex;align-items:center;justify-content:center;height:100%;color:#888;font-size:1.2rem}.loading,.empty{padding:1rem;text-align:center;color:#888} diff --git a/web/dist/v3/assets/index-DLUux7xH.js b/web/dist/v3/assets/index-DLUux7xH.js deleted file mode 100644 index 972c275..0000000 --- a/web/dist/v3/assets/index-DLUux7xH.js +++ /dev/null @@ -1,48 +0,0 @@ -(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const n of document.querySelectorAll('link[rel="modulepreload"]'))a(n);new MutationObserver(n=>{for(const s of n)if(s.type==="childList")for(const c of s.addedNodes)c.tagName==="LINK"&&c.rel==="modulepreload"&&a(c)}).observe(document,{childList:!0,subtree:!0});function t(n){const s={};return n.integrity&&(s.integrity=n.integrity),n.referrerPolicy&&(s.referrerPolicy=n.referrerPolicy),n.crossOrigin==="use-credentials"?s.credentials="include":n.crossOrigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function a(n){if(n.ep)return;n.ep=!0;const s=t(n);fetch(n.href,s)}})();function v(i){const t=`; ${document.cookie}`.split(`; ${i}=`);if(t.length===2)return t.pop()?.split(";").shift()}async function d(i,e){const t=e?.method?.toUpperCase()||"GET",a=["POST","PUT","DELETE"].includes(t),n=new Headers(e?.headers||{});if(a){const s=v("csrf_token");s&&n.set("X-CSRF-Token",s)}return fetch(i,{...e,headers:n,credentials:"include"})}class w extends EventTarget{feeds=[];items=[];activeFeedId=null;loading=!1;setFeeds(e){this.feeds=e,this.emit("feeds-updated")}setItems(e){this.items=e,this.emit("items-updated")}setActiveFeed(e){this.activeFeedId=e,this.emit("active-feed-updated")}setLoading(e){this.loading=e,this.emit("loading-state-changed")}emit(e,t){this.dispatchEvent(new CustomEvent(e,{detail:t}))}on(e,t){this.addEventListener(e,t)}}const o=new w;class y extends EventTarget{constructor(){super(),window.addEventListener("popstate",()=>this.handleRouteChange())}handleRouteChange(){this.dispatchEvent(new CustomEvent("route-changed",{detail:this.getCurrentRoute()}))}getCurrentRoute(){const t=window.location.pathname.replace(/^\/v3\//,"").split("/").filter(Boolean);let a="/";const n={};return t[0]==="feed"&&t[1]?(a="/feed",n.feedId=t[1]):t[0]==="tag"&&t[1]&&(a="/tag",n.tagName=t[1]),{path:a,params:n}}navigate(e){window.history.pushState({},"",`/v3${e}`),this.handleRouteChange()}}const f=new y;function E(i,e){return` - <li class="feed-item ${e?"active":""}" data-id="${i._id}"> - <a href="/v3/feed/${i._id}" class="feed-link" onclick="event.preventDefault(); window.app.navigate('/feed/${i._id}')"> - ${i.title||i.url} - </a> - </li> - `}const L=document.querySelector("#app");L.innerHTML=` - <div class="layout"> - <aside class="sidebar"> - <div class="sidebar-header"> - <h2>Neko v3</h2> - </div> - <ul id="feed-list" class="feed-list"></ul> - </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> -`;const $=document.getElementById("feed-list"),l=document.getElementById("view-title"),r=document.getElementById("item-list-container"),m=document.getElementById("item-detail-content");function p(){const{feeds:i,activeFeedId:e}=o;$.innerHTML=i.map(t=>E(t,t._id===e)).join("")}function h(){const{items:i,loading:e}=o;if(e){r.innerHTML='<p class="loading">Loading items...</p>';return}if(i.length===0){r.innerHTML='<p class="empty">No items found.</p>';return}r.innerHTML=` - <ul class="item-list"> - ${i.map(t=>` - <li class="item-row ${t.read?"read":""}" data-id="${t._id}"> - <div class="item-title">${t.title}</div> - <div class="item-meta">${t.feed_title||""}</div> - </li> - `).join("")} - </ul> - `,r.querySelectorAll(".item-row").forEach(t=>{t.addEventListener("click",()=>{const a=parseInt(t.getAttribute("data-id")||"0");I(a)})})}async function I(i){const e=o.items.find(t=>t._id===i);if(e){if(r.querySelectorAll(".item-row").forEach(t=>{t.classList.toggle("active",parseInt(t.getAttribute("data-id")||"0")===i)}),m.innerHTML=` - <article class="item-detail"> - <header> - <h1><a href="${e.url}" target="_blank">${e.title}</a></h1> - <div class="item-meta"> - From ${e.feed_title||"Unknown"} on ${new Date(e.publish_date).toLocaleString()} - </div> - </header> - <div id="full-content" class="full-content"> - ${e.description||"No description available."} - </div> - </article> - `,!e.read)try{await d(`/api/item/${e._id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({read:!0})}),e.read=!0;const t=r.querySelector(`.item-row[data-id="${i}"]`);t&&t.classList.add("read")}catch(t){console.error("Failed to mark as read",t)}if(e.url&&(!e.full_content||e.full_content===e.description))try{const t=await d(`/api/item/${e._id}/content`);if(t.ok){const a=await t.json();if(a.full_content){e.full_content=a.full_content;const n=document.getElementById("full-content");n&&(n.innerHTML=a.full_content)}}}catch(t){console.error("Failed to fetch full content",t)}}}async function F(){try{const i=await d("/api/feed/");if(!i.ok)throw new Error("Failed to fetch feeds");const e=await i.json();o.setFeeds(e)}catch(i){console.error(i)}}async function u(i,e){o.setLoading(!0);try{let t="/api/stream";const a=new URLSearchParams;i&&a.append("feed_id",i),e&&a.append("tag",e);const n=await d(`${t}?${a.toString()}`);if(!n.ok)throw new Error("Failed to fetch items");const s=await n.json();o.setItems(s),m.innerHTML='<div class="empty-state">Select an item to read</div>'}catch(t){console.error(t),o.setItems([])}finally{o.setLoading(!1)}}function g(){const i=f.getCurrentRoute();if(i.path==="/feed"&&i.params.feedId){const e=parseInt(i.params.feedId);o.setActiveFeed(e);const t=o.feeds.find(a=>a._id===e);l.textContent=t?t.title:`Feed ${e}`,u(i.params.feedId)}else i.path==="/tag"&&i.params.tagName?(o.setActiveFeed(null),l.textContent=`Tag: ${i.params.tagName}`,u(void 0,i.params.tagName)):(o.setActiveFeed(null),l.textContent="All Items",u())}o.on("feeds-updated",p);o.on("active-feed-updated",p);o.on("items-updated",h);o.on("loading-state-changed",h);f.addEventListener("route-changed",g);window.app={navigate:i=>f.navigate(i)};async function _(){if((await d("/api/auth")).status===401){window.location.href="/login/";return}await F(),g()}_(); diff --git a/web/dist/v3/index.html b/web/dist/v3/index.html index 22eb548..4d9f10e 100644 --- a/web/dist/v3/index.html +++ b/web/dist/v3/index.html @@ -5,8 +5,8 @@ <link rel="icon" type="image/svg+xml" href="/vite.svg" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>frontend-vanilla</title> - <script type="module" crossorigin src="/v3/assets/index-DLUux7xH.js"></script> - <link rel="stylesheet" crossorigin href="/v3/assets/index-Ca6lOcOY.css"> + <script type="module" crossorigin src="/v3/assets/index-BoWfbp6N.js"></script> + <link rel="stylesheet" crossorigin href="/v3/assets/index-A9upXj8Y.css"> </head> <body> <div id="app"></div> |
