diff options
| author | Adam Mathes <adam@adammathes.com> | 2026-02-15 19:36:03 -0800 |
|---|---|---|
| committer | Adam Mathes <adam@adammathes.com> | 2026-02-15 19:36:03 -0800 |
| commit | 59743dcaa87920a5125915454e0afa0a22b05ee7 (patch) | |
| tree | 11666ff75c615face8dee0f479d0b3c7c79bb187 /frontend-vanilla | |
| parent | a113bc13e569049c59baa2165d28a992d7bdde7b (diff) | |
| download | neko-59743dcaa87920a5125915454e0afa0a22b05ee7.tar.gz neko-59743dcaa87920a5125915454e0afa0a22b05ee7.tar.bz2 neko-59743dcaa87920a5125915454e0afa0a22b05ee7.zip | |
Vanilla JS (v3): Redesign to 2-pane glassmorphism, fix CSP errors, fix Settings view, and achieve 80% test coverage
Diffstat (limited to 'frontend-vanilla')
| -rw-r--r-- | frontend-vanilla/.gitignore | 1 | ||||
| -rw-r--r-- | frontend-vanilla/package-lock.json | 214 | ||||
| -rw-r--r-- | frontend-vanilla/package.json | 3 | ||||
| -rw-r--r-- | frontend-vanilla/src/api.test.ts | 45 | ||||
| -rw-r--r-- | frontend-vanilla/src/components/FeedItem.test.ts | 38 | ||||
| -rw-r--r-- | frontend-vanilla/src/components/FeedItem.ts | 38 | ||||
| -rw-r--r-- | frontend-vanilla/src/main.test.ts | 249 | ||||
| -rw-r--r-- | frontend-vanilla/src/main.ts | 461 | ||||
| -rw-r--r-- | frontend-vanilla/src/router.test.ts | 56 | ||||
| -rw-r--r-- | frontend-vanilla/src/store.test.ts | 39 | ||||
| -rw-r--r-- | frontend-vanilla/src/style.css | 382 |
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 |
