aboutsummaryrefslogtreecommitdiffstats
path: root/frontend-vanilla/src
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-15 21:48:57 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-15 21:48:57 -0800
commit657faf3acd7755d6b84a87e0e363729d95930258 (patch)
tree0691a677c3b6e8827d52a9a536f9ee9a6cbdcf80 /frontend-vanilla/src
parent371e474217b5ade280e2d9b3893b1893be507eb1 (diff)
downloadneko-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/src')
-rw-r--r--frontend-vanilla/src/main.ts132
-rw-r--r--frontend-vanilla/src/style.css64
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;
+}