diff options
| author | Adam Mathes <adam@adammathes.com> | 2026-02-16 10:53:59 -0800 |
|---|---|---|
| committer | Adam Mathes <adam@trenchant.org> | 2026-02-16 10:57:38 -0800 |
| commit | 5cf8275540d7162cd4936a7c0e76dbfe7f66b62c (patch) | |
| tree | fdb3c47560f1b1556f7c203f72d50d13f171c95a /frontend-vanilla | |
| parent | 96e78c5fdfada73d37644083c7580a1d444ed748 (diff) | |
| download | neko-5cf8275540d7162cd4936a7c0e76dbfe7f66b62c.tar.gz neko-5cf8275540d7162cd4936a7c0e76dbfe7f66b62c.tar.bz2 neko-5cf8275540d7162cd4936a7c0e76dbfe7f66b62c.zip | |
V3 UI Polish: Improved keyboard navigation, fixed logo position, and updated branding
- Fix V3 keyboard navigation delay (resolved NK-wjats7)
- Update V3 document title to 'neko' (resolved NK-4p3s91)
- Fix V3 neko logo/button position to be top-left fixed (resolved NK-89za3s)
- Improve FeedItems (React) stability with ref-based index tracking and robust tests
- Sync V3 styling and selection feedback with V2 patterns
- Rebuild production assets
Diffstat (limited to 'frontend-vanilla')
| -rw-r--r-- | frontend-vanilla/index.html | 2 | ||||
| -rw-r--r-- | frontend-vanilla/src/components/FeedItem.ts | 4 | ||||
| -rw-r--r-- | frontend-vanilla/src/main.test.ts | 21 | ||||
| -rw-r--r-- | frontend-vanilla/src/main.ts | 43 | ||||
| -rw-r--r-- | frontend-vanilla/src/style.css | 17 |
5 files changed, 68 insertions, 19 deletions
diff --git a/frontend-vanilla/index.html b/frontend-vanilla/index.html index 0038daa..27d91c9 100644 --- a/frontend-vanilla/index.html +++ b/frontend-vanilla/index.html @@ -4,7 +4,7 @@ <meta charset="UTF-8" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <title>frontend-vanilla</title> + <title>neko</title> </head> <body> <div id="app"></div> diff --git a/frontend-vanilla/src/components/FeedItem.ts b/frontend-vanilla/src/components/FeedItem.ts index e58aac8..212e9dd 100644 --- a/frontend-vanilla/src/components/FeedItem.ts +++ b/frontend-vanilla/src/components/FeedItem.ts @@ -1,9 +1,9 @@ import type { Item } from '../types'; -export function createFeedItem(item: Item): string { +export function createFeedItem(item: Item, isSelected: boolean = false): string { const date = new Date(item.publish_date).toLocaleDateString(); return ` - <li class="feed-item ${item.read ? 'read' : 'unread'}" data-id="${item._id}"> + <li class="feed-item ${item.read ? 'read' : 'unread'} ${isSelected ? 'selected' : ''}" 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)'} diff --git a/frontend-vanilla/src/main.test.ts b/frontend-vanilla/src/main.test.ts index c9d0e0c..5bf9fe0 100644 --- a/frontend-vanilla/src/main.test.ts +++ b/frontend-vanilla/src/main.test.ts @@ -347,4 +347,25 @@ describe('main application logic', () => { expect(manageSection?.innerHTML).toContain('My Feed'); expect(document.querySelector('.feed-tag-input')).not.toBeNull(); }); + + it('should navigate items with j/k keys', () => { + store.setItems([ + { _id: 101, title: 'Item 1', publish_date: '2023-01-01', read: false } as any, + { _id: 102, title: 'Item 2', publish_date: '2023-01-02', read: false } as any + ]); + renderLayout(); + renderItems(); + + // 1st press 'j' -> index 0 + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'j' })); + expect(document.querySelector('.feed-item[data-id="101"]')?.classList.contains('selected')).toBe(true); + + // 2nd press 'j' -> index 1 + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'j' })); + expect(document.querySelector('.feed-item[data-id="102"]')?.classList.contains('selected')).toBe(true); + + // Press 'k' -> back to index 0 + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k' })); + expect(document.querySelector('.feed-item[data-id="101"]')?.classList.contains('selected')).toBe(true); + }); }); diff --git a/frontend-vanilla/src/main.ts b/frontend-vanilla/src/main.ts index 3f53fc2..7b55c48 100644 --- a/frontend-vanilla/src/main.ts +++ b/frontend-vanilla/src/main.ts @@ -185,6 +185,14 @@ export function attachLayoutListeners() { const itemRow = target.closest('.feed-item'); if (itemRow && !itemTitle) { // Clicking the row itself (but not the link) const id = parseInt(itemRow.getAttribute('data-id')!); + activeItemId = id; + + // Update visual selection + document.querySelectorAll('.feed-item').forEach(el => { + const itemId = parseInt(el.getAttribute('data-id') || '0'); + el.classList.toggle('selected', itemId === activeItemId); + }); + const item = store.items.find(i => i._id === id); if (item && !item.read) { updateItem(id, { read: true }); @@ -252,7 +260,7 @@ export function renderItems() { contentArea.innerHTML = ` <ul class="item-list"> - ${items.map((item: Item) => createFeedItem(item)).join('')} + ${items.map((item: Item) => createFeedItem(item, item._id === activeItemId)).join('')} </ul> ${store.hasMore ? '<div id="load-more-sentinel" class="loading-more">Loading more...</div>' : ''} `; @@ -682,19 +690,28 @@ window.addEventListener('keydown', (e) => { function navigateItems(direction: number) { if (store.items.length === 0) return; - let index = store.items.findIndex(i => i._id === activeItemId); - index += direction; - if (index >= 0 && index < store.items.length) { - 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 currentIndex = store.items.findIndex(i => i._id === activeItemId); + let nextIndex; + + if (currentIndex === -1) { + nextIndex = direction > 0 ? 0 : store.items.length - 1; + } else { + nextIndex = currentIndex + direction; + } + + if (nextIndex >= 0 && nextIndex < store.items.length) { + activeItemId = store.items[nextIndex]._id; + + // Update visual selection without full re-render for speed + document.querySelectorAll('.feed-item').forEach(el => { + const id = parseInt(el.getAttribute('data-id') || '0'); + el.classList.toggle('selected', id === activeItemId); + }); + const el = document.querySelector(`.feed-item[data-id="${activeItemId}"]`); - if (el) el.scrollIntoView({ block: 'nearest' }); + if (el) el.scrollIntoView({ block: 'start', behavior: 'smooth' }); + + if (!store.items[nextIndex].read) updateItem(activeItemId, { read: true }); } } diff --git a/frontend-vanilla/src/style.css b/frontend-vanilla/src/style.css index 0076e58..37ba761 100644 --- a/frontend-vanilla/src/style.css +++ b/frontend-vanilla/src/style.css @@ -348,9 +348,20 @@ select:focus { } .feed-item { - padding: 1rem 0; - margin-top: 5rem; + padding: 1rem 0.5rem; + margin-top: 2rem; border-bottom: none; + border-radius: 8px; + transition: background-color 0.2s ease; +} + +.feed-item.selected { + background-color: rgba(0, 123, 255, 0.05); + box-shadow: inset 4px 0 0 var(--accent-color); +} + +.theme-dark .feed-item.selected { + background-color: rgba(33, 136, 255, 0.1); } .item-header { @@ -452,7 +463,7 @@ select:focus { --bg-color: #24292e; --text-color: #ffffff; --sidebar-bg: #1b1f23; - --link-color: rgb(90, 200, 250); + --link-color: #5ac8fa; --border-color: #444d56; --accent-color: #2188ff; } |
