diff options
| author | Adam Mathes <adam@adammathes.com> | 2026-02-15 21:48:57 -0800 |
|---|---|---|
| committer | Adam Mathes <adam@adammathes.com> | 2026-02-15 21:48:57 -0800 |
| commit | 657faf3acd7755d6b84a87e0e363729d95930258 (patch) | |
| tree | 0691a677c3b6e8827d52a9a536f9ee9a6cbdcf80 /frontend-vanilla | |
| parent | 371e474217b5ade280e2d9b3893b1893be507eb1 (diff) | |
| download | neko-657faf3acd7755d6b84a87e0e363729d95930258.tar.gz neko-657faf3acd7755d6b84a87e0e363729d95930258.tar.bz2 neko-657faf3acd7755d6b84a87e0e363729d95930258.zip | |
Vanilla JS (v3): Fix mobile horizontal scroll, simplify logo to 🐱 emoji, implement feed deselect, and complete Settings (Add Feed, Export/Import OPML)
Diffstat (limited to 'frontend-vanilla')
| -rw-r--r-- | frontend-vanilla/src/main.ts | 132 | ||||
| -rw-r--r-- | frontend-vanilla/src/style.css | 64 |
2 files changed, 171 insertions, 25 deletions
diff --git a/frontend-vanilla/src/main.ts b/frontend-vanilla/src/main.ts index 3553ea7..f545285 100644 --- a/frontend-vanilla/src/main.ts +++ b/frontend-vanilla/src/main.ts @@ -30,7 +30,7 @@ export function renderLayout() { <div class="sidebar-backdrop" id="sidebar-backdrop"></div> <aside class="sidebar" id="sidebar"> <div class="sidebar-header"> - <h2 id="logo-link">Neko v3</h2> + <div id="logo-link" class="sidebar-logo">🐱</div> </div> <div class="sidebar-search"> <input type="search" id="search-input" placeholder="Search..." value="${store.searchQuery}"> @@ -124,7 +124,11 @@ export function attachLayoutListeners() { } else if (navType === 'feed') { e.preventDefault(); const feedId = link.getAttribute('data-value')!; - router.navigate(`/feed/${feedId}`, currentQuery); + if (store.activeFeedId === parseInt(feedId)) { + router.navigate('/', currentQuery); + } else { + router.navigate(`/feed/${feedId}`, currentQuery); + } } else if (navType === 'settings') { e.preventDefault(); router.navigate('/settings', currentQuery); @@ -253,40 +257,130 @@ export function renderSettings() { contentArea.innerHTML = ` <div class="settings-view"> <h2>Settings</h2> + + <section class="settings-section"> + <h3>Add Feed</h3> + <div class="add-feed-form"> + <input type="url" id="new-feed-url" placeholder="https://example.com/rss.xml"> + <button id="add-feed-btn">Add Feed</button> + </div> + </section> + <section class="settings-section"> - <h3>Theme</h3> - <div class="theme-options" id="theme-options"> - <button class="${store.theme === 'light' ? 'active' : ''}" data-theme="light">Light</button> - <button class="${store.theme === 'dark' ? 'active' : ''}" data-theme="dark">Dark</button> + <h3>Appearance</h3> + <div class="settings-group"> + <label>Theme</label> + <div class="theme-options" id="theme-options"> + <button class="${store.theme === 'light' ? 'active' : ''}" data-theme="light">Light</button> + <button class="${store.theme === 'dark' ? 'active' : ''}" data-theme="dark">Dark</button> + </div> + </div> + <div class="settings-group" style="margin-top: 1rem;"> + <label>Font</label> + <select id="font-selector"> + <option value="default" ${store.fontTheme === 'default' ? 'selected' : ''}>Default (Palatino)</option> + <option value="serif" ${store.fontTheme === 'serif' ? 'selected' : ''}>Serif (Georgia)</option> + <option value="sans" ${store.fontTheme === 'sans' ? 'selected' : ''}>Sans-Serif (Helvetica)</option> + <option value="mono" ${store.fontTheme === 'mono' ? 'selected' : ''}>Monospace</option> + </select> </div> </section> + <section class="settings-section"> - <h3>Font</h3> - <select id="font-selector"> - <option value="default" ${store.fontTheme === 'default' ? 'selected' : ''}>Default (Palatino)</option> - <option value="serif" ${store.fontTheme === 'serif' ? 'selected' : ''}>Serif (Georgia)</option> - <option value="sans" ${store.fontTheme === 'sans' ? 'selected' : ''}>Sans-Serif (Helvetica)</option> - <option value="mono" ${store.fontTheme === 'mono' ? 'selected' : ''}>Monospace</option> - </select> + <h3>Data Management</h3> + <div class="data-actions"> + <button id="export-opml-btn">Export OPML</button> + <div class="import-section" style="margin-top: 1rem;"> + <label for="import-opml-file" class="button">Import OPML</label> + <input type="file" id="import-opml-file" accept=".opml,.xml" style="display: none;"> + </div> + </div> </section> </div> `; // Attach settings listeners - const themeOptions = document.getElementById('theme-options'); - themeOptions?.addEventListener('click', (e) => { + document.getElementById('theme-options')?.addEventListener('click', (e) => { const btn = (e.target as HTMLElement).closest('button'); if (btn) { const theme = btn.getAttribute('data-theme')!; store.setTheme(theme); - renderSettings(); // Re-render to show active + renderSettings(); } }); - const fontSelector = document.getElementById('font-selector') as HTMLSelectElement; - fontSelector?.addEventListener('change', () => { - store.setFontTheme(fontSelector.value); + document.getElementById('font-selector')?.addEventListener('change', (e) => { + store.setFontTheme((e.target as HTMLSelectElement).value); }); + + document.getElementById('add-feed-btn')?.addEventListener('click', async () => { + const input = document.getElementById('new-feed-url') as HTMLInputElement; + const url = input.value.trim(); + if (url) { + const success = await addFeed(url); + if (success) { + input.value = ''; + alert('Feed added successfully!'); + fetchFeeds(); + } else { + alert('Failed to add feed.'); + } + } + }); + + document.getElementById('export-opml-btn')?.addEventListener('click', () => { + window.location.href = '/api/export/opml'; + }); + + document.getElementById('import-opml-file')?.addEventListener('change', async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) { + const success = await importOPML(file); + if (success) { + alert('OPML imported successfully! Crawling started.'); + fetchFeeds(); + } else { + alert('Failed to import OPML.'); + } + } + }); +} + +async function addFeed(url: string): Promise<boolean> { + try { + const res = await apiFetch('/api/feed', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }) + }); + return res.ok; + } catch (err) { + console.error('Failed to add feed', err); + return false; + } +} + +async function importOPML(file: File): Promise<boolean> { + try { + const formData = new FormData(); + formData.append('file', file); + formData.append('format', 'opml'); + + // We need to handle CSRF manually since apiFetch expects JSON or simple body + const csrfToken = document.cookie.split('; ').find(row => row.startsWith('csrf_token='))?.split('=')[1]; + + const res = await fetch('/api/import', { + method: 'POST', + headers: { + 'X-CSRF-Token': csrfToken || '' + }, + body: formData + }); + return res.ok; + } catch (err) { + console.error('Failed to import OPML', err); + return false; + } } // --- Data Actions --- diff --git a/frontend-vanilla/src/style.css b/frontend-vanilla/src/style.css index 6e048aa..a97f443 100644 --- a/frontend-vanilla/src/style.css +++ b/frontend-vanilla/src/style.css @@ -19,23 +19,31 @@ color-scheme: light dark; } +* { + box-sizing: border-box; +} + body { margin: 0; font-family: var(--font-body); background-color: var(--bg-color); color: var(--text-color); height: 100vh; + width: 100%; overflow: hidden; } #app { height: 100%; + width: 100%; } .layout { display: flex; height: 100%; width: 100%; + overflow-x: hidden; + position: relative; } /* Sidebar - matching v2 glass variant */ @@ -58,12 +66,16 @@ body { border-right-color: rgba(255, 255, 255, 0.05); } -.sidebar-header h2 { - font-family: var(--font-heading); - font-size: 1.5rem; - margin: 0 0 2rem 0; - opacity: 0.8; +.sidebar-header { + margin-bottom: 2rem; + display: flex; + justify-content: center; +} + +.sidebar-logo { + font-size: 3rem; cursor: pointer; + user-select: none; } .sidebar-search { @@ -470,4 +482,44 @@ button.active { .theme-dark button.active { background-color: #224; border-color: var(--accent-color); -}
\ No newline at end of file +} +.add-feed-form { + display: flex; + gap: 0.5rem; +} + +.add-feed-form input { + flex: 1; + padding: 0.6rem 1rem; + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-color); + color: var(--text-color); +} + +.settings-group label { + display: block; + font-size: 0.85rem; + font-weight: 600; + margin-bottom: 0.5rem; + opacity: 0.7; +} + +#font-selector { + width: 100%; + padding: 0.6rem; + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-color); + color: var(--text-color); +} + +.data-actions button, .data-actions .button { + display: inline-block; + text-align: center; + width: 100%; +} + +label.button { + cursor: pointer; +} |
