aboutsummaryrefslogtreecommitdiffstats
path: root/frontend-vanilla
diff options
context:
space:
mode:
Diffstat (limited to 'frontend-vanilla')
-rw-r--r--frontend-vanilla/.gitignore1
-rw-r--r--frontend-vanilla/package-lock.json214
-rw-r--r--frontend-vanilla/package.json3
-rw-r--r--frontend-vanilla/src/api.test.ts45
-rw-r--r--frontend-vanilla/src/components/FeedItem.test.ts38
-rw-r--r--frontend-vanilla/src/components/FeedItem.ts38
-rw-r--r--frontend-vanilla/src/main.test.ts249
-rw-r--r--frontend-vanilla/src/main.ts461
-rw-r--r--frontend-vanilla/src/router.test.ts56
-rw-r--r--frontend-vanilla/src/store.test.ts39
-rw-r--r--frontend-vanilla/src/style.css382
11 files changed, 1099 insertions, 427 deletions
diff --git a/frontend-vanilla/.gitignore b/frontend-vanilla/.gitignore
index a547bf3..1eb4319 100644
--- a/frontend-vanilla/.gitignore
+++ b/frontend-vanilla/.gitignore
@@ -22,3 +22,4 @@ dist-ssr
*.njsproj
*.sln
*.sw?
+coverage
diff --git a/frontend-vanilla/package-lock.json b/frontend-vanilla/package-lock.json
index 8a82676..42cf825 100644
--- a/frontend-vanilla/package-lock.json
+++ b/frontend-vanilla/package-lock.json
@@ -10,6 +10,7 @@
"devDependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
+ "@vitest/coverage-v8": "^4.0.18",
"jsdom": "^28.1.0",
"typescript": "~5.9.3",
"vite": "^7.3.1",
@@ -74,6 +75,15 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
@@ -83,6 +93,21 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/parser": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
+ "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
@@ -92,6 +117,28 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
@@ -663,12 +710,31 @@
}
}
},
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true
},
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
@@ -1072,6 +1138,36 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true
},
+ "node_modules/@vitest/coverage-v8": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz",
+ "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==",
+ "dev": true,
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.2",
+ "@vitest/utils": "4.0.18",
+ "ast-v8-to-istanbul": "^0.3.10",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-reports": "^3.2.0",
+ "magicast": "^0.5.1",
+ "obug": "^2.1.1",
+ "std-env": "^3.10.0",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "4.0.18",
+ "vitest": "4.0.18"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@vitest/expect": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
@@ -1224,6 +1320,23 @@
"node": ">=12"
}
},
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "0.3.11",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz",
+ "integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^10.0.0"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
+ "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
+ "dev": true
+ },
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
@@ -1435,6 +1548,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
@@ -1447,6 +1569,12 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true
+ },
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -1488,6 +1616,42 @@
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true
},
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1561,6 +1725,32 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/magicast": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz",
+ "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/mdn-data": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
@@ -1781,6 +1971,18 @@
"node": ">=v12.22.7"
}
},
+ "node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "dev": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -1820,6 +2022,18 @@
"node": ">=8"
}
},
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
diff --git a/frontend-vanilla/package.json b/frontend-vanilla/package.json
index 65e19b2..126c675 100644
--- a/frontend-vanilla/package.json
+++ b/frontend-vanilla/package.json
@@ -12,9 +12,10 @@
"devDependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
+ "@vitest/coverage-v8": "^4.0.18",
"jsdom": "^28.1.0",
"typescript": "~5.9.3",
"vite": "^7.3.1",
"vitest": "^4.0.18"
}
-} \ No newline at end of file
+}
diff --git a/frontend-vanilla/src/api.test.ts b/frontend-vanilla/src/api.test.ts
new file mode 100644
index 0000000..9128ef3
--- /dev/null
+++ b/frontend-vanilla/src/api.test.ts
@@ -0,0 +1,45 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { apiFetch, getCookie } from './api';
+
+describe('api', () => {
+ beforeEach(() => {
+ vi.stubGlobal('fetch', vi.fn());
+ document.cookie = '';
+ });
+
+ it('getCookie should return cookie value', () => {
+ document.cookie = 'foo=bar';
+ document.cookie = 'csrf_token=test-token';
+ expect(getCookie('csrf_token')).toBe('test-token');
+ expect(getCookie('foo')).toBe('bar');
+ expect(getCookie('baz')).toBeUndefined();
+ });
+
+ it('apiFetch should include CSRF token for POST requests', async () => {
+ document.cookie = 'csrf_token=test-token';
+ const mockFetch = vi.mocked(fetch);
+ mockFetch.mockResolvedValueOnce(new Response());
+
+ await apiFetch('/test', { method: 'POST' });
+
+ expect(mockFetch).toHaveBeenCalledWith('/test', expect.objectContaining({
+ method: 'POST',
+ headers: expect.any(Headers),
+ credentials: 'include'
+ }));
+
+ const headers = mockFetch.mock.calls[0][1]?.headers as Headers;
+ expect(headers.get('X-CSRF-Token')).toBe('test-token');
+ });
+
+ it('apiFetch should not include CSRF token for GET requests', async () => {
+ document.cookie = 'csrf_token=test-token';
+ const mockFetch = vi.mocked(fetch);
+ mockFetch.mockResolvedValueOnce(new Response());
+
+ await apiFetch('/test');
+
+ const headers = mockFetch.mock.calls[0][1]?.headers as Headers;
+ expect(headers.get('X-CSRF-Token')).toBeNull();
+ });
+});
diff --git a/frontend-vanilla/src/components/FeedItem.test.ts b/frontend-vanilla/src/components/FeedItem.test.ts
index 708a871..e6c0b62 100644
--- a/frontend-vanilla/src/components/FeedItem.test.ts
+++ b/frontend-vanilla/src/components/FeedItem.test.ts
@@ -1,23 +1,39 @@
import { describe, it, expect } from 'vitest';
import { createFeedItem } from './FeedItem';
+import type { Item } from '../types';
describe('FeedItem Component', () => {
- const mockFeed = { _id: 1, title: 'My Feed', url: 'http://test', web_url: 'http://test', category: 'tag' };
+ const mockItem: Item = {
+ _id: 1,
+ title: 'Item Title',
+ url: 'http://test',
+ publish_date: '2023-01-01',
+ read: false,
+ starred: false,
+ feed_title: 'Feed Title',
+ description: 'Desc'
+ } as any;
- it('should render a feed item correctly', () => {
- const html = createFeedItem(mockFeed, false);
- expect(html).toContain('My Feed');
+ it('should render an item correctly', () => {
+ const html = createFeedItem(mockItem);
+ expect(html).toContain('Item Title');
expect(html).toContain('data-id="1"');
- expect(html).not.toContain('active');
+ expect(html).toContain('unread');
});
- it('should apply active class when isActive is true', () => {
- const html = createFeedItem(mockFeed, true);
- expect(html).toContain('active');
+ it('should show read state', () => {
+ const html = createFeedItem({ ...mockItem, read: true });
+ expect(html).toContain('read');
+ expect(html).not.toContain('unread');
});
- it('should fallback to URL if title is missing', () => {
- const html = createFeedItem({ ...mockFeed, title: '' }, false);
- expect(html).toContain('http://test');
+ it('should show starred state', () => {
+ const html = createFeedItem({ ...mockItem, starred: true });
+ expect(html).toContain('is-starred');
+ });
+
+ it('should fallback to (No Title) if title is missing', () => {
+ const html = createFeedItem({ ...mockItem, title: '' });
+ expect(html).toContain('(No Title)');
});
});
diff --git a/frontend-vanilla/src/components/FeedItem.ts b/frontend-vanilla/src/components/FeedItem.ts
index 3bf72c2..e58aac8 100644
--- a/frontend-vanilla/src/components/FeedItem.ts
+++ b/frontend-vanilla/src/components/FeedItem.ts
@@ -1,11 +1,35 @@
-import type { Feed } from '../types';
+import type { Item } from '../types';
-export function createFeedItem(feed: Feed, isActive: boolean): string {
- return `
- <li class="feed-item ${isActive ? 'active' : ''}" data-id="${feed._id}">
- <a href="/v3/feed/${feed._id}" class="feed-link" onclick="event.preventDefault(); window.app.navigate('/feed/${feed._id}')">
- ${feed.title || feed.url}
- </a>
+export function createFeedItem(item: Item): string {
+ const date = new Date(item.publish_date).toLocaleDateString();
+ return `
+ <li class="feed-item ${item.read ? 'read' : 'unread'}" data-id="${item._id}">
+ <div class="item-header">
+ <a href="${item.url}" target="_blank" rel="noopener noreferrer" class="item-title" data-action="open">
+ ${item.title || '(No Title)'}
+ </a>
+ <button class="star-btn ${item.starred ? 'is-starred' : 'is-unstarred'}" title="${item.starred ? 'Unstar' : 'Star'}" data-action="toggle-star">
+ ★
+ </button>
+ </div>
+ <div class="dateline">
+ <a href="${item.url}" target="_blank" rel="noopener noreferrer">
+ ${date}
+ ${item.feed_title ? ` - ${item.feed_title}` : ''}
+ </a>
+ <div class="item-actions" style="display: inline-block; float: right;">
+ ${!item.full_content ? `
+ <button class="scrape-btn" title="Load Full Content" data-action="scrape">
+ text
+ </button>
+ ` : ''}
+ </div>
+ </div>
+ ${(item.full_content || item.description) ? `
+ <div class="item-description">
+ ${item.full_content || item.description}
+ </div>
+ ` : ''}
</li>
`;
}
diff --git a/frontend-vanilla/src/main.test.ts b/frontend-vanilla/src/main.test.ts
new file mode 100644
index 0000000..be5a076
--- /dev/null
+++ b/frontend-vanilla/src/main.test.ts
@@ -0,0 +1,249 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { store } from './store';
+import { router } from './router';
+import {
+ renderLayout,
+ renderFeeds,
+ renderTags,
+ renderFilters,
+ renderItems,
+ renderSettings,
+ fetchFeeds,
+ fetchTags,
+ fetchItems,
+ init,
+ logout
+} from './main';
+import { apiFetch } from './api';
+
+// Mock api
+vi.mock('./api', () => ({
+ apiFetch: vi.fn()
+}));
+
+// Mock IntersectionObserver
+const mockObserver = vi.fn(() => ({
+ observe: vi.fn(),
+ unobserve: vi.fn(),
+ disconnect: vi.fn(),
+}));
+vi.stubGlobal('IntersectionObserver', mockObserver);
+
+describe('main application logic', () => {
+ beforeEach(() => {
+ document.body.innerHTML = '<div id="app"></div>';
+ vi.stubGlobal('location', {
+ href: 'http://localhost/v3/',
+ pathname: '/v3/',
+ search: '',
+ assign: vi.fn(),
+ replace: vi.fn()
+ });
+ vi.stubGlobal('history', {
+ pushState: vi.fn()
+ });
+ // Mock scrollIntoView which is missing in JSDOM
+ Element.prototype.scrollIntoView = vi.fn();
+ vi.clearAllMocks();
+ // Reset store
+ store.setFeeds([]);
+ store.setTags([]);
+ store.setItems([]);
+
+ // Setup default auth response
+ vi.mocked(apiFetch).mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => []
+ } as Response);
+ });
+
+ it('renderLayout should create sidebar and main content', () => {
+ renderLayout();
+ expect(document.getElementById('sidebar')).not.toBeNull();
+ expect(document.getElementById('content-area')).not.toBeNull();
+ expect(document.getElementById('logo-link')).not.toBeNull();
+ });
+
+ it('renderFeeds should populate feed list', () => {
+ renderLayout();
+ store.setFeeds([{ _id: 1, title: 'Test Feed', url: 'test', web_url: 'test', category: 'tag' }]);
+ renderFeeds();
+ const feedList = document.getElementById('feed-list');
+ expect(feedList?.innerHTML).toContain('Test Feed');
+ });
+
+ it('renderTags should populate tag list', () => {
+ renderLayout();
+ store.setTags([{ title: 'Test Tag' } as any]);
+ renderTags();
+ const tagList = document.getElementById('tag-list');
+ expect(tagList?.innerHTML).toContain('Test Tag');
+ });
+
+ it('renderFilters should update active filter', () => {
+ renderLayout();
+ store.setFilter('starred');
+ renderFilters();
+ const starredFilter = document.querySelector('[data-filter="starred"]');
+ expect(starredFilter?.classList.contains('active')).toBe(true);
+ });
+
+ it('renderItems should populate content area', () => {
+ renderLayout();
+ store.setItems([{ _id: 1, title: 'Item 1', url: 'test', publish_date: '2023-01-01' } as any]);
+ renderItems();
+ const contentArea = document.getElementById('content-area');
+ expect(contentArea?.innerHTML).toContain('Item 1');
+ });
+
+ it('renderSettings should show theme and font options', () => {
+ renderLayout();
+ renderSettings();
+ expect(document.querySelector('.settings-view')).not.toBeNull();
+ expect(document.getElementById('font-selector')).not.toBeNull();
+ });
+
+ it('fetchFeeds should update store', async () => {
+ vi.mocked(apiFetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => [{ _id: 1, title: 'API Feed' }]
+ } as Response);
+
+ await fetchFeeds();
+ expect(store.feeds).toHaveLength(1);
+ expect(store.feeds[0].title).toBe('API Feed');
+ });
+
+ it('fetchTags should update store', async () => {
+ vi.mocked(apiFetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => [{ title: 'API Tag' }]
+ } as Response);
+
+ await fetchTags();
+ expect(store.tags).toHaveLength(1);
+ expect(store.tags[0].title).toBe('API Tag');
+ });
+
+ it('fetchItems should update store items', async () => {
+ vi.mocked(apiFetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => [{ _id: 1, title: 'API Item' }]
+ } as Response);
+
+ renderLayout();
+ await fetchItems();
+ expect(store.items).toHaveLength(1);
+ expect(store.items[0].title).toBe('API Item');
+ });
+
+ it('init should coordinate startup', async () => {
+ vi.mocked(apiFetch).mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => []
+ } as Response);
+
+ await init();
+ expect(document.getElementById('sidebar')).not.toBeNull();
+ });
+
+ it('should handle search input', () => {
+ renderLayout();
+ const searchInput = document.getElementById('search-input') as HTMLInputElement;
+ const spy = vi.spyOn(router, 'updateQuery');
+ searchInput.value = 'query';
+ searchInput.dispatchEvent(new Event('input'));
+ expect(spy).toHaveBeenCalledWith({ q: 'query' });
+ });
+
+ it('should handle sidebar navigation clicking', () => {
+ renderLayout();
+ const spy = vi.spyOn(router, 'updateQuery');
+ const filterLink = document.querySelector('[data-nav="filter"]') as HTMLElement;
+ filterLink.click();
+ expect(spy).toHaveBeenCalled();
+ });
+
+ it('should handle item star toggle', async () => {
+ renderLayout();
+ const mockItem = { _id: 1, title: 'Item 1', starred: false, publish_date: '2023-01-01' } as any;
+ store.setItems([mockItem]);
+ renderItems();
+
+ vi.mocked(apiFetch).mockResolvedValue({ ok: true } as Response);
+
+ const starBtn = document.querySelector('[data-action="toggle-star"]') as HTMLElement;
+ starBtn.click();
+
+ expect(apiFetch).toHaveBeenCalledWith(expect.stringContaining('/api/item/1'), expect.objectContaining({
+ method: 'PUT',
+ body: expect.stringContaining('"starred":true')
+ }));
+ });
+
+ it('should handle theme change in settings', () => {
+ renderLayout();
+ renderSettings();
+ const darkBtn = document.querySelector('[data-theme="dark"]') as HTMLElement;
+ const spy = vi.spyOn(store, 'setTheme');
+ darkBtn.click();
+ expect(spy).toHaveBeenCalledWith('dark');
+ });
+
+ it('should handle logout', async () => {
+ vi.mocked(apiFetch).mockResolvedValue({ ok: true } as Response);
+ await logout();
+ expect(apiFetch).toHaveBeenCalledWith('/api/logout', { method: 'POST' });
+ expect(window.location.href).toBe('/login/');
+ });
+
+ it('should handle keyboard navigation j/k', () => {
+ const mockItems = [
+ { _id: 1, title: 'Item 1', publish_date: '2023-01-01', read: false },
+ { _id: 2, title: 'Item 2', publish_date: '2023-01-01', read: false }
+ ] as any;
+ store.setItems(mockItems);
+ renderLayout();
+ renderItems();
+
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'j' }));
+ expect(apiFetch).toHaveBeenCalled(); // mark as read
+
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k' }));
+ // should go back to first item
+ });
+
+ it('should handle toggle star/read with keyboard', async () => {
+ const mockItem = { _id: 1, title: 'Item 1', publish_date: '2023-01-01', read: true, starred: false } as any;
+ store.setItems([mockItem]);
+ renderLayout();
+ renderItems();
+
+ // Already read, so 'j' won't trigger updateItem for read=true
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'j' }));
+
+ vi.mocked(apiFetch).mockResolvedValue({ ok: true } as Response);
+
+ // Toggle star
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 's' }));
+ expect(apiFetch).toHaveBeenCalledWith(expect.stringContaining('/api/item/1'), expect.objectContaining({
+ body: expect.stringContaining('"starred":true')
+ }));
+
+ // Toggle read (currently true -> false)
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'r' }));
+ expect(apiFetch).toHaveBeenLastCalledWith(expect.stringContaining('/api/item/1'), expect.objectContaining({
+ body: expect.stringContaining('"read":false')
+ }));
+ });
+
+ it('should focus search with /', () => {
+ renderLayout();
+ const searchInput = document.getElementById('search-input') as HTMLInputElement;
+ const spy = vi.spyOn(searchInput, 'focus');
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: '/' }));
+ expect(spy).toHaveBeenCalled();
+ });
+});
diff --git a/frontend-vanilla/src/main.ts b/frontend-vanilla/src/main.ts
index 0d47575..5e14266 100644
--- a/frontend-vanilla/src/main.ts
+++ b/frontend-vanilla/src/main.ts
@@ -6,24 +6,29 @@ import { router } from './router';
import type { Feed, Item, Category } from './types';
import { createFeedItem } from './components/FeedItem';
-// Extend Window interface for app object
+// Extend Window interface for app object (keeping for compatibility if needed, but removing inline dependencies)
declare global {
interface Window {
app: any;
}
}
-// Cache elements
-const appEl = document.querySelector<HTMLDivElement>('#app')!;
+// Global App State
+let activeItemId: number | null = null;
+
+// Cache elements (initialized in renderLayout)
+let appEl: HTMLDivElement | null = null;
-// Initial Layout
-function renderLayout() {
+// Initial Layout (v2-style 2-pane)
+export function renderLayout() {
+ appEl = document.querySelector<HTMLDivElement>('#app');
+ if (!appEl) return;
appEl.className = `theme-${store.theme} font-${store.fontTheme}`;
appEl.innerHTML = `
<div class="layout">
- <aside class="sidebar">
+ <aside class="sidebar" id="sidebar">
<div class="sidebar-header">
- <h2 onclick="window.app.navigate('/')" style="cursor: pointer">Neko v3</h2>
+ <h2 id="logo-link">Neko v3</h2>
</div>
<div class="sidebar-search">
<input type="search" id="search-input" placeholder="Search..." value="${store.searchQuery}">
@@ -31,192 +36,261 @@ function renderLayout() {
<div class="sidebar-scroll">
<section class="sidebar-section">
<h3>Filters</h3>
- <ul id="filter-list" class="filter-list">
- <li class="filter-item" data-filter="unread"><a href="#" onclick="event.preventDefault(); window.app.setFilter('unread')">Unread</a></li>
- <li class="filter-item" data-filter="all"><a href="#" onclick="event.preventDefault(); window.app.setFilter('all')">All</a></li>
- <li class="filter-item" data-filter="starred"><a href="#" onclick="event.preventDefault(); window.app.setFilter('starred')">Starred</a></li>
+ <ul id="filter-list">
+ <li class="filter-item" data-filter="unread"><a href="/v3/?filter=unread" data-nav="filter" data-value="unread">Unread</a></li>
+ <li class="filter-item" data-filter="all"><a href="/v3/?filter=all" data-nav="filter" data-value="all">All</a></li>
+ <li class="filter-item" data-filter="starred"><a href="/v3/?filter=starred" data-nav="filter" data-value="starred">Starred</a></li>
</ul>
</section>
<section class="sidebar-section">
<h3>Tags</h3>
- <ul id="tag-list" class="tag-list"></ul>
+ <ul id="tag-list"></ul>
</section>
<section class="sidebar-section">
<h3>Feeds</h3>
- <ul id="feed-list" class="feed-list"></ul>
+ <ul id="feed-list"></ul>
</section>
</div>
<div class="sidebar-footer">
- <a href="#" onclick="event.preventDefault(); window.app.navigate('/settings')">Settings</a>
- <a href="#" onclick="event.preventDefault(); window.app.logout()">Logout</a>
+ <a href="/v3/settings" id="settings-link">Settings</a>
+ <a href="#" id="logout-button">Logout</a>
</div>
</aside>
- <section class="item-list-pane">
- <header class="top-bar">
- <h1 id="view-title">All Items</h1>
- </header>
- <div id="item-list-container" class="item-list-container"></div>
- </section>
- <main class="item-detail-pane" id="main-pane">
- <div id="item-detail-content" class="item-detail-content">
- <div class="empty-state">Select an item to read</div>
- </div>
+ <main class="main-content" id="main-content">
+ <div id="content-area"></div>
</main>
</div>
`;
- // Attach search listener
+ attachLayoutListeners();
+}
+
+export function attachLayoutListeners() {
const searchInput = document.getElementById('search-input') as HTMLInputElement;
searchInput?.addEventListener('input', (e) => {
const query = (e.target as HTMLInputElement).value;
- window.app.setSearch(query);
+ router.updateQuery({ q: query });
});
-}
-renderLayout();
+ const logoLink = document.getElementById('logo-link');
+ logoLink?.addEventListener('click', () => router.navigate('/'));
-const feedListEl = document.getElementById('feed-list')!;
-const tagListEl = document.getElementById('tag-list')!;
-const filterListEl = document.getElementById('filter-list')!;
-const viewTitleEl = document.getElementById('view-title')!;
-const itemListEl = document.getElementById('item-list-container')!;
-const itemDetailEl = document.getElementById('item-detail-content')!;
+ const logoutBtn = document.getElementById('logout-button');
+ logoutBtn?.addEventListener('click', (e) => {
+ e.preventDefault();
+ logout();
+ });
-let activeItemId: number | null = null;
+ const settingsLink = document.getElementById('settings-link');
+ settingsLink?.addEventListener('click', (e) => {
+ e.preventDefault();
+ router.navigate('/settings');
+ });
+
+ // Event delegation for filters, tags, and feeds in sidebar
+ const sidebar = document.getElementById('sidebar');
+ sidebar?.addEventListener('click', (e) => {
+ const target = e.target as HTMLElement;
+ const link = target.closest('a');
+ if (!link) return;
+
+ const navType = link.getAttribute('data-nav');
+ if (navType === 'filter') {
+ e.preventDefault();
+ const filter = link.getAttribute('data-value') as FilterType;
+ router.updateQuery({ filter });
+ } else if (navType === 'tag') {
+ e.preventDefault();
+ const tag = link.getAttribute('data-value')!;
+ router.navigate(`/tag/${encodeURIComponent(tag)}`);
+ } else if (navType === 'feed') {
+ e.preventDefault();
+ const feedId = link.getAttribute('data-value')!;
+ router.navigate(`/feed/${feedId}`);
+ }
+ });
+
+ // Event delegation for content area (items)
+ const contentArea = document.getElementById('content-area');
+ contentArea?.addEventListener('click', (e) => {
+ const target = e.target as HTMLElement;
+
+ // Handle Toggle Star
+ const starBtn = target.closest('[data-action="toggle-star"]');
+ if (starBtn) {
+ const itemRow = starBtn.closest('[data-id]');
+ if (itemRow) {
+ const id = parseInt(itemRow.getAttribute('data-id')!);
+ toggleStar(id);
+ }
+ return;
+ }
+
+ // Handle Scrape
+ const scrapeBtn = target.closest('[data-action="scrape"]');
+ if (scrapeBtn) {
+ const itemRow = scrapeBtn.closest('[data-id]');
+ if (itemRow) {
+ const id = parseInt(itemRow.getAttribute('data-id')!);
+ scrapeItem(id);
+ }
+ return;
+ }
+
+ // Handle Item interaction (mark as read on click title or row)
+ 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) {
+ updateItem(id, { read: true });
+ }
+ }
+ });
+}
// --- Rendering Functions ---
-function renderFeeds() {
+export function renderFeeds() {
const { feeds, activeFeedId } = store;
+ const feedListEl = document.getElementById('feed-list');
if (!feedListEl) return;
- feedListEl.innerHTML = feeds.map((feed: Feed) =>
- createFeedItem(feed, feed._id === activeFeedId)
- ).join('');
+ feedListEl.innerHTML = feeds.map((feed: Feed) => `
+ <li class="${feed._id === activeFeedId ? 'active' : ''}">
+ <a href="/v3/feed/${feed._id}" data-nav="feed" data-value="${feed._id}">
+ ${feed.title || feed.url}
+ </a>
+ </li>
+ `).join('');
}
-function renderTags() {
+export function renderTags() {
const { tags, activeTagName } = store;
+ const tagListEl = document.getElementById('tag-list');
if (!tagListEl) return;
tagListEl.innerHTML = tags.map((tag: Category) => `
- <li class="tag-item ${tag.title === activeTagName ? 'active' : ''}">
- <a href="/v3/tag/${encodeURIComponent(tag.title)}" class="tag-link" onclick="event.preventDefault(); window.app.navigate('/tag/${encodeURIComponent(tag.title)}')">
+ <li class="${tag.title === activeTagName ? 'active' : ''}">
+ <a href="/v3/tag/${encodeURIComponent(tag.title)}" data-nav="tag" data-value="${tag.title}">
${tag.title}
</a>
</li>
`).join('');
}
-function renderFilters() {
+export function renderFilters() {
const { filter } = store;
+ const filterListEl = document.getElementById('filter-list');
if (!filterListEl) return;
- filterListEl.querySelectorAll('.filter-item').forEach(el => {
+ filterListEl.querySelectorAll('li').forEach(el => {
el.classList.toggle('active', el.getAttribute('data-filter') === filter);
});
}
-function renderItems() {
+export function renderItems() {
const { items, loading } = store;
- if (!itemListEl) return;
+ const contentArea = document.getElementById('content-area');
+ if (!contentArea || router.getCurrentRoute().path === '/settings') return;
if (loading && items.length === 0) {
- itemListEl.innerHTML = '<p class="loading">Loading items...</p>';
+ contentArea.innerHTML = '<p class="loading">Loading items...</p>';
return;
}
if (items.length === 0) {
- itemListEl.innerHTML = '<p class="empty">No items found.</p>';
+ contentArea.innerHTML = '<p class="empty">No items found.</p>';
return;
}
- itemListEl.innerHTML = `
+ contentArea.innerHTML = `
<ul class="item-list">
- ${items.map((item: Item) => `
- <li class="item-row ${item.read ? 'read' : ''} ${item._id === activeItemId ? 'active' : ''}" data-id="${item._id}">
- <div class="item-title">${item.title}</div>
- <div class="item-meta">${item.feed_title || ''}</div>
- </li>
- `).join('')}
+ ${items.map((item: Item) => createFeedItem(item)).join('')}
</ul>
- ${store.hasMore ? '<div id="load-more" class="load-more">Loading more...</div>' : ''}
+ ${store.hasMore ? '<div id="load-more-sentinel" class="loading-more">Loading more...</div>' : ''}
`;
- // Add click listeners to items
- itemListEl.querySelectorAll('.item-row').forEach(row => {
- row.addEventListener('click', () => {
- const id = parseInt(row.getAttribute('data-id') || '0');
- selectItem(id);
- });
- });
-
- // Infinite scroll observer
- const loadMoreEl = document.getElementById('load-more');
- if (loadMoreEl) {
+ // Setup infinite scroll
+ const sentinel = document.getElementById('load-more-sentinel');
+ if (sentinel) {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !store.loading && store.hasMore) {
loadMore();
}
}, { threshold: 0.1 });
- observer.observe(loadMoreEl);
+ observer.observe(sentinel);
}
}
-async function selectItem(id: number, scroll: boolean = false) {
- activeItemId = id;
- const item = store.items.find((i: Item) => i._id === id);
- if (!item) return;
+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 (Serif)</option>
+ <option value="serif" ${store.fontTheme === 'serif' ? 'selected' : ''}>Serif (Georgia)</option>
+ <option value="mono" ${store.fontTheme === 'mono' ? 'selected' : ''}>Monospace</option>
+ </select>
+ </section>
+ </div>
+ `;
- // Mark active row
- itemListEl.querySelectorAll('.item-row').forEach(row => {
- const rowId = parseInt(row.getAttribute('data-id') || '0');
- row.classList.toggle('active', rowId === id);
- if (scroll && rowId === id) {
- row.scrollIntoView({ block: 'nearest' });
+ // Attach settings listeners
+ const themeOptions = document.getElementById('theme-options');
+ themeOptions?.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
}
});
- // Render basic detail
- itemDetailEl.innerHTML = `
- <article class="item-detail">
- <header>
- <h1><a href="${item.url}" target="_blank">${item.title}</a></h1>
- <div class="item-meta">
- From ${item.feed_title || 'Unknown'} on ${new Date(item.publish_date).toLocaleString()}
- </div>
- <div class="item-actions">
- <button onclick="window.app.toggleStar(${item._id})">${item.starred ? '★ Unstar' : '☆ Star'}</button>
- <button onclick="window.app.toggleRead(${item._id})">${item.read ? 'Unread' : 'Read'}</button>
- </div>
- </header>
- <div id="full-content" class="full-content">
- ${item.description || 'No description available.'}
- </div>
- </article>
- `;
+ const fontSelector = document.getElementById('font-selector') as HTMLSelectElement;
+ fontSelector?.addEventListener('change', () => {
+ store.setFontTheme(fontSelector.value);
+ });
+}
+
+// --- Data Actions ---
- // Mark as read if not already
- if (!item.read) {
- updateItem(item._id, { read: true });
+export async function toggleStar(id: number) {
+ const item = store.items.find(i => i._id === id);
+ if (item) {
+ updateItem(id, { starred: !item.starred });
}
+}
- // Fetch full content if missing
- if (item.url && (!item.full_content || item.full_content === item.description)) {
- try {
- const res = await apiFetch(`/api/item/${item._id}/content`);
- if (res.ok) {
- const data = await res.json();
- if (data.full_content) {
- item.full_content = data.full_content;
- const contentEl = document.getElementById('full-content');
- if (contentEl) contentEl.innerHTML = data.full_content;
- }
+export async function scrapeItem(id: number) {
+ const item = store.items.find(i => i._id === id);
+ if (!item) return;
+
+ try {
+ const res = await apiFetch(`/api/item/${id}/content`);
+ if (res.ok) {
+ const data = await res.json();
+ if (data.full_content) {
+ updateItem(id, { full_content: data.full_content });
}
- } catch (err) {
- console.error('Failed to fetch full content', err);
}
+ } catch (err) {
+ console.error('Failed to fetch full content', err);
}
}
-async function updateItem(id: number, updates: Partial<Item>) {
+export async function updateItem(id: number, updates: Partial<Item>) {
try {
const res = await apiFetch(`/api/item/${id}`, {
method: 'PUT',
@@ -227,15 +301,21 @@ async function updateItem(id: number, updates: Partial<Item>) {
const item = store.items.find(i => i._id === id);
if (item) {
Object.assign(item, updates);
- const row = itemListEl.querySelector(`.item-row[data-id="${id}"]`);
- if (row) {
- if (updates.read !== undefined) row.classList.toggle('read', updates.read);
- }
- // Update detail view if active
- if (activeItemId === id) {
- const starBtn = itemDetailEl.querySelector('.item-actions button');
- if (starBtn && updates.starred !== undefined) {
- starBtn.textContent = updates.starred ? '★ Unstar' : '☆ Star';
+ // Selective DOM update to avoid full re-render
+ const el = document.querySelector(`.feed-item[data-id="${id}"]`);
+ if (el) {
+ if (updates.read !== undefined) el.classList.toggle('read', updates.read);
+ if (updates.starred !== undefined) {
+ const starBtn = el.querySelector('.star-btn');
+ if (starBtn) {
+ starBtn.classList.toggle('is-starred', updates.starred);
+ starBtn.classList.toggle('is-unstarred', !updates.starred);
+ starBtn.setAttribute('title', updates.starred ? 'Unstar' : 'Star');
+ }
+ }
+ if (updates.full_content) {
+ // If full content was scraped, we might need to update description or re-render chunk
+ renderItems(); // Full re-render is safer for content injection
}
}
}
@@ -245,64 +325,29 @@ async function updateItem(id: number, updates: Partial<Item>) {
}
}
-function renderSettings() {
- viewTitleEl.textContent = 'Settings';
- itemListEl.innerHTML = '';
- itemDetailEl.innerHTML = `
- <div class="settings-view">
- <h2>Settings</h2>
- <section class="settings-section">
- <h3>Theme</h3>
- <div class="theme-options">
- <button class="${store.theme === 'light' ? 'active' : ''}" onclick="window.app.setTheme('light')">Light</button>
- <button class="${store.theme === 'dark' ? 'active' : ''}" onclick="window.app.setTheme('dark')">Dark</button>
- </div>
- </section>
- <section class="settings-section">
- <h3>Font</h3>
- <select onchange="window.app.setFontTheme(this.value)">
- <option value="default" ${store.fontTheme === 'default' ? 'selected' : ''}>Default</option>
- <option value="serif" ${store.fontTheme === 'serif' ? 'selected' : ''}>Serif</option>
- <option value="mono" ${store.fontTheme === 'mono' ? 'selected' : ''}>Monospace</option>
- </select>
- </section>
- </div>
- `;
-}
-
-// --- Data Actions ---
-
-async function fetchFeeds() {
- try {
- const res = await apiFetch('/api/feed/');
- if (!res.ok) throw new Error('Failed to fetch feeds');
+export async function fetchFeeds() {
+ const res = await apiFetch('/api/feed/');
+ if (res.ok) {
const feeds = await res.json();
store.setFeeds(feeds);
- } catch (err) {
- console.error(err);
}
}
-async function fetchTags() {
- try {
- const res = await apiFetch('/api/tag');
- if (!res.ok) throw new Error('Failed to fetch tags');
+export async function fetchTags() {
+ const res = await apiFetch('/api/tag');
+ if (res.ok) {
const tags = await res.json();
store.setTags(tags);
- } catch (err) {
- console.error(err);
}
}
-async function fetchItems(feedId?: string, tagName?: string, append: boolean = false) {
+export async function fetchItems(feedId?: string, tagName?: string, append: boolean = false) {
store.setLoading(true);
try {
- let url = '/api/stream';
const params = new URLSearchParams();
if (feedId) params.append('feed_id', feedId);
if (tagName) params.append('tag', tagName);
if (store.searchQuery) params.append('q', store.searchQuery);
-
if (store.filter === 'unread') params.append('read', 'false');
if (store.filter === 'starred') params.append('starred', 'true');
@@ -310,30 +355,27 @@ async function fetchItems(feedId?: string, tagName?: string, append: boolean = f
params.append('max_id', String(store.items[store.items.length - 1]._id));
}
- const res = await apiFetch(`${url}?${params.toString()}`);
- if (!res.ok) throw new Error('Failed to fetch items');
- const items = await res.json();
-
- store.setHasMore(items.length >= 50);
- store.setItems(items, append);
-
- if (!append) {
- activeItemId = null;
- itemDetailEl.innerHTML = '<div class="empty-state">Select an item to read</div>';
+ const res = await apiFetch(`/api/stream?${params.toString()}`);
+ if (res.ok) {
+ const items = await res.json();
+ store.setHasMore(items.length >= 50);
+ store.setItems(items, append);
}
- } catch (err) {
- console.error(err);
- if (!append) store.setItems([]);
} finally {
store.setLoading(false);
}
}
-async function loadMore() {
+export async function loadMore() {
const route = router.getCurrentRoute();
fetchItems(route.params.feedId, route.params.tagName, true);
}
+export async function logout() {
+ await apiFetch('/api/logout', { method: 'POST' });
+ window.location.href = '/login/';
+}
+
// --- App Logic ---
function handleRoute() {
@@ -357,17 +399,13 @@ function handleRoute() {
if (route.path === '/feed' && route.params.feedId) {
const id = parseInt(route.params.feedId);
store.setActiveFeed(id);
- const feed = store.feeds.find((f: Feed) => f._id === id);
- viewTitleEl.textContent = feed ? feed.title : `Feed ${id}`;
fetchItems(route.params.feedId);
} else if (route.path === '/tag' && route.params.tagName) {
store.setActiveTag(route.params.tagName);
- viewTitleEl.textContent = `Tag: ${route.params.tagName}`;
fetchItems(undefined, route.params.tagName);
} else {
store.setActiveFeed(null);
store.setActiveTag(null);
- viewTitleEl.textContent = 'All Items';
fetchItems();
}
}
@@ -407,7 +445,16 @@ function navigateItems(direction: number) {
let index = store.items.findIndex(i => i._id === activeItemId);
index += direction;
if (index >= 0 && index < store.items.length) {
- selectItem(store.items[index]._id, true);
+ activeItemId = store.items[index]._id;
+ const el = document.querySelector(`.feed-item[data-id="${activeItemId}"]`);
+ if (el) el.scrollIntoView({ block: 'nearest' });
+ // Optional: mark as read when keyboard navigating
+ if (!store.items[index].read) updateItem(activeItemId, { read: true });
+ // Since we are in 2-pane, we just scroll to it.
+ } else if (index === -1) {
+ activeItemId = store.items[0]._id;
+ const el = document.querySelector(`.feed-item[data-id="${activeItemId}"]`);
+ if (el) el.scrollIntoView({ block: 'nearest' });
}
}
@@ -428,7 +475,10 @@ store.on('search-updated', () => {
handleRoute();
});
store.on('theme-updated', () => {
- appEl.className = `theme-${store.theme} font-${store.fontTheme}`;
+ if (!appEl) appEl = document.querySelector<HTMLDivElement>('#app');
+ if (appEl) {
+ appEl.className = `theme-${store.theme} font-${store.fontTheme}`;
+ }
});
store.on('items-updated', renderItems);
@@ -437,40 +487,31 @@ store.on('loading-state-changed', renderItems);
// Subscribe to router
router.addEventListener('route-changed', handleRoute);
-// Global app object for inline handlers
+// Compatibility app object (empty handlers, since we use delegation)
window.app = {
- navigate: (path: string) => router.navigate(path),
- setFilter: (filter: FilterType) => router.updateQuery({ filter }),
- setSearch: (q: string) => {
- router.updateQuery({ q });
- },
- setTheme: (t: string) => store.setTheme(t),
- setFontTheme: (f: string) => store.setFontTheme(f),
- toggleStar: (id: number) => {
- const item = store.items.find(i => i._id === id);
- if (item) updateItem(id, { starred: !item.starred });
- },
- toggleRead: (id: number) => {
- const item = store.items.find(i => i._id === id);
- if (item) updateItem(id, { read: !item.read });
- },
- logout: async () => {
- await apiFetch('/api/logout', { method: 'POST' });
- window.location.href = '/login/';
- }
+ navigate: (path: string) => router.navigate(path)
};
// Start
-async function init() {
+// Start
+export async function init() {
const authRes = await apiFetch('/api/auth');
- if (authRes.status === 401) {
+ if (!authRes || authRes.status === 401) {
window.location.href = '/login/';
return;
}
+ renderLayout();
renderFilters();
- await Promise.all([fetchFeeds(), fetchTags()]);
- handleRoute(); // handles initial route
+ try {
+ await Promise.all([fetchFeeds(), fetchTags()]);
+ } catch (err) {
+ console.error('Initial fetch failed', err);
+ }
+ handleRoute();
}
-init();
+// Only auto-init if not in a test environment
+if (typeof window !== 'undefined' && !(window as any).__VITEST__) {
+ init();
+}
diff --git a/frontend-vanilla/src/router.test.ts b/frontend-vanilla/src/router.test.ts
index d79abc1..c206d9c 100644
--- a/frontend-vanilla/src/router.test.ts
+++ b/frontend-vanilla/src/router.test.ts
@@ -1,14 +1,28 @@
-import { describe, it, expect, vi } from 'vitest';
-import { router } from './router';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { Router } from './router';
describe('Router', () => {
+ let router: Router;
+
+ beforeEach(() => {
+ vi.stubGlobal('location', {
+ href: 'http://localhost/v3/',
+ pathname: '/v3/',
+ search: '',
+ origin: 'http://localhost'
+ });
+ vi.stubGlobal('history', {
+ pushState: vi.fn()
+ });
+ router = new Router();
+ });
+
it('should parse simple paths', () => {
- // Mock window.location
vi.stubGlobal('location', {
href: 'http://localhost/v3/feed/123',
- pathname: '/v3/feed/123'
+ pathname: '/v3/feed/123',
+ search: ''
});
-
const route = router.getCurrentRoute();
expect(route.path).toBe('/feed');
expect(route.params.feedId).toBe('123');
@@ -17,9 +31,9 @@ describe('Router', () => {
it('should parse tags correctly', () => {
vi.stubGlobal('location', {
href: 'http://localhost/v3/tag/Tech%20News',
- pathname: '/v3/tag/Tech%20News'
+ pathname: '/v3/tag/Tech%20News',
+ search: ''
});
-
const route = router.getCurrentRoute();
expect(route.path).toBe('/tag');
expect(route.params.tagName).toBe('Tech News');
@@ -28,10 +42,34 @@ describe('Router', () => {
it('should parse query parameters', () => {
vi.stubGlobal('location', {
href: 'http://localhost/v3/?filter=starred',
- pathname: '/v3/'
+ pathname: '/v3/',
+ search: '?filter=starred'
});
-
const route = router.getCurrentRoute();
expect(route.query.get('filter')).toBe('starred');
});
+
+ it('should navigate to new path', () => {
+ router.navigate('/settings');
+ // Match what the router actually does.
+ // If it uses new URL().pathname, it might be absolute.
+ expect(history.pushState).toHaveBeenCalled();
+ });
+
+ it('should update query parameters', () => {
+ router.updateQuery({ q: 'test' });
+ expect(history.pushState).toHaveBeenCalled();
+ const call = vi.mocked(history.pushState).mock.calls[0];
+ expect(call[2]).toContain('q=test');
+ });
+
+ it('should trigger event on popstate', () => {
+ const handler = vi.fn();
+ router.addEventListener('route-changed', handler);
+
+ // Simulate popstate
+ window.dispatchEvent(PopStateEvent.prototype instanceof PopStateEvent ? new PopStateEvent('popstate') : new Event('popstate'));
+
+ expect(handler).toHaveBeenCalled();
+ });
});
diff --git a/frontend-vanilla/src/store.test.ts b/frontend-vanilla/src/store.test.ts
index ccf9a1d..33deb7f 100644
--- a/frontend-vanilla/src/store.test.ts
+++ b/frontend-vanilla/src/store.test.ts
@@ -17,6 +17,20 @@ describe('Store', () => {
expect(callback).toHaveBeenCalled();
});
+ it('should handle tags', () => {
+ const store = new Store();
+ const mockTags = [{ title: 'Tag 1' } as any];
+ const callback = vi.fn();
+ store.on('tags-updated', callback);
+
+ store.setTags(mockTags);
+ expect(store.tags).toEqual(mockTags);
+ expect(callback).toHaveBeenCalled();
+
+ store.setActiveTag('Tag 1');
+ expect(store.activeTagName).toBe('Tag 1');
+ });
+
it('should handle items and loading state', () => {
const store = new Store();
const mockItems = [{ _id: 1, title: 'Item 1' } as any];
@@ -34,6 +48,20 @@ describe('Store', () => {
store.setItems(mockItems);
expect(store.items).toEqual(mockItems);
expect(itemCallback).toHaveBeenCalled();
+
+ // Test append
+ const moreItems = [{ _id: 2, title: 'Item 2' } as any];
+ store.setItems(moreItems, true);
+ expect(store.items).toHaveLength(2);
+ expect(store.items[1]._id).toBe(2);
+ });
+
+ it('should handle pagination state', () => {
+ const store = new Store();
+ store.setHasMore(true);
+ expect(store.hasMore).toBe(true);
+ store.setHasMore(false);
+ expect(store.hasMore).toBe(false);
});
it('should notify when active feed changes', () => {
@@ -66,4 +94,15 @@ describe('Store', () => {
expect(localStorage.getItem('neko-theme')).toBe('dark');
expect(callback).toHaveBeenCalled();
});
+
+ it('should handle font theme changes', () => {
+ const store = new Store();
+ const callback = vi.fn();
+ store.on('theme-updated', callback);
+
+ store.setFontTheme('serif');
+ expect(store.fontTheme).toBe('serif');
+ expect(localStorage.getItem('neko-font-theme')).toBe('serif');
+ expect(callback).toHaveBeenCalled();
+ });
});
diff --git a/frontend-vanilla/src/style.css b/frontend-vanilla/src/style.css
index c79fd3d..575be9d 100644
--- a/frontend-vanilla/src/style.css
+++ b/frontend-vanilla/src/style.css
@@ -1,40 +1,28 @@
:root {
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ /* Font Variables */
+ --font-body: Palatino, 'Palatino Linotype', 'Palatino LT STD', 'Book Antiqua', Georgia, serif;
+ --font-heading: 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ --font-sans: Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+
line-height: 1.5;
- font-weight: 400;
+ font-size: 18px;
- color-scheme: light dark;
+ /* Light Mode Defaults */
--bg-color: #ffffff;
- --text-color: #213547;
- --sidebar-bg: #f8f9fa;
- --border-color: #e9ecef;
+ --text-color: rgba(0, 0, 0, 0.87);
+ --sidebar-bg: #ccc;
+ --link-color: #0000ee;
+ --border-color: #999;
--accent-color: #007bff;
- --hover-color: #e2e6ea;
- --sidebar-width: 250px;
- --item-list-width: 350px;
-}
-
-.theme-dark {
- --bg-color: #1a1a1a;
- --text-color: #e9ecef;
- --sidebar-bg: #212529;
- --border-color: #343a40;
- --accent-color: #375a7f;
- --hover-color: #2c3034;
-}
-
-.font-serif {
- font-family: Georgia, serif;
-}
-.font-mono {
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ color-scheme: light dark;
}
body {
margin: 0;
- color: var(--text-color);
+ font-family: var(--font-body);
background-color: var(--bg-color);
+ color: var(--text-color);
height: 100vh;
overflow: hidden;
}
@@ -46,59 +34,67 @@ body {
.layout {
display: flex;
height: 100%;
+ width: 100%;
}
-/* Sidebar */
+/* Sidebar - glassmorphism by default */
.sidebar {
- width: var(--sidebar-width);
- background-color: var(--sidebar-bg);
- border-right: 1px solid var(--border-color);
+ width: 14rem;
+ background: rgba(255, 255, 255, 0.05);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ border-right: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
+ height: 100%;
+ overflow: hidden;
+ z-index: 100;
+ padding: 1.5rem;
}
-.sidebar-header {
- padding: 1rem;
- border-bottom: 1px solid var(--border-color);
+.theme-dark .sidebar {
+ background: rgba(0, 0, 0, 0.2);
+ border-right-color: rgba(255, 255, 255, 0.05);
}
.sidebar-header h2 {
- margin: 0;
- font-size: 1.1rem;
+ font-family: var(--font-heading);
+ font-size: 1.5rem;
+ margin: 0 0 2rem 0;
+ opacity: 0.8;
+ cursor: pointer;
}
.sidebar-search {
- padding: 0.75rem 1rem;
- border-bottom: 1px solid var(--border-color);
+ margin-bottom: 2rem;
}
.sidebar-search input {
width: 100%;
- padding: 0.4rem 0.6rem;
- background-color: var(--bg-color);
- border: 1px solid var(--border-color);
- border-radius: 4px;
+ border-radius: 20px;
+ background: rgba(0, 0, 0, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--text-color);
- font-size: 0.85rem;
+ padding: 0.5rem 1rem;
+ font-size: 0.9rem;
}
.sidebar-scroll {
flex: 1;
overflow-y: auto;
- padding: 1rem 0;
-}
-
-.sidebar-section {
- margin-bottom: 2rem;
+ margin: 0 -1.5rem;
+ padding: 0 1.5rem;
}
.sidebar-section h3 {
- padding: 0 1rem;
- font-size: 0.7rem;
+ font-family: var(--font-heading);
+ font-size: 0.75rem;
text-transform: uppercase;
- color: #888;
- margin: 0 0 0.5rem 0;
- letter-spacing: 0.05rem;
+ letter-spacing: 0.1em;
+ opacity: 0.5;
+ margin-top: 2rem;
+ margin-bottom: 0.5rem;
+ font-weight: 600;
}
.sidebar-section ul {
@@ -109,206 +105,200 @@ body {
.sidebar-section li a {
display: block;
- padding: 0.4rem 1rem;
+ padding: 0.4rem 0.8rem;
+ margin: 0.2rem 0;
+ border-radius: 8px;
+ transition: all 0.2s ease;
+ font-weight: 500;
text-decoration: none;
color: var(--text-color);
- font-size: 0.9rem;
+ opacity: 0.8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
-.sidebar-section li:hover {
- background-color: var(--hover-color);
-}
-
-.sidebar-section li.active {
- background-color: var(--hover-color);
- font-weight: bold;
+.sidebar-section li a:hover {
+ background: rgba(255, 255, 255, 0.1);
+ opacity: 1;
+ transform: translateX(4px);
}
.sidebar-section li.active a {
- color: var(--accent-color);
+ background: rgba(255, 255, 255, 0.25);
+ opacity: 1;
+ font-weight: 700;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ border: 1px solid rgba(255, 255, 255, 0.2);
}
.sidebar-footer {
- padding: 1rem;
- border-top: 1px solid var(--border-color);
+ margin-top: auto;
+ padding-top: 1.5rem;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
- justify-content: space-between;
- font-size: 0.85rem;
+ flex-direction: column;
+ gap: 0.5rem;
}
.sidebar-footer a {
- color: var(--text-color);
+ opacity: 0.6;
+ padding: 0.5rem 0.8rem;
+ border-radius: 8px;
text-decoration: none;
- opacity: 0.7;
+ color: var(--text-color);
+ font-size: 0.9rem;
+ font-family: var(--font-heading);
}
.sidebar-footer a:hover {
+ background: rgba(255, 255, 255, 0.05);
opacity: 1;
}
-/* Item List Pane */
-.item-list-pane {
- width: var(--item-list-width);
- border-right: 1px solid var(--border-color);
- display: flex;
- flex-direction: column;
- background-color: var(--bg-color);
-}
-
-.top-bar {
- padding: 0.75rem 1rem;
- border-bottom: 1px solid var(--border-color);
+/* Main Content area */
+.main-content {
+ flex: 1;
+ min-width: 0;
+ overflow-y: auto;
background-color: var(--bg-color);
- height: 40px;
- display: flex;
- align-items: center;
-}
-
-.top-bar h1 {
- margin: 0;
- font-size: 0.95rem;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
+ padding: 2rem;
}
-.item-list-container {
- flex: 1;
- overflow-y: auto;
+.main-content>* {
+ max-width: 35em;
+ margin: 0 auto;
}
+/* Feed Items Styles (from v2) */
.item-list {
list-style: none;
padding: 0;
margin: 0;
}
-.item-row {
- padding: 0.75rem 1rem;
- border-bottom: 1px solid var(--border-color);
- cursor: pointer;
- transition: background 0.1s;
-}
-
-.item-row:hover {
- background-color: var(--hover-color);
-}
-
-.item-row.active {
- background-color: var(--hover-color);
- border-left: 3px solid var(--accent-color);
+.feed-item {
+ padding: 1rem 0;
+ margin-top: 5rem;
+ border-bottom: none;
}
-.item-row.read {
- opacity: 0.6;
+.item-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 0.5rem;
}
.item-title {
- font-weight: 600;
- font-size: 0.9rem;
- margin-bottom: 0.2rem;
- line-height: 1.3;
-}
-
-.item-meta {
- font-size: 0.75rem;
- color: #888;
+ font-family: var(--font-heading);
+ font-size: 1.8rem;
+ font-weight: bold;
+ text-decoration: none;
+ color: var(--link-color);
+ display: block;
+ flex: 1;
+ cursor: pointer;
}
-.load-more {
- padding: 1.5rem;
- text-align: center;
- color: #888;
- font-size: 0.85rem;
+.item-title:hover {
+ text-decoration: underline;
}
-/* Item Detail Pane */
-.item-detail-pane {
- flex: 1;
- overflow-y: auto;
- background-color: var(--bg-color);
+.star-btn {
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: 1.25rem;
+ padding: 0 0 0 0.5rem;
+ vertical-align: middle;
+ transition: color 0.2s;
+ line-height: 1;
}
-.item-detail-content {
- max-width: 700px;
- margin: 0 auto;
- padding: 2rem;
+.star-btn.is-starred {
+ color: blue;
}
-.item-detail header {
- margin-bottom: 2rem;
- border-bottom: 1px solid var(--border-color);
- padding-bottom: 1.5rem;
+.star-btn.is-unstarred {
+ color: var(--text-color);
+ opacity: 0.3;
}
-.item-detail h1 {
- font-size: 1.75rem;
- margin: 0 0 0.75rem 0;
- line-height: 1.2;
+.dateline {
+ margin-top: 0;
+ font-weight: normal;
+ font-size: 0.75em;
+ color: #ccc;
+ margin-bottom: 1rem;
}
-.item-detail h1 a {
- color: var(--text-color);
+.dateline a {
+ color: #ccc;
text-decoration: none;
}
-.item-detail h1 a:hover {
- text-decoration: underline;
-}
-
-.item-actions {
- display: flex;
- gap: 0.5rem;
+.item-description {
+ color: var(--text-color);
+ line-height: 1.5;
+ font-size: 1rem;
margin-top: 1rem;
+ overflow-wrap: break-word;
+ word-break: break-word;
}
-.item-actions button {
- padding: 0.3rem 0.6rem;
- font-size: 0.8rem;
- cursor: pointer;
- background-color: var(--bg-color);
- border: 1px solid var(--border-color);
- color: var(--text-color);
- border-radius: 4px;
+.item-description img {
+ max-width: 100%;
+ height: auto;
+ display: block;
+ margin: 1rem 0;
}
-.item-actions button:hover {
- background-color: var(--hover-color);
+.scrape-btn {
+ background: var(--bg-color);
+ border: 1px solid var(--border-color, #ccc);
+ color: blue;
+ cursor: pointer;
+ font-family: var(--font-heading);
+ font-weight: bold;
+ font-size: 0.8rem;
+ padding: 2px 6px;
+ margin-left: 0.5rem;
}
-.full-content {
- font-size: 1.1rem;
- line-height: 1.7;
+/* Themes */
+.theme-dark {
+ --bg-color: #000000;
+ --text-color: #ffffff;
+ --sidebar-bg: #111111;
+ --link-color: rgb(90, 200, 250);
+ --border-color: #333;
}
-.full-content img {
- max-width: 100%;
- height: auto;
- display: block;
- margin: 1.5rem 0;
- border-radius: 4px;
+.font-serif {
+ --font-body: Georgia, 'Times New Roman', Times, serif;
+ font-family: var(--font-body);
}
-.full-content a {
- color: var(--accent-color);
+.font-mono {
+ --font-body: Menlo, Monaco, Consolas, 'Courier New', monospace;
+ font-family: var(--font-body);
}
+/* Settings View */
.settings-view {
- padding: 2rem;
+ padding-top: 2rem;
}
.settings-section {
- margin-bottom: 2rem;
+ margin-bottom: 2.5rem;
}
.settings-section h3 {
- font-size: 1rem;
- margin-bottom: 1rem;
+ font-family: var(--font-heading);
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.5rem;
+ margin-bottom: 1rem;
}
.theme-options {
@@ -316,23 +306,37 @@ body {
gap: 1rem;
}
-.theme-options button.active {
+button {
+ border-radius: 8px;
+ border: 1px solid var(--border-color);
+ padding: 0.6em 1.2em;
+ font-size: 1em;
+ font-weight: bold;
+ font-family: inherit;
+ background-color: #f9f9f9;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.theme-dark button {
+ background-color: #1a1a1a;
+ color: #fff;
+ border-color: #333;
+}
+
+button.active {
border-color: var(--accent-color);
- background-color: var(--hover-color);
+ background-color: #eef;
}
-.empty-state {
- display: flex;
- align-items: center;
- justify-content: center;
- height: 100%;
- color: #888;
- font-size: 1.1rem;
+.theme-dark button.active {
+ background-color: #224;
+ border-color: var(--accent-color);
}
-.loading,
-.empty {
- padding: 2rem;
- text-align: center;
- color: #888;
+@media (max-width: 768px) {
+ .sidebar {
+ display: none;
+ /* Mobile sidebar will need to be handled later */
+ }
} \ No newline at end of file