aboutsummaryrefslogtreecommitdiffstats
path: root/frontend-vanilla
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-15 20:17:51 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-15 20:17:51 -0800
commitd98873787ec40938a4fafdb9bee562b494428f71 (patch)
treef2e4794b6ec73c19d138819be72a19cab97ae913 /frontend-vanilla
parent2d48202fa547e94f21662d63a3ff5d04c4fe8f2c (diff)
downloadneko-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.ts8
-rw-r--r--frontend-vanilla/src/main.ts86
-rw-r--r--frontend-vanilla/src/store.ts12
-rw-r--r--frontend-vanilla/src/style.css106
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) */