aboutsummaryrefslogtreecommitdiffstats
path: root/web/dist/vanilla
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-13 22:23:54 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-13 22:23:54 -0800
commit860e1ecb570bda229b6a64fef905923898e0f832 (patch)
tree261c4781766e3d6d273809625cc4e364ab07b925 /web/dist/vanilla
parent47c43577ead8721008b858232511b2f65e0ed574 (diff)
downloadneko-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.js257
-rw-r--r--web/dist/vanilla/index.html49
-rw-r--r--web/dist/vanilla/style.css173
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