diff options
| author | Adam Mathes <adam@adammathes.com> | 2026-02-13 22:23:54 -0800 |
|---|---|---|
| committer | Adam Mathes <adam@adammathes.com> | 2026-02-13 22:23:54 -0800 |
| commit | 860e1ecb570bda229b6a64fef905923898e0f832 (patch) | |
| tree | 261c4781766e3d6d273809625cc4e364ab07b925 /web/dist/vanilla | |
| parent | 47c43577ead8721008b858232511b2f65e0ed574 (diff) | |
| download | neko-860e1ecb570bda229b6a64fef905923898e0f832.tar.gz neko-860e1ecb570bda229b6a64fef905923898e0f832.tar.bz2 neko-860e1ecb570bda229b6a64fef905923898e0f832.zip | |
Audit and reduce Go dependencies: replace go.rice with embed, pflag with flag
Diffstat (limited to 'web/dist/vanilla')
| -rw-r--r-- | web/dist/vanilla/app.js | 257 | ||||
| -rw-r--r-- | web/dist/vanilla/index.html | 49 | ||||
| -rw-r--r-- | web/dist/vanilla/style.css | 173 |
3 files changed, 479 insertions, 0 deletions
diff --git a/web/dist/vanilla/app.js b/web/dist/vanilla/app.js new file mode 100644 index 0000000..65a0833 --- /dev/null +++ b/web/dist/vanilla/app.js @@ -0,0 +1,257 @@ +document.addEventListener('DOMContentLoaded', () => { + fetchFeeds(); + fetchItems(); // Default to fetching recent items + + const searchInput = document.getElementById('search-input'); + searchInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + const query = searchInput.value.trim(); + if (query) { + document.getElementById('feed-title').textContent = `Search: ${query}`; + document.querySelectorAll('.feed-item').forEach(el => el.classList.remove('active')); + fetchItems(null, null, query); + } + } + }); +}); + +export async function fetchFeeds(apiBase = '') { + try { + const response = await fetch(`${apiBase}/api/feed/`); + if (!response.ok) throw new Error('Failed to fetch feeds'); + const feeds = await response.json(); + renderFeeds(feeds); + return feeds; + } catch (err) { + console.error(err); + const nav = document.getElementById('feeds-nav'); + if (nav) nav.innerHTML = '<div class="error">Error loading feeds</div>'; + throw err; + } +} + +export async function fetchItems(feedId = null, filter = null, query = null, apiBase = '') { + const listEl = document.getElementById('entries-list'); + if (listEl) listEl.innerHTML = '<div class="loading">Loading items...</div>'; + + let url = `${apiBase}/api/stream/`; + const params = new URLSearchParams(); + if (feedId) params.append('feed_id', feedId); + if (filter === 'unread') params.append('read_filter', 'unread'); + if (filter === 'starred') params.append('starred', 'true'); + if (query) params.append('q', query); + + if ([...params].length > 0) { + url += '?' + params.toString(); + } + + try { + const response = await fetch(url); + if (!response.ok) throw new Error('Failed to fetch items'); + const items = await response.json(); + renderItems(items); + return items; + } catch (err) { + console.error(err); + if (listEl) listEl.innerHTML = '<div class="error">Error loading items</div>'; + throw err; + } +} + +export function renderFeeds(feeds) { + const nav = document.getElementById('feeds-nav'); + if (!nav) return; + + // Clear existing items but keep search container if present + const searchContainer = nav.querySelector('.search-container'); + nav.innerHTML = ''; + if (searchContainer) nav.appendChild(searchContainer); + + const allLink = document.createElement('div'); + allLink.className = 'feed-item'; + allLink.textContent = 'All Items'; + allLink.onclick = () => { + document.querySelectorAll('.feed-item').forEach(el => el.classList.remove('active')); + allLink.classList.add('active'); + const title = document.getElementById('feed-title'); + if (title) title.textContent = 'All Items'; + fetchItems(); + }; + nav.appendChild(allLink); + + const unreadLink = document.createElement('div'); + unreadLink.className = 'feed-item'; + unreadLink.textContent = 'Unread Items'; + unreadLink.onclick = () => { + document.querySelectorAll('.feed-item').forEach(el => el.classList.remove('active')); + unreadLink.classList.add('active'); + const title = document.getElementById('feed-title'); + if (title) title.textContent = 'Unread Items'; + fetchItems(null, 'unread'); + }; + nav.appendChild(unreadLink); + + const starredLink = document.createElement('div'); + starredLink.className = 'feed-item'; + starredLink.textContent = 'Starred Items'; + starredLink.onclick = () => { + document.querySelectorAll('.feed-item').forEach(el => el.classList.remove('active')); + starredLink.classList.add('active'); + const title = document.getElementById('feed-title'); + if (title) title.textContent = 'Starred Items'; + fetchItems(null, 'starred'); + }; + nav.appendChild(starredLink); + + if (Array.isArray(feeds)) { + feeds.forEach(feed => { + const div = document.createElement('div'); + div.className = 'feed-item'; + div.textContent = feed.title || feed.url; + div.title = feed.url; + div.onclick = () => { + document.querySelectorAll('.feed-item').forEach(el => el.classList.remove('active')); + div.classList.add('active'); + const title = document.getElementById('feed-title'); + if (title) title.textContent = feed.title; + fetchItems(feed.id); + }; + nav.appendChild(div); + }); + } +} + +export function renderItems(items) { + const list = document.getElementById('entries-list'); + if (!list) return; + list.innerHTML = ''; + + if (!items || items.length === 0) { + list.innerHTML = '<div class="empty">No items found.</div>'; + return; + } + + items.forEach(item => { + const article = document.createElement('article'); + article.className = 'entry'; + + const date = new Date(item.published_at || item.created_at).toLocaleString(); + + article.innerHTML = ` + <header class="entry-header"> + <div class="entry-controls"> + <button class="btn-star ${item.starred ? 'active' : ''}" data-id="${item.id}" data-starred="${item.starred}">${item.starred ? '★' : '☆'}</button> + <button class="btn-read ${item.read ? 'read' : 'unread'}" data-id="${item.id}" data-read="${item.read}">${item.read ? 'Mark Unread' : 'Mark Read'}</button> + </div> + <a href="${item.url}" class="entry-title ${item.read ? 'read' : ''}" target="_blank">${item.title}</a> + <div class="entry-meta"> + ${item.feed ? `<span class="feed-name">${item.feed.title}</span> • ` : ''} + <span class="date">${date}</span> + </div> + </header> + <div class="entry-content"> + ${item.description || ''} + </div> + `; + + // Add event listeners programmatically to avoid inline onclick with modules + const starBtn = article.querySelector('.btn-star'); + starBtn.onclick = () => toggleStar(item.id, item.starred, starBtn); + + const readBtn = article.querySelector('.btn-read'); + readBtn.onclick = () => toggleRead(item.id, item.read, readBtn); + + list.appendChild(article); + }); +} + +export async function toggleStar(id, currentStatus, btn, apiBase = '') { + const newStatus = !currentStatus; + try { + const response = await fetch(`${apiBase}/api/item/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: id, starred: newStatus }) + }); + if (!response.ok) throw new Error('Failed to toggle star'); + + // Update UI + btn.textContent = newStatus ? '★' : '☆'; + btn.classList.toggle('active'); + btn.onclick = () => toggleStar(id, newStatus, btn, apiBase); + + // Update data attributes + btn.dataset.starred = newStatus; + + return newStatus; + } catch (err) { + console.error(err); + alert('Error toggling star'); + throw err; + } +} + +export async function toggleRead(id, currentStatus, btn, apiBase = '') { + const newStatus = !currentStatus; + try { + const response = await fetch(`${apiBase}/api/item/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: id, read: newStatus }) + }); + if (!response.ok) throw new Error('Failed to toggle read'); + + // Update UI + btn.textContent = newStatus ? 'Mark Unread' : 'Mark Read'; + btn.classList.toggle('read'); + btn.classList.toggle('unread'); + btn.onclick = () => toggleRead(id, newStatus, btn, apiBase); + + // Update data attributes + btn.dataset.read = newStatus; + + // Find title and dim it if read + const header = btn.closest('.entry-header'); + if (header) { + const title = header.querySelector('.entry-title'); + if (title) { + if (newStatus) { + title.classList.add('read'); + } else { + title.classList.remove('read'); + } + } + } + + return newStatus; + } catch (err) { + console.error(err); + alert('Error toggling read status'); + throw err; + } +} + +export function init() { + if (typeof document !== 'undefined') { + // Only run if we're in a browser environment with these elements + if (document.getElementById('feeds-nav')) { + fetchFeeds(); + fetchItems(); + + const searchInput = document.getElementById('search-input'); + if (searchInput) { + searchInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + const query = searchInput.value.trim(); + if (query) { + const title = document.getElementById('feed-title'); + if (title) title.textContent = `Search: ${query}`; + document.querySelectorAll('.feed-item').forEach(el => el.classList.remove('active')); + fetchItems(null, null, query); + } + } + }); + } + } + } +} diff --git a/web/dist/vanilla/index.html b/web/dist/vanilla/index.html new file mode 100644 index 0000000..c504f6f --- /dev/null +++ b/web/dist/vanilla/index.html @@ -0,0 +1,49 @@ +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Neko Reader (Vanilla)</title> + <link rel="stylesheet" href="style.css"> + <style> + /* Minimal reset for now, proper styles in style.css */ + body, + html { + margin: 0; + padding: 0; + height: 100%; + font-family: sans-serif; + } + </style> +</head> + +<body> + <div id="app"> + <aside id="sidebar"> + <header> + <h1>🐱 Neko</h1> + </header> + <nav id="feeds-nav"> + <div class="search-container"> + <input type="text" id="search-input" placeholder="Search items..." /> + </div> + <div class="loading">Loading feeds...</div> + </nav> + </aside> + <main id="main"> + <header id="main-header"> + <h2 id="feed-title">All Items</h2> + </header> + <div id="entries-list"> + <div class="loading">Loading items...</div> + </div> + </main> + </div> + <script type="module"> + import { init } from './app.js'; + document.addEventListener('DOMContentLoaded', init); + </script> +</body> + +</html>
\ No newline at end of file diff --git a/web/dist/vanilla/style.css b/web/dist/vanilla/style.css new file mode 100644 index 0000000..573248d --- /dev/null +++ b/web/dist/vanilla/style.css @@ -0,0 +1,173 @@ +:root { + --bg-color: #f6f6f6; + --sidebar-bg: #eaeaea; + --item-bg: #fff; + --text-color: #222; + --link-color: #0000EE; + /* Standard blue link */ + --border-color: #ddd; + --selected-bg: #e8f0fe; +} + +body { + background-color: var(--bg-color); + color: var(--text-color); + overflow: hidden; + /* App container handles scrolling */ +} + +#app { + display: flex; + height: 100vh; +} + +#sidebar { + width: 250px; + 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 h1 { + margin: 0; + font-size: 1.2rem; +} + +.search-container { + padding: 0.5rem; + border-bottom: 1px solid var(--border-color); +} + +#search-input { + width: 100%; + padding: 0.5rem; + border: 1px solid var(--border-color); + border-radius: 4px; + box-sizing: border-box; +} + +#feeds-nav { + flex: 1; + overflow-y: auto; + padding: 0.5rem; +} + +.feed-item { + padding: 0.5rem; + cursor: pointer; + border-radius: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.feed-item:hover { + background-color: rgba(0, 0, 0, 0.05); +} + +.feed-item.active { + font-weight: bold; + background-color: rgba(0, 0, 0, 0.1); +} + +#main { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + background-color: #fff; +} + +#main-header { + padding: 1rem; + border-bottom: 1px solid var(--border-color); + background-color: #fcfcfc; +} + +#main-header h2 { + margin: 0; + font-size: 1.5rem; +} + +#entries-list { + flex: 1; + overflow-y: auto; + padding: 1rem; +} + +.entry { + background-color: var(--item-bg); + border-bottom: 1px solid var(--border-color); + padding: 1rem 0; +} + +.entry-header { + margin-bottom: 0.5rem; + position: relative; + padding-left: 0; +} + +.entry-controls { + display: inline-block; + vertical-align: middle; + margin-right: 0.5rem; +} + +.btn-star, +.btn-read { + background: none; + border: none; + cursor: pointer; + font-size: 1rem; + padding: 2px 4px; + margin-right: 4px; + color: #ccc; +} + +.btn-star.active { + color: orange; +} + +.btn-star:hover, +.btn-read:hover { + color: #888; +} + +.entry-title { + font-size: 1.2rem; + font-weight: bold; + color: var(--link-color); + text-decoration: none; + display: inline-block; + /* Changed to inline-block for alignment */ + vertical-align: middle; + margin-bottom: 0.25rem; +} + +.entry-title.read { + font-weight: normal; + color: #555; + text-decoration: none; +} + +.entry-meta { + font-size: 0.85rem; + color: #666; + margin-top: 4px; +} + +.entry-content { + line-height: 1.6; + max-width: 800px; +} + +.entry-content img { + max-width: 100%; + height: auto; +}
\ No newline at end of file |
