From c652ac6a2cd23ef29f48465be09c2b674783e8e9 Mon Sep 17 00:00:00 2001 From: Adam Mathes Date: Sun, 15 Feb 2026 17:44:55 -0800 Subject: Vanilla JS (v3): Implement 3-pane layout, item fetching, reading, and testing --- frontend-vanilla/src/api.ts | 29 +++ frontend-vanilla/src/components/FeedItem.test.ts | 23 +++ frontend-vanilla/src/components/FeedItem.ts | 11 ++ frontend-vanilla/src/counter.ts | 9 - frontend-vanilla/src/main.ts | 241 ++++++++++++++++++++--- frontend-vanilla/src/router.ts | 40 ++++ frontend-vanilla/src/setupTests.ts | 1 + frontend-vanilla/src/store.test.ts | 48 +++++ frontend-vanilla/src/store.ts | 41 ++++ frontend-vanilla/src/style.css | 240 ++++++++++++++++------ frontend-vanilla/src/types.ts | 24 +++ frontend-vanilla/src/typescript.svg | 1 - 12 files changed, 613 insertions(+), 95 deletions(-) create mode 100644 frontend-vanilla/src/api.ts create mode 100644 frontend-vanilla/src/components/FeedItem.test.ts create mode 100644 frontend-vanilla/src/components/FeedItem.ts delete mode 100644 frontend-vanilla/src/counter.ts create mode 100644 frontend-vanilla/src/router.ts create mode 100644 frontend-vanilla/src/setupTests.ts create mode 100644 frontend-vanilla/src/store.test.ts create mode 100644 frontend-vanilla/src/store.ts create mode 100644 frontend-vanilla/src/types.ts delete mode 100644 frontend-vanilla/src/typescript.svg (limited to 'frontend-vanilla/src') 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 { + 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 ` +
  • + + ${feed.title || feed.url} + +
  • + `; +} 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('#app')!.innerHTML = ` -
    - - - - - - -

    Vite + TypeScript

    -
    - -
    -

    - Click on the Vite and TypeScript logos to learn more -

    +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('#app')!; + +// Initial Layout +appEl.innerHTML = ` +
    + +
    +
    +

    All Items

    +
    +
    +
    +
    +
    +
    Select an item to read
    +
    +
    -` +`; + +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 = '

    Loading items...

    '; + return; + } + + if (items.length === 0) { + itemListEl.innerHTML = '

    No items found.

    '; + return; + } + + itemListEl.innerHTML = ` +
      + ${items.map((item: Item) => ` +
    • +
      ${item.title}
      +
      ${item.feed_title || ''}
      +
    • + `).join('')} +
    + `; + + // 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 = ` +
    +
    +

    ${item.title}

    +
    + From ${item.feed_title || 'Unknown'} on ${new Date(item.publish_date).toLocaleString()} +
    +
    +
    + ${item.description || 'No description available.'} +
    +
    + `; + + // 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 = '
    Select an item to read
    '; + } 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('#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; +}; + +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 = {}; + + 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 @@ - \ No newline at end of file -- cgit v1.2.3