diff options
Diffstat (limited to 'frontend-vanilla/src')
| -rw-r--r-- | frontend-vanilla/src/api.ts | 29 | ||||
| -rw-r--r-- | frontend-vanilla/src/components/FeedItem.test.ts | 23 | ||||
| -rw-r--r-- | frontend-vanilla/src/components/FeedItem.ts | 11 | ||||
| -rw-r--r-- | frontend-vanilla/src/counter.ts | 9 | ||||
| -rw-r--r-- | frontend-vanilla/src/main.ts | 241 | ||||
| -rw-r--r-- | frontend-vanilla/src/router.ts | 40 | ||||
| -rw-r--r-- | frontend-vanilla/src/setupTests.ts | 1 | ||||
| -rw-r--r-- | frontend-vanilla/src/store.test.ts | 48 | ||||
| -rw-r--r-- | frontend-vanilla/src/store.ts | 41 | ||||
| -rw-r--r-- | frontend-vanilla/src/style.css | 240 | ||||
| -rw-r--r-- | frontend-vanilla/src/types.ts | 24 | ||||
| -rw-r--r-- | frontend-vanilla/src/typescript.svg | 1 |
12 files changed, 613 insertions, 95 deletions
diff --git a/frontend-vanilla/src/api.ts b/frontend-vanilla/src/api.ts new file mode 100644 index 0000000..c32299d --- /dev/null +++ b/frontend-vanilla/src/api.ts @@ -0,0 +1,29 @@ +export function getCookie(name: string): string | undefined { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop()?.split(';').shift(); +} + +/** + * A wrapper around fetch that automatically includes the CSRF token + * for state-changing requests (POST, PUT, DELETE). + */ +export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> { + const method = init?.method?.toUpperCase() || 'GET'; + const isStateChanging = ['POST', 'PUT', 'DELETE'].includes(method); + + const headers = new Headers(init?.headers || {}); + + if (isStateChanging) { + const token = getCookie('csrf_token'); + if (token) { + headers.set('X-CSRF-Token', token); + } + } + + return fetch(input, { + ...init, + headers, + credentials: 'include', // Ensure cookies are sent + }); +} diff --git a/frontend-vanilla/src/components/FeedItem.test.ts b/frontend-vanilla/src/components/FeedItem.test.ts new file mode 100644 index 0000000..708a871 --- /dev/null +++ b/frontend-vanilla/src/components/FeedItem.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { createFeedItem } from './FeedItem'; + +describe('FeedItem Component', () => { + const mockFeed = { _id: 1, title: 'My Feed', url: 'http://test', web_url: 'http://test', category: 'tag' }; + + it('should render a feed item correctly', () => { + const html = createFeedItem(mockFeed, false); + expect(html).toContain('My Feed'); + expect(html).toContain('data-id="1"'); + expect(html).not.toContain('active'); + }); + + it('should apply active class when isActive is true', () => { + const html = createFeedItem(mockFeed, true); + expect(html).toContain('active'); + }); + + it('should fallback to URL if title is missing', () => { + const html = createFeedItem({ ...mockFeed, title: '' }, false); + expect(html).toContain('http://test'); + }); +}); diff --git a/frontend-vanilla/src/components/FeedItem.ts b/frontend-vanilla/src/components/FeedItem.ts new file mode 100644 index 0000000..3bf72c2 --- /dev/null +++ b/frontend-vanilla/src/components/FeedItem.ts @@ -0,0 +1,11 @@ +import type { Feed } from '../types'; + +export function createFeedItem(feed: Feed, isActive: boolean): string { + return ` + <li class="feed-item ${isActive ? 'active' : ''}" data-id="${feed._id}"> + <a href="/v3/feed/${feed._id}" class="feed-link" onclick="event.preventDefault(); window.app.navigate('/feed/${feed._id}')"> + ${feed.title || feed.url} + </a> + </li> + `; +} diff --git a/frontend-vanilla/src/counter.ts b/frontend-vanilla/src/counter.ts deleted file mode 100644 index 09e5afd..0000000 --- a/frontend-vanilla/src/counter.ts +++ /dev/null @@ -1,9 +0,0 @@ -export function setupCounter(element: HTMLButtonElement) { - let counter = 0 - const setCounter = (count: number) => { - counter = count - element.innerHTML = `count is ${counter}` - } - element.addEventListener('click', () => setCounter(counter + 1)) - setCounter(0) -} diff --git a/frontend-vanilla/src/main.ts b/frontend-vanilla/src/main.ts index 6396b50..6846a67 100644 --- a/frontend-vanilla/src/main.ts +++ b/frontend-vanilla/src/main.ts @@ -1,24 +1,221 @@ -import './style.css' -import typescriptLogo from './typescript.svg' -import viteLogo from '/vite.svg' -import { setupCounter } from './counter.ts' - -document.querySelector<HTMLDivElement>('#app')!.innerHTML = ` - <div> - <a href="https://vite.dev" target="_blank"> - <img src="${viteLogo}" class="logo" alt="Vite logo" /> - </a> - <a href="https://www.typescriptlang.org/" target="_blank"> - <img src="${typescriptLogo}" class="logo vanilla" alt="TypeScript logo" /> - </a> - <h1>Vite + TypeScript</h1> - <div class="card"> - <button id="counter" type="button"></button> - </div> - <p class="read-the-docs"> - Click on the Vite and TypeScript logos to learn more - </p> +import './style.css'; +import { apiFetch } from './api'; +import { store } from './store'; +import { router } from './router'; +import type { Feed, Item } from './types'; +import { createFeedItem } from './components/FeedItem'; + +// Cache elements +const appEl = document.querySelector<HTMLDivElement>('#app')!; + +// Initial Layout +appEl.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 feedListEl = document.getElementById('feed-list')!; +const viewTitleEl = document.getElementById('view-title')!; +const itemListEl = document.getElementById('item-list-container')!; +const itemDetailEl = document.getElementById('item-detail-content')!; + +// --- Rendering Functions --- + +function renderFeeds() { + const { feeds, activeFeedId } = store; + feedListEl.innerHTML = feeds.map((feed: Feed) => + createFeedItem(feed, feed._id === activeFeedId) + ).join(''); +} + +function renderItems() { + const { items, loading } = store; + + if (loading) { + itemListEl.innerHTML = '<p class="loading">Loading items...</p>'; + return; + } + + if (items.length === 0) { + itemListEl.innerHTML = '<p class="empty">No items found.</p>'; + return; + } + + itemListEl.innerHTML = ` + <ul class="item-list"> + ${items.map((item: Item) => ` + <li class="item-row ${item.read ? 'read' : ''}" data-id="${item._id}"> + <div class="item-title">${item.title}</div> + <div class="item-meta">${item.feed_title || ''}</div> + </li> + `).join('')} + </ul> + `; + + // Add click listeners to items + itemListEl.querySelectorAll('.item-row').forEach(row => { + row.addEventListener('click', () => { + const id = parseInt(row.getAttribute('data-id') || '0'); + selectItem(id); + }); + }); +} + +async function selectItem(id: number) { + 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); + }); + + // Render basic detail + itemDetailEl.innerHTML = ` + <article class="item-detail"> + <header> + <h1><a href="${item.url}" target="_blank">${item.title}</a></h1> + <div class="item-meta"> + From ${item.feed_title || 'Unknown'} on ${new Date(item.publish_date).toLocaleString()} + </div> + </header> + <div id="full-content" class="full-content"> + ${item.description || 'No description available.'} + </div> + </article> + `; + + // 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); + } + } + + // Fetch full content if missing + if (item.url && (!item.full_content || item.full_content === item.description)) { + try { + const res = await apiFetch(`/api/item/${item._id}/content`); + if (res.ok) { + const data = await res.json(); + if (data.full_content) { + item.full_content = data.full_content; + const contentEl = document.getElementById('full-content'); + if (contentEl) contentEl.innerHTML = data.full_content; + } + } + } catch (err) { + console.error('Failed to fetch full content', err); + } + } +} + +// --- Data Actions --- + +async function fetchFeeds() { + try { + const res = await apiFetch('/api/feed/'); + if (!res.ok) throw new Error('Failed to fetch feeds'); + const feeds = await res.json(); + store.setFeeds(feeds); + } catch (err) { + console.error(err); + } +} + +async function fetchItems(feedId?: string, tagName?: string) { + store.setLoading(true); + try { + let url = '/api/stream'; + const params = new URLSearchParams(); + if (feedId) params.append('feed_id', feedId); + if (tagName) params.append('tag', tagName); + + 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>'; + } catch (err) { + console.error(err); + store.setItems([]); + } finally { + store.setLoading(false); + } +} + +// --- App Logic --- + +function handleRoute() { + const route = router.getCurrentRoute(); + + if (route.path === '/feed' && route.params.feedId) { + const id = parseInt(route.params.feedId); + store.setActiveFeed(id); + const feed = store.feeds.find((f: Feed) => f._id === id); + viewTitleEl.textContent = feed ? feed.title : `Feed ${id}`; + fetchItems(route.params.feedId); + } else if (route.path === '/tag' && route.params.tagName) { + store.setActiveFeed(null); + viewTitleEl.textContent = `Tag: ${route.params.tagName}`; + fetchItems(undefined, route.params.tagName); + } else { + store.setActiveFeed(null); + viewTitleEl.textContent = 'All Items'; + fetchItems(); + } +} + +// Subscribe to store +store.on('feeds-updated', renderFeeds); +store.on('active-feed-updated', renderFeeds); +store.on('items-updated', renderItems); +store.on('loading-state-changed', renderItems); + +// Subscribe to router +router.addEventListener('route-changed', handleRoute); + +// Global app object for inline handlers +(window as any).app = { + navigate: (path: string) => router.navigate(path) +}; + +// Start +async function init() { + const authRes = await apiFetch('/api/auth'); + if (authRes.status === 401) { + window.location.href = '/login/'; + return; + } + + await fetchFeeds(); + handleRoute(); // handles initial route +} -setupCounter(document.querySelector<HTMLButtonElement>('#counter')!) +init(); diff --git a/frontend-vanilla/src/router.ts b/frontend-vanilla/src/router.ts new file mode 100644 index 0000000..08a9e02 --- /dev/null +++ b/frontend-vanilla/src/router.ts @@ -0,0 +1,40 @@ +export type Route = { + path: string; + params: Record<string, string>; +}; + +export class Router extends EventTarget { + constructor() { + super(); + window.addEventListener('popstate', () => this.handleRouteChange()); + } + + private handleRouteChange() { + this.dispatchEvent(new CustomEvent('route-changed', { detail: this.getCurrentRoute() })); + } + + getCurrentRoute(): Route { + const path = window.location.pathname.replace(/^\/v3\//, ''); + const segments = path.split('/').filter(Boolean); + + let routePath = '/'; + const params: Record<string, string> = {}; + + if (segments[0] === 'feed' && segments[1]) { + routePath = '/feed'; + params.feedId = segments[1]; + } else if (segments[0] === 'tag' && segments[1]) { + routePath = '/tag'; + params.tagName = segments[1]; + } + + return { path: routePath, params }; + } + + navigate(path: string) { + window.history.pushState({}, '', `/v3${path}`); + this.handleRouteChange(); + } +} + +export const router = new Router(); diff --git a/frontend-vanilla/src/setupTests.ts b/frontend-vanilla/src/setupTests.ts new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/frontend-vanilla/src/setupTests.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/frontend-vanilla/src/store.test.ts b/frontend-vanilla/src/store.test.ts new file mode 100644 index 0000000..688e43e --- /dev/null +++ b/frontend-vanilla/src/store.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Store } from './store'; + +describe('Store', () => { + it('should store and notify about feeds', () => { + const store = new Store(); + const mockFeeds = [ + { _id: 1, title: 'Feed 1', url: 'http://1', web_url: 'http://1', category: 'cat' } + ]; + + const callback = vi.fn(); + store.addEventListener('feeds-updated', callback); + + store.setFeeds(mockFeeds); + + expect(store.feeds).toEqual(mockFeeds); + expect(callback).toHaveBeenCalled(); + }); + + it('should handle items and loading state', () => { + const store = new Store(); + const mockItems = [{ _id: 1, title: 'Item 1' } as any]; + + const itemCallback = vi.fn(); + const loadingCallback = vi.fn(); + + store.addEventListener('items-updated', itemCallback); + store.addEventListener('loading-state-changed', loadingCallback); + + store.setLoading(true); + expect(store.loading).toBe(true); + expect(loadingCallback).toHaveBeenCalled(); + + store.setItems(mockItems); + expect(store.items).toEqual(mockItems); + expect(itemCallback).toHaveBeenCalled(); + }); + + it('should notify when active feed changes', () => { + const store = new Store(); + const callback = vi.fn(); + store.addEventListener('active-feed-updated', callback); + + store.setActiveFeed(123); + expect(store.activeFeedId).toBe(123); + expect(callback).toHaveBeenCalled(); + }); +}); diff --git a/frontend-vanilla/src/store.ts b/frontend-vanilla/src/store.ts new file mode 100644 index 0000000..d274c5d --- /dev/null +++ b/frontend-vanilla/src/store.ts @@ -0,0 +1,41 @@ +import type { Feed, Item } from './types.ts'; + +export type StoreEvent = 'feeds-updated' | 'items-updated' | 'active-feed-updated' | 'loading-state-changed'; + +export class Store extends EventTarget { + feeds: Feed[] = []; + items: Item[] = []; + activeFeedId: number | null = null; + loading: boolean = false; + + setFeeds(feeds: Feed[]) { + this.feeds = feeds; + this.emit('feeds-updated'); + } + + setItems(items: Item[]) { + this.items = items; + this.emit('items-updated'); + } + + setActiveFeed(id: number | null) { + this.activeFeedId = id; + this.emit('active-feed-updated'); + } + + setLoading(loading: boolean) { + this.loading = loading; + this.emit('loading-state-changed'); + } + + 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); + } +} + +export const store = new Store(); diff --git a/frontend-vanilla/src/style.css b/frontend-vanilla/src/style.css index 3bcdbd0..a9c1c61 100644 --- a/frontend-vanilla/src/style.css +++ b/frontend-vanilla/src/style.css @@ -1,96 +1,210 @@ :root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; line-height: 1.5; font-weight: 400; color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; + --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; +} - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; +@media (prefers-color-scheme: dark) { + :root { + --bg-color: #1a1a1a; + --text-color: #e9ecef; + --sidebar-bg: #2d2d2d; + --border-color: #444; + --accent-color: #375a7f; + --hover-color: #3e3e3e; + } } -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; +body { + margin: 0; + color: var(--text-color); + background-color: var(--bg-color); + height: 100vh; + overflow: hidden; } -a:hover { - color: #535bf2; + +#app { + height: 100%; } -body { +.layout { + display: flex; + height: 100%; +} + +/* Sidebar */ +.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: 0.5rem 1rem; + text-decoration: none; + color: var(--text-color); + font-size: 0.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: bold; +} + +/* Item List Pane */ +.item-list-pane { + width: var(--item-list-width); + border-right: 1px solid var(--border-color); display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; + flex-direction: column; + background-color: var(--bg-color); } -h1 { - font-size: 3.2em; - line-height: 1.1; +.top-bar { + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border-color); } -#app { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; +.top-bar h1 { + margin: 0; + font-size: 1rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; +.item-list-container { + flex: 1; + overflow-y: auto; } -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); + +.item-list { + list-style: none; + padding: 0; + margin: 0; +} + +.item-row { + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border-color); + cursor: pointer; + transition: background 0.1s; +} + +.item-row:hover { + background-color: var(--hover-color); +} + +.item-row.active { + background-color: var(--hover-color); + border-left: 3px solid var(--accent-color); } -.logo.vanilla:hover { - filter: drop-shadow(0 0 2em #3178c6aa); + +.item-row.read { + opacity: 0.6; } -.card { - padding: 2em; +.item-title { + font-weight: 600; + font-size: 0.95rem; + margin-bottom: 0.2rem; + line-height: 1.3; } -.read-the-docs { +.item-meta { + font-size: 0.8rem; color: #888; } -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; +/* Item Detail Pane */ +.item-detail-pane { + flex: 1; + overflow-y: auto; + background-color: var(--bg-color); } -button:hover { - border-color: #646cff; + +.item-detail-content { + max-width: 800px; + margin: 0 auto; + padding: 2rem; } -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; + +.item-detail header { + margin-bottom: 2rem; + border-bottom: 1px solid var(--border-color); + padding-bottom: 1rem; } -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } +.item-detail h1 { + font-size: 1.8rem; + margin: 0 0 0.5rem 0; +} + +.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; +}
\ No newline at end of file diff --git a/frontend-vanilla/src/types.ts b/frontend-vanilla/src/types.ts new file mode 100644 index 0000000..4c1110f --- /dev/null +++ b/frontend-vanilla/src/types.ts @@ -0,0 +1,24 @@ +export interface Feed { + _id: number; + url: string; + web_url: string; + title: string; + category: string; +} + +export interface Item { + _id: number; + feed_id: number; + title: string; + url: string; + description: string; + publish_date: string; + read: boolean; + starred: boolean; + full_content?: string; + header_image?: string; + feed_title?: string; +} +export interface Category { + title: string; +} diff --git a/frontend-vanilla/src/typescript.svg b/frontend-vanilla/src/typescript.svg deleted file mode 100644 index d91c910..0000000 --- a/frontend-vanilla/src/typescript.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"></path><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"></path></svg>
\ No newline at end of file |
