aboutsummaryrefslogtreecommitdiffstats
path: root/frontend-vanilla/src
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-17 19:00:26 -0800
committerGitHub <noreply@github.com>2026-02-17 19:00:26 -0800
commit9db36ae402dbb74f7223a4efc8b2483086684e38 (patch)
treef7f69858a5667e88f160a579c805d6ad834e8844 /frontend-vanilla/src
parentfd5f67d2a45dbefbc1045bf8270cc3bc5d711592 (diff)
parent81c78496e1fa0701618254986e9ff17081a74f11 (diff)
downloadneko-9db36ae402dbb74f7223a4efc8b2483086684e38.tar.gz
neko-9db36ae402dbb74f7223a4efc8b2483086684e38.tar.bz2
neko-9db36ae402dbb74f7223a4efc8b2483086684e38.zip
Merge pull request #13 from adammathes/claude/add-css-themes-QGTmP
Add 4 CSS style themes with runtime switcher
Diffstat (limited to 'frontend-vanilla/src')
-rw-r--r--frontend-vanilla/src/main.ts84
-rw-r--r--frontend-vanilla/src/store.ts9
-rw-r--r--frontend-vanilla/src/style.css59
3 files changed, 149 insertions, 3 deletions
diff --git a/frontend-vanilla/src/main.ts b/frontend-vanilla/src/main.ts
index 9c8f2b3..901e316 100644
--- a/frontend-vanilla/src/main.ts
+++ b/frontend-vanilla/src/main.ts
@@ -13,6 +13,24 @@ declare global {
}
}
+// Style theme management: load/unload CSS files
+const STYLE_THEMES = ['default', 'refined', 'terminal', 'codex', 'sakura'] as const;
+
+function loadStyleTheme(theme: string) {
+ // Remove any existing theme stylesheet
+ const existing = document.getElementById('style-theme-link');
+ if (existing) existing.remove();
+
+ // 'default' means no extra stylesheet
+ if (theme === 'default') return;
+
+ const link = document.createElement('link');
+ link.id = 'style-theme-link';
+ link.rel = 'stylesheet';
+ link.href = `/v3/themes/${theme}.css`;
+ document.head.appendChild(link);
+}
+
// Global App State
let activeItemId: number | null = null;
@@ -58,6 +76,15 @@ export function renderLayout() {
-->
</div>
<div class="sidebar-footer">
+ <div class="sidebar-quick-controls">
+ <button id="sidebar-theme-toggle" class="sidebar-icon-btn" title="${store.theme === 'light' ? 'Switch to dark mode' : 'Switch to light mode'}">${store.theme === 'light' ? '☽' : '☀'}</button>
+ <span class="sidebar-controls-divider"></span>
+ <button class="sidebar-icon-btn sidebar-style-btn ${store.styleTheme === 'default' ? 'active' : ''}" data-style-theme="default" title="Default">○</button>
+ <button class="sidebar-icon-btn sidebar-style-btn ${store.styleTheme === 'refined' ? 'active' : ''}" data-style-theme="refined" title="Refined">◆</button>
+ <button class="sidebar-icon-btn sidebar-style-btn ${store.styleTheme === 'terminal' ? 'active' : ''}" data-style-theme="terminal" title="Terminal">▮</button>
+ <button class="sidebar-icon-btn sidebar-style-btn ${store.styleTheme === 'codex' ? 'active' : ''}" data-style-theme="codex" title="Codex">❧</button>
+ <button class="sidebar-icon-btn sidebar-style-btn ${store.styleTheme === 'sakura' ? 'active' : ''}" data-style-theme="sakura" title="Sakura">❀</button>
+ </div>
<a href="/v3/settings" data-nav="settings">Settings</a>
<a href="#" id="logout-button">Logout</a>
</div>
@@ -86,6 +113,19 @@ export function attachLayoutListeners() {
logout();
});
+ // Sidebar quick controls: light/dark toggle
+ document.getElementById('sidebar-theme-toggle')?.addEventListener('click', () => {
+ store.setTheme(store.theme === 'light' ? 'dark' : 'light');
+ });
+
+ // Sidebar quick controls: style theme emoji buttons
+ document.querySelectorAll('.sidebar-style-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const theme = btn.getAttribute('data-style-theme');
+ if (theme) store.setStyleTheme(theme);
+ });
+ });
+
document.getElementById('sidebar-toggle-btn')?.addEventListener('click', () => {
store.toggleSidebar();
});
@@ -394,7 +434,20 @@ export function renderSettings() {
<button class="${store.theme === 'dark' ? 'active' : ''}" data-theme="dark">Dark</button>
</div>
</div>
- <div class="settings-group" style="margin-top: 1rem;">
+ </section>
+
+ <section class="settings-section">
+ <h3>Style</h3>
+ <div class="settings-group">
+ <div class="theme-options" id="style-theme-options">
+ ${STYLE_THEMES.map(t => `<button class="${store.styleTheme === t ? 'active' : ''}" data-style-theme="${t}">${t.charAt(0).toUpperCase() + t.slice(1)}</button>`).join('\n ')}
+ </div>
+ </div>
+ </section>
+
+ <section class="settings-section">
+ <h3>Fonts</h3>
+ <div class="settings-group">
<label>System & headings</label>
<select id="heading-font-selector" style="margin-bottom: 1rem;">
<option value="default" ${store.headingFontTheme === 'default' ? 'selected' : ''}>System (Helvetica Neue)</option>
@@ -445,7 +498,7 @@ export function renderSettings() {
// --- Listeners ---
- // Theme
+ // Theme (light/dark)
document.getElementById('theme-options')?.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest('button');
if (btn) {
@@ -454,6 +507,14 @@ export function renderSettings() {
}
});
+ // Style Theme
+ document.getElementById('style-theme-options')?.addEventListener('click', (e) => {
+ const btn = (e.target as HTMLElement).closest('button');
+ if (btn) {
+ store.setStyleTheme(btn.getAttribute('data-style-theme')!);
+ }
+ });
+
// Heading Font
document.getElementById('heading-font-selector')?.addEventListener('change', (e) => {
store.setHeadingFontTheme((e.target as HTMLSelectElement).value);
@@ -825,6 +886,12 @@ store.on('theme-updated', () => {
// Re-apply classes with proper specificity logic
appEl.className = `theme-${store.theme} font-${store.fontTheme} heading-font-${store.headingFontTheme}`;
}
+ // Update sidebar toggle icon
+ const toggleBtn = document.getElementById('sidebar-theme-toggle');
+ if (toggleBtn) {
+ toggleBtn.textContent = store.theme === 'light' ? '☽' : '☀';
+ toggleBtn.title = store.theme === 'light' ? 'Switch to dark mode' : 'Switch to light mode';
+ }
// Also re-render settings if we are on settings page to update active state of buttons
if (router.getCurrentRoute().path === '/settings') {
renderSettings();
@@ -844,6 +911,18 @@ store.on('sidebar-toggle', () => {
}
});
+store.on('style-theme-updated', () => {
+ loadStyleTheme(store.styleTheme);
+ // Update sidebar style emoji buttons
+ document.querySelectorAll('.sidebar-style-btn').forEach(btn => {
+ btn.classList.toggle('active', btn.getAttribute('data-style-theme') === store.styleTheme);
+ });
+ // Re-render settings if on settings page to update active state
+ if (router.getCurrentRoute().path === '/settings') {
+ renderSettings();
+ }
+});
+
store.on('items-updated', renderItems);
store.on('loading-state-changed', renderItems);
@@ -864,6 +943,7 @@ export async function init() {
}
renderLayout();
+ loadStyleTheme(store.styleTheme);
renderFilters();
try {
await Promise.all([fetchFeeds(), fetchTags()]);
diff --git a/frontend-vanilla/src/store.ts b/frontend-vanilla/src/store.ts
index dc79339..bfbc55e 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' | 'sidebar-toggle';
+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' | 'style-theme-updated';
export type FilterType = 'unread' | 'all' | 'starred';
@@ -34,6 +34,7 @@ export class Store extends EventTarget {
theme: string = localStorage.getItem('neko-theme') || 'light';
fontTheme: string = localStorage.getItem('neko-font-theme') || 'default';
headingFontTheme: string = localStorage.getItem('neko-heading-font-theme') || 'default';
+ styleTheme: string = localStorage.getItem('neko-style-theme') || 'default';
sidebarVisible: boolean = getInitialSidebarVisible();
setFeeds(feeds: Feed[]) {
@@ -108,6 +109,12 @@ export class Store extends EventTarget {
this.emit('theme-updated');
}
+ setStyleTheme(styleTheme: string) {
+ this.styleTheme = styleTheme;
+ localStorage.setItem('neko-style-theme', styleTheme);
+ this.emit('style-theme-updated');
+ }
+
setSidebarVisible(visible: boolean) {
this.sidebarVisible = visible;
setSidebarCookie(visible);
diff --git a/frontend-vanilla/src/style.css b/frontend-vanilla/src/style.css
index e9512b7..f58ae24 100644
--- a/frontend-vanilla/src/style.css
+++ b/frontend-vanilla/src/style.css
@@ -215,6 +215,65 @@ html {
opacity: 1;
}
+/* Quick controls row in sidebar footer */
+.sidebar-quick-controls {
+ display: flex;
+ align-items: center;
+ gap: 0.15rem;
+ margin-bottom: 0.25rem;
+}
+
+.sidebar-controls-divider {
+ width: 1px;
+ height: 1rem;
+ background: rgba(128, 128, 128, 0.25);
+ margin: 0 0.25rem;
+}
+
+.sidebar-icon-btn {
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: 0.8rem;
+ padding: 0.25rem 0.35rem;
+ border-radius: 4px;
+ color: var(--text-color);
+ opacity: 0.35;
+ transition: opacity 0.15s, background 0.15s;
+ font-family: inherit;
+ text-transform: none;
+ font-weight: 400;
+ height: auto;
+ line-height: 1;
+}
+
+.sidebar-icon-btn:hover {
+ opacity: 0.8;
+ background: rgba(255, 255, 255, 0.08);
+ border: none;
+}
+
+.sidebar-icon-btn.active {
+ opacity: 1;
+ background: rgba(255, 255, 255, 0.15);
+ color: var(--text-color);
+}
+
+.theme-dark .sidebar-icon-btn {
+ color: rgba(0, 0, 0, 0.87);
+ border: none;
+ background: none;
+}
+
+.theme-dark .sidebar-icon-btn:hover {
+ background: rgba(0, 0, 0, 0.06);
+ border: none;
+}
+
+.theme-dark .sidebar-icon-btn.active {
+ background: rgba(0, 0, 0, 0.12);
+}
+
/* Main Content area - always fills full width (sidebar overlays) */
.main-content {
width: 100%;