aboutsummaryrefslogtreecommitdiffstats
path: root/frontend-vanilla
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-17 12:01:53 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-17 12:01:53 -0800
commit89f471809a08d3d3af049dcef43093543c11fca0 (patch)
tree00ddbbec3cd86e6dbc52a27894778f38a8dd208f /frontend-vanilla
parent3bd52a03323a9983aa7896af4d3fc3668e4c1252 (diff)
downloadneko-89f471809a08d3d3af049dcef43093543c11fca0.tar.gz
neko-89f471809a08d3d3af049dcef43093543c11fca0.tar.bz2
neko-89f471809a08d3d3af049dcef43093543c11fca0.zip
Redesign Settings page: grid layout and extended import/export options
Diffstat (limited to 'frontend-vanilla')
-rw-r--r--frontend-vanilla/src/main.ts169
-rw-r--r--frontend-vanilla/src/style.css395
2 files changed, 483 insertions, 81 deletions
diff --git a/frontend-vanilla/src/main.ts b/frontend-vanilla/src/main.ts
index 6a605c4..49b63b7 100644
--- a/frontend-vanilla/src/main.ts
+++ b/frontend-vanilla/src/main.ts
@@ -349,93 +349,111 @@ if (typeof window !== 'undefined') {
}, 1000);
}
+// ... (add this variable at module level or inside renderSettings if possible, but module level is safer for persistence across clicks if renderSettings re-runs? No, event flow is synchronous: click button -> click file input. User selects file. Change event fires.
+// Actually, file input click is async in terms of user action. renderSettings won't run in between unless something else triggers it.
+// But to be safe, I'll update the function signature of importOPML to importData.
+
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>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>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 class="settings-grid">
+ <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>
- </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>
+
+ <section class="settings-section">
+ <h3>Data</h3>
+ <div class="data-group">
+ <label>Export</label>
+ <div class="button-group">
+ <a href="/api/export/opml" class="button" target="_blank">OPML</a>
+ <a href="/api/export/text" class="button" target="_blank">TEXT</a>
+ <a href="/api/export/json" class="button" target="_blank">JSON</a>
+ </div>
+ </div>
+ <div class="data-group" style="margin-top: 1rem;">
+ <label>Import</label>
+ <div class="button-group">
+ <button class="import-btn" data-format="opml">OPML</button>
+ <button class="import-btn" data-format="text">TEXT</button>
+ <button class="import-btn" data-format="json">JSON</button>
+ </div>
+ <input type="file" id="import-file" style="display: none;">
+ </div>
+ </section>
+
+ <section class="settings-section">
+ <h3>Theme</h3>
+ <div class="settings-group">
+ <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>
+ </div>
<section class="settings-section manage-feeds-section">
<h3>Manage Feeds</h3>
- <ul class="manage-feed-list" style="list-style: none; padding: 0;">
+ <ul class="manage-feed-list">
${store.feeds.map(feed => `
- <li class="manage-feed-item" style="margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border-color); display: flex; flex-direction: column; gap: 0.5rem;">
+ <li class="manage-feed-item">
<div class="feed-info">
- <div class="feed-title" style="font-weight: bold;">${feed.title || feed.url}</div>
- <div class="feed-url" style="font-size: 0.8em; color: var(--text-color); opacity: 0.6; overflow: hidden; text-overflow: ellipsis;">${feed.url}</div>
+ <div class="feed-title">${feed.title || feed.url}</div>
+ <div class="feed-url">${feed.url}</div>
</div>
- <div class="feed-actions" style="display: flex; gap: 0.5rem;">
- <input type="text" class="feed-tag-input" data-id="${feed._id}" value="${feed.category || ''}" placeholder="Tag" style="flex: 1;">
+ <div class="feed-actions">
+ <input type="text" class="feed-tag-input" data-id="${feed._id}" value="${feed.category || ''}" placeholder="Tag">
<button class="update-feed-tag-btn" data-id="${feed._id}">Save</button>
- <button class="delete-feed-btn" data-id="${feed._id}" style="color: var(--error-color, #ff4444);">Delete</button>
+ <button class="delete-feed-btn" data-id="${feed._id}">Delete</button>
</div>
</li>
`).join('')}
</ul>
</section>
-
- <section class="settings-section">
- <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
+ // --- Listeners ---
+
+ // Theme
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);
+ store.setTheme(btn.getAttribute('data-theme')!);
renderSettings();
}
});
+ // Font
document.getElementById('font-selector')?.addEventListener('change', (e) => {
store.setFontTheme((e.target as HTMLSelectElement).value);
});
+ // Add Feed
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) {
+ if (await addFeed(url)) {
input.value = '';
alert('Feed added successfully!');
fetchFeeds();
@@ -445,40 +463,36 @@ export function renderSettings() {
}
});
- document.getElementById('export-opml-btn')?.addEventListener('click', () => {
- window.location.href = '/api/export/opml';
+ // Import Logic
+ let pendingImportFormat = 'opml';
+ const fileInput = document.getElementById('import-file') as HTMLInputElement;
+
+ document.querySelectorAll('.import-btn').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ pendingImportFormat = (e.currentTarget as HTMLElement).getAttribute('data-format') || 'opml';
+ fileInput.click();
+ });
});
- document.getElementById('import-opml-file')?.addEventListener('change', async (e) => {
+ fileInput?.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.');
+ if (await importData(file, pendingImportFormat)) {
+ alert(`Import (${pendingImportFormat}) started! check logs.`);
fetchFeeds();
} else {
- alert('Failed to import OPML.');
+ alert('Failed to import.');
}
}
+ fileInput.value = ''; // Reset
});
- // Feed Management Listeners
+ // Manage Feeds
document.querySelectorAll('.delete-feed-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
const id = parseInt((e.target as HTMLElement).getAttribute('data-id')!);
- if (confirm('Are you sure you want to delete this feed?')) {
+ if (confirm('Delete this feed?')) {
await deleteFeed(id);
- fetchFeeds();
- // re-render settings to remove the deleted feed from list
- // delay slightly to allow feed fetch? No, fetchFeeds is async.
- // We should await fetchFeeds before re-rendering?
- // But fetchFeeds updates store, and store emits 'feeds-updated'.
- // Does 'feeds-updated' re-render settings?
- // No, 'feeds-updated' calls renderFeeds (the sidebar list).
- // So we need to explicitly call renderSettings() to update the management list.
- // But we should wait for fetchFeeds() to complete so store is updated.
- // wait... fetchFeeds() is async but we don't await result in the listener above?
- // Ah, fetchFeeds() returns Promise.
await fetchFeeds();
renderSettings();
}
@@ -489,12 +503,10 @@ export function renderSettings() {
btn.addEventListener('click', async (e) => {
const id = parseInt((e.target as HTMLElement).getAttribute('data-id')!);
const input = document.querySelector(`.feed-tag-input[data-id="${id}"]`) as HTMLInputElement;
- const category = input.value.trim();
- await updateFeed(id, { category });
- // updateFeed returns boolean, assuming success
+ await updateFeed(id, { category: input.value.trim() });
await fetchFeeds();
await fetchTags();
- renderSettings(); // Update list to show persistence
+ renderSettings();
alert('Feed updated');
});
});
@@ -514,25 +526,22 @@ async function addFeed(url: string): Promise<boolean> {
}
}
-async function importOPML(file: File): Promise<boolean> {
+async function importData(file: File, format: string): Promise<boolean> {
try {
const formData = new FormData();
formData.append('file', file);
- formData.append('format', 'opml');
+ formData.append('format', format);
- // 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 || ''
- },
+ headers: { 'X-CSRF-Token': csrfToken || '' },
body: formData
});
return res.ok;
} catch (err) {
- console.error('Failed to import OPML', err);
+ console.error('Failed to import', err);
return false;
}
}
diff --git a/frontend-vanilla/src/style.css b/frontend-vanilla/src/style.css
index a4bcbe9..ca2fdf5 100644
--- a/frontend-vanilla/src/style.css
+++ b/frontend-vanilla/src/style.css
@@ -629,4 +629,397 @@ button.active {
label.button {
cursor: pointer;
-} \ No newline at end of file
+}
+/* Settings Grid Layout */
+.settings-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: 2rem;
+ margin-bottom: 2rem;
+}
+
+.settings-section {
+ background: var(--bg-color);
+ padding: 0;
+ border: none;
+ margin-bottom: 0;
+}
+
+.settings-section h3 {
+ font-size: 1.1rem;
+ margin-top: 0;
+ margin-bottom: 1rem;
+ border-bottom: 1px solid var(--border-color);
+ padding-bottom: 0.5rem;
+ opacity: 0.7;
+}
+
+.manage-feeds-section {
+ grid-column: 1 / -1;
+ margin-top: 1rem;
+}
+
+.button-group {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.button-group a.button,
+.button-group button {
+ flex: 1;
+ display: inline-block;
+ padding: 0.5rem;
+ text-align: center;
+ background: var(--bg-color);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ text-decoration: none;
+ color: var(--text-color);
+ font-size: 0.9rem;
+ cursor: pointer;
+ line-height: 1.5;
+}
+
+.button-group a.button:hover,
+.button-group button:hover {
+ background: rgba(0,0,0,0.05);
+ border-color: var(--accent-color);
+}
+
+.theme-dark .button-group a.button:hover,
+.theme-dark .button-group button:hover {
+ background: rgba(255,255,255,0.1);
+}
+
+.manage-feed-list {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ gap: 1rem;
+ padding: 0;
+ list-style: none;
+}
+
+.manage-feed-item {
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ padding: 1rem;
+ background: rgba(0,0,0,0.02);
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.theme-dark .manage-feed-item {
+ background: rgba(255,255,255,0.02);
+}
+
+.feed-title {
+ font-weight: bold;
+ margin-bottom: 0.25rem;
+ font-size: 0.95rem;
+}
+
+.feed-url {
+ font-size: 0.8em;
+ opacity: 0.7;
+ margin-bottom: 0.5rem;
+ word-break: break-all;
+ overflow-wrap: anywhere;
+}
+
+/* Settings Grid Layout */
+.settings-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: 2rem;
+ margin-bottom: 2rem;
+}
+
+.settings-section {
+ background: var(--bg-color);
+ padding: 0;
+ border: none;
+ margin-bottom: 0;
+}
+
+.settings-section h3 {
+ font-size: 1.1rem;
+ margin-top: 0;
+ margin-bottom: 1rem;
+ border-bottom: 1px solid var(--border-color);
+ padding-bottom: 0.5rem;
+ opacity: 0.7;
+}
+
+.manage-feeds-section {
+ grid-column: 1 / -1;
+ margin-top: 1rem;
+}
+
+.button-group {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.button-group a.button,
+.button-group button {
+ flex: 1;
+ display: inline-block;
+ padding: 0.5rem;
+ text-align: center;
+ background: var(--bg-color);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ text-decoration: none;
+ color: var(--text-color);
+ font-size: 0.9rem;
+ cursor: pointer;
+ line-height: 1.5;
+}
+
+.button-group a.button:hover,
+.button-group button:hover {
+ background: rgba(0,0,0,0.05);
+ border-color: var(--accent-color);
+}
+
+.theme-dark .button-group a.button:hover,
+.theme-dark .button-group button:hover {
+ background: rgba(255,255,255,0.1);
+}
+
+.manage-feed-list {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ gap: 1rem;
+ padding: 0;
+ list-style: none;
+}
+
+.manage-feed-item {
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ padding: 1rem;
+ background: rgba(0,0,0,0.02);
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.theme-dark .manage-feed-item {
+ background: rgba(255,255,255,0.02);
+}
+
+.feed-title {
+ font-weight: bold;
+ margin-bottom: 0.25rem;
+ font-size: 0.95rem;
+}
+
+.feed-url {
+ font-size: 0.8em;
+ opacity: 0.7;
+ margin-bottom: 0.5rem;
+ word-break: break-all;
+ overflow-wrap: anywhere;
+}
+
+/* Settings Grid Layout */
+.settings-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: 2rem;
+ margin-bottom: 2rem;
+}
+
+.settings-section {
+ background: var(--bg-color);
+ padding: 0;
+ border: none;
+ margin-bottom: 0;
+}
+
+.settings-section h3 {
+ font-size: 1.1rem;
+ margin-top: 0;
+ margin-bottom: 1rem;
+ border-bottom: 1px solid var(--border-color);
+ padding-bottom: 0.5rem;
+ opacity: 0.7;
+}
+
+.manage-feeds-section {
+ grid-column: 1 / -1;
+ margin-top: 1rem;
+}
+
+.button-group {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.button-group a.button,
+.button-group button {
+ flex: 1;
+ display: inline-block;
+ padding: 0.5rem;
+ text-align: center;
+ background: var(--bg-color);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ text-decoration: none;
+ color: var(--text-color);
+ font-size: 0.9rem;
+ cursor: pointer;
+ line-height: 1.5;
+}
+
+.button-group a.button:hover,
+.button-group button:hover {
+ background: rgba(0,0,0,0.05);
+ border-color: var(--accent-color);
+}
+
+.theme-dark .button-group a.button:hover,
+.theme-dark .button-group button:hover {
+ background: rgba(255,255,255,0.1);
+}
+
+.manage-feed-list {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ gap: 1rem;
+ padding: 0;
+ list-style: none;
+}
+
+.manage-feed-item {
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ padding: 1rem;
+ background: rgba(0,0,0,0.02);
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.theme-dark .manage-feed-item {
+ background: rgba(255,255,255,0.02);
+}
+
+.feed-title {
+ font-weight: bold;
+ margin-bottom: 0.25rem;
+ font-size: 0.95rem;
+}
+
+.feed-url {
+ font-size: 0.8em;
+ opacity: 0.7;
+ margin-bottom: 0.5rem;
+ word-break: break-all;
+ overflow-wrap: anywhere;
+}
+
+
+/* Settings Grid Layout */
+.settings-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: 2rem;
+ margin-bottom: 2rem;
+}
+
+.settings-section {
+ background: var(--bg-color);
+ padding: 0;
+ border: none;
+ margin-bottom: 0;
+}
+
+.settings-section h3 {
+ font-size: 1.1rem;
+ margin-top: 0;
+ margin-bottom: 1rem;
+ border-bottom: 1px solid var(--border-color);
+ padding-bottom: 0.5rem;
+ opacity: 0.7;
+}
+
+.manage-feeds-section {
+ grid-column: 1 / -1;
+ margin-top: 1rem;
+}
+
+.button-group {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.button-group a.button,
+.button-group button {
+ flex: 1;
+ display: inline-block;
+ padding: 0.5rem;
+ text-align: center;
+ background: var(--bg-color);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ text-decoration: none;
+ color: var(--text-color);
+ font-size: 0.9rem;
+ cursor: pointer;
+ line-height: 1.5;
+}
+
+.button-group a.button:hover,
+.button-group button:hover {
+ background: rgba(0,0,0,0.05);
+ border-color: var(--accent-color);
+}
+
+.theme-dark .button-group a.button:hover,
+.theme-dark .button-group button:hover {
+ background: rgba(255,255,255,0.1);
+}
+
+.manage-feed-list {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ gap: 1rem;
+ padding: 0;
+ list-style: none;
+}
+
+.manage-feed-item {
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ padding: 1rem;
+ background: rgba(0,0,0,0.02);
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.theme-dark .manage-feed-item {
+ background: rgba(255,255,255,0.02);
+}
+
+.feed-title {
+ font-weight: bold;
+ margin-bottom: 0.25rem;
+ font-size: 0.95rem;
+}
+
+.feed-url {
+ font-size: 0.8em;
+ opacity: 0.7;
+ margin-bottom: 0.5rem;
+ word-break: break-all;
+ overflow-wrap: anywhere;
+}
+