diff options
| author | Adam Mathes <adam@adammathes.com> | 2026-02-15 20:17:51 -0800 |
|---|---|---|
| committer | Adam Mathes <adam@adammathes.com> | 2026-02-15 20:17:51 -0800 |
| commit | d98873787ec40938a4fafdb9bee562b494428f71 (patch) | |
| tree | f2e4794b6ec73c19d138819be72a19cab97ae913 /frontend-vanilla | |
| parent | 2d48202fa547e94f21662d63a3ff5d04c4fe8f2c (diff) | |
| download | neko-d98873787ec40938a4fafdb9bee562b494428f71.tar.gz neko-d98873787ec40938a4fafdb9bee562b494428f71.tar.bz2 neko-d98873787ec40938a4fafdb9bee562b494428f71.zip | |
Vanilla JS (v3): Add Logout button, 'neko' cat emoji toggle, and mobile responsiveness with backdrop
Diffstat (limited to 'frontend-vanilla')
| -rw-r--r-- | frontend-vanilla/src/main.test.ts | 8 | ||||
| -rw-r--r-- | frontend-vanilla/src/main.ts | 86 | ||||
| -rw-r--r-- | frontend-vanilla/src/store.ts | 12 | ||||
| -rw-r--r-- | frontend-vanilla/src/style.css | 106 |
4 files changed, 180 insertions, 32 deletions
diff --git a/frontend-vanilla/src/main.test.ts b/frontend-vanilla/src/main.test.ts index aa0568b..d5681b8 100644 --- a/frontend-vanilla/src/main.test.ts +++ b/frontend-vanilla/src/main.test.ts @@ -246,4 +246,12 @@ describe('main application logic', () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: '/' })); expect(spy).toHaveBeenCalled(); }); + + it('should handle sidebar toggle', () => { + renderLayout(); + const toggleBtn = document.getElementById('sidebar-toggle-btn') as HTMLElement; + const initialVisible = store.sidebarVisible; + toggleBtn.click(); + expect(store.sidebarVisible).toBe(!initialVisible); + }); }); diff --git a/frontend-vanilla/src/main.ts b/frontend-vanilla/src/main.ts index c0a4e66..400bf7b 100644 --- a/frontend-vanilla/src/main.ts +++ b/frontend-vanilla/src/main.ts @@ -25,7 +25,9 @@ export function renderLayout() { if (!appEl) return; appEl.className = `theme-${store.theme} font-${store.fontTheme}`; appEl.innerHTML = ` - <div class="layout"> + <div class="layout ${store.sidebarVisible ? 'sidebar-visible' : 'sidebar-hidden'}"> + <button class="sidebar-toggle" id="sidebar-toggle-btn" title="Toggle Sidebar">🐱</button> + <div class="sidebar-backdrop" id="sidebar-backdrop"></div> <aside class="sidebar" id="sidebar"> <div class="sidebar-header"> <h2 id="logo-link">Neko v3</h2> @@ -52,7 +54,7 @@ export function renderLayout() { </section> </div> <div class="sidebar-footer"> - <a href="/v3/settings" id="settings-link">Settings</a> + <a href="/v3/settings" data-nav="settings">Settings</a> <a href="#" id="logout-button">Logout</a> </div> </aside> @@ -75,16 +77,23 @@ export function attachLayoutListeners() { const logoLink = document.getElementById('logo-link'); logoLink?.addEventListener('click', () => router.navigate('/')); - const logoutBtn = document.getElementById('logout-button'); - logoutBtn?.addEventListener('click', (e) => { + document.getElementById('logout-button')?.addEventListener('click', (e) => { e.preventDefault(); logout(); }); - const settingsLink = document.getElementById('settings-link'); - settingsLink?.addEventListener('click', (e) => { - e.preventDefault(); - router.navigate('/settings'); + document.getElementById('sidebar-toggle-btn')?.addEventListener('click', () => { + store.toggleSidebar(); + }); + + document.getElementById('sidebar-backdrop')?.addEventListener('click', () => { + store.setSidebarVisible(false); + }); + + window.addEventListener('resize', () => { + if (window.innerWidth > 768 && !store.sidebarVisible) { + store.setSidebarVisible(true); + } }); // Event delegation for filters, tags, and feeds in sidebar @@ -109,6 +118,14 @@ export function attachLayoutListeners() { e.preventDefault(); const feedId = link.getAttribute('data-value')!; router.navigate(`/feed/${feedId}`, currentQuery); + } else if (navType === 'settings') { + e.preventDefault(); + router.navigate('/settings', currentQuery); + } + + // Auto-close sidebar on mobile after clicking a link + if (window.innerWidth <= 768) { + store.setSidebarVisible(false); } }); @@ -143,8 +160,6 @@ export function attachLayoutListeners() { const itemTitle = target.closest('[data-action="open"]'); const itemRow = target.closest('.feed-item'); if (itemRow && !itemTitle) { // Clicking the row itself (but not the link) - // We can add "expand" logic here if we want but v2 shows it by default if loaded - // For now, let's just mark as read if it's unread const id = parseInt(itemRow.getAttribute('data-id')!); const item = store.items.find(i => i._id === id); if (item && !item.read) { @@ -229,26 +244,26 @@ export function renderSettings() { const contentArea = document.getElementById('content-area'); if (!contentArea) return; contentArea.innerHTML = ` - <div class="settings-view"> - <h2>Settings</h2> - <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> - </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> - </section> + <div class="settings-view"> + <h2>Settings</h2> + <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> </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> + </section> + </div> + `; // Attach settings listeners const themeOptions = document.getElementById('theme-options'); @@ -483,6 +498,19 @@ store.on('theme-updated', () => { } }); +store.on('sidebar-toggle', () => { + const layout = document.querySelector('.layout'); + if (layout) { + if (store.sidebarVisible) { + layout.classList.remove('sidebar-hidden'); + layout.classList.add('sidebar-visible'); + } else { + layout.classList.remove('sidebar-visible'); + layout.classList.add('sidebar-hidden'); + } + } +}); + store.on('items-updated', renderItems); store.on('loading-state-changed', renderItems); diff --git a/frontend-vanilla/src/store.ts b/frontend-vanilla/src/store.ts index a7a99b0..a23934a 100644 --- a/frontend-vanilla/src/store.ts +++ b/frontend-vanilla/src/store.ts @@ -1,6 +1,6 @@ import type { Feed, Item, Category } from './types.ts'; -export type StoreEvent = 'feeds-updated' | 'tags-updated' | 'items-updated' | 'active-feed-updated' | 'active-tag-updated' | 'loading-state-changed' | 'filter-updated' | 'search-updated' | 'theme-updated'; +export type StoreEvent = 'feeds-updated' | 'tags-updated' | 'items-updated' | 'active-feed-updated' | 'active-tag-updated' | 'loading-state-changed' | 'filter-updated' | 'search-updated' | 'theme-updated' | 'sidebar-toggle'; export type FilterType = 'unread' | 'all' | 'starred'; @@ -16,6 +16,7 @@ export class Store extends EventTarget { hasMore: boolean = true; theme: string = localStorage.getItem('neko-theme') || 'light'; fontTheme: string = localStorage.getItem('neko-font-theme') || 'default'; + sidebarVisible: boolean = window.innerWidth > 768; setFeeds(feeds: Feed[]) { this.feeds = feeds; @@ -83,6 +84,15 @@ export class Store extends EventTarget { this.emit('theme-updated'); } + setSidebarVisible(visible: boolean) { + this.sidebarVisible = visible; + this.emit('sidebar-toggle'); + } + + toggleSidebar() { + this.setSidebarVisible(!this.sidebarVisible); + } + private emit(type: StoreEvent, detail?: any) { this.dispatchEvent(new CustomEvent(type, { detail })); } diff --git a/frontend-vanilla/src/style.css b/frontend-vanilla/src/style.css index 8ec3db3..1002035 100644 --- a/frontend-vanilla/src/style.css +++ b/frontend-vanilla/src/style.css @@ -163,12 +163,114 @@ body { min-width: 0; overflow-y: auto; background-color: var(--bg-color); - padding: 2rem; + padding: 1.5rem 2rem; + transition: padding 0.3s ease; +} + +@media (max-width: 768px) { + .main-content { + padding: 1rem; + padding-top: 4rem; + /* Space for the toggle button */ + } } .main-content>* { max-width: 35em; - margin: 0 auto; + margin-left: auto; + margin-right: auto; +} + +/* Sidebar Toggle (Neko Emoji) */ +.sidebar-toggle { + position: fixed; + top: 1rem; + left: 1rem; + z-index: 1001; + background: var(--sidebar-bg); + border: 1px solid var(--border-color); + border-radius: 50%; + width: 3rem; + height: 3rem; + font-size: 1.5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +.sidebar-toggle:hover { + transform: scale(1.1); +} + +.sidebar-toggle:active { + transform: scale(0.95); +} + +/* Mobile Sidebar & Backdrop */ +.sidebar-backdrop { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.3); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + z-index: 999; +} + +@media (max-width: 768px) { + .sidebar-visible .sidebar-backdrop { + display: block; + } + + .sidebar { + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 1000; + transform: translateX(-100%); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: none; + } + + .sidebar-visible .sidebar { + transform: translateX(0); + box-shadow: 10px 0 20px rgba(0, 0, 0, 0.1); + } + + /* Keep toggle visible but maybe adjust position if needed */ + .sidebar-visible .sidebar-toggle { + left: 12rem; + /* Move out with sidebar if desired, or keep fixed */ + } +} + +/* Layout State */ +.sidebar-hidden .sidebar { + display: none; +} + +@media (min-width: 769px) { + .sidebar-hidden .sidebar { + display: none; + } + + .sidebar-visible .sidebar { + display: flex; + } + + /* On desktop, hide toggle unless we want to allow hiding sidebar manually */ + .sidebar-toggle { + display: none; + } } /* Feed Items Styles (from v2) */ |
