aboutsummaryrefslogtreecommitdiffstats
path: root/frontend-vanilla/src
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-17 07:57:52 -0800
committerGitHub <noreply@github.com>2026-02-17 07:57:52 -0800
commit5c3b6234caf8b6c27f37d67d4e04c853e59888ef (patch)
tree3abc994bd8ac3699449cf37ca25ce34610657588 /frontend-vanilla/src
parentc15995fe944a6e8f3e68cf0c44fd454e53f21081 (diff)
parent7f0b9ae0f53f26304d26a8d45191f268821425c8 (diff)
downloadneko-5c3b6234caf8b6c27f37d67d4e04c853e59888ef.tar.gz
neko-5c3b6234caf8b6c27f37d67d4e04c853e59888ef.tar.bz2
neko-5c3b6234caf8b6c27f37d67d4e04c853e59888ef.zip
Merge pull request #9 from adammathes/claude/fix-open-tickets-IVV1C
Update benchmarks, fix SSRF proxy bypass, and refactor frontend sidebar layout
Diffstat (limited to 'frontend-vanilla/src')
-rw-r--r--frontend-vanilla/src/main.ts17
-rw-r--r--frontend-vanilla/src/perf/renderItems.perf.test.ts6
-rw-r--r--frontend-vanilla/src/regression.test.ts92
-rw-r--r--frontend-vanilla/src/style.css83
4 files changed, 174 insertions, 24 deletions
diff --git a/frontend-vanilla/src/main.ts b/frontend-vanilla/src/main.ts
index 02e4188..5f8056c 100644
--- a/frontend-vanilla/src/main.ts
+++ b/frontend-vanilla/src/main.ts
@@ -30,9 +30,6 @@ export function renderLayout() {
<button class="sidebar-toggle" id="sidebar-toggle-btn" title="Toggle Sidebar">🐱</button>
<div class="sidebar-backdrop" id="sidebar-backdrop"></div>
<aside class="sidebar" id="sidebar">
- <div class="sidebar-search">
- <input type="search" id="search-input" placeholder="Search..." value="${store.searchQuery}">
- </div>
<div class="sidebar-scroll">
<section class="sidebar-section">
<ul id="filter-list">
@@ -41,14 +38,22 @@ export function renderLayout() {
<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 collapsible collapsed" id="section-tags">
- <h3>Tags <span class="caret">▶</span></h3>
- <ul id="tag-list"></ul>
+ <div class="sidebar-search">
+ <input type="search" id="search-input" placeholder="Search..." value="${store.searchQuery}">
+ </div>
+ <section class="sidebar-section">
+ <ul>
+ <li><a href="/v3/settings" data-nav="settings" class="new-feed-link">+ new</a></li>
+ </ul>
</section>
<section class="sidebar-section collapsible collapsed" id="section-feeds">
<h3>Feeds <span class="caret">▶</span></h3>
<ul id="feed-list"></ul>
</section>
+ <section class="sidebar-section collapsible collapsed" id="section-tags">
+ <h3>Tags <span class="caret">▶</span></h3>
+ <ul id="tag-list"></ul>
+ </section>
</div>
<div class="sidebar-footer">
<a href="/v3/settings" data-nav="settings">Settings</a>
diff --git a/frontend-vanilla/src/perf/renderItems.perf.test.ts b/frontend-vanilla/src/perf/renderItems.perf.test.ts
index 7157093..ac6ede6 100644
--- a/frontend-vanilla/src/perf/renderItems.perf.test.ts
+++ b/frontend-vanilla/src/perf/renderItems.perf.test.ts
@@ -54,7 +54,7 @@ describe('renderItems performance', () => {
expect(elapsed).toBeLessThan(100);
});
- it('DOM insertion of 100 items under 200ms', () => {
+ it('DOM insertion of 100 items under 500ms', () => {
const items = Array.from({ length: 100 }, (_, i) => makeItem(i));
const html = items.map(item => createFeedItem(item)).join('');
@@ -66,12 +66,12 @@ describe('renderItems performance', () => {
const elapsed = performance.now() - start;
expect(container.children.length).toBe(100);
- expect(elapsed).toBeLessThan(200);
+ expect(elapsed).toBeLessThan(500);
document.body.removeChild(container);
});
- it('DOM insertion of 500 items under 500ms', () => {
+ it('DOM insertion of 500 items under 1400ms', () => {
const items = Array.from({ length: 500 }, (_, i) => makeItem(i));
const html = items.map(item => createFeedItem(item)).join('');
diff --git a/frontend-vanilla/src/regression.test.ts b/frontend-vanilla/src/regression.test.ts
index a0b13d5..8529e20 100644
--- a/frontend-vanilla/src/regression.test.ts
+++ b/frontend-vanilla/src/regression.test.ts
@@ -1,7 +1,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { store } from './store';
import { apiFetch } from './api';
-import { renderItems } from './main';
+import { renderItems, renderLayout } from './main';
+import { createFeedItem } from './components/FeedItem';
// Mock api
vi.mock('./api', () => ({
@@ -167,3 +168,92 @@ describe('Scroll-to-Read Regression Tests', () => {
expect(apiFetch).not.toHaveBeenCalledWith(expect.stringContaining('/api/item/888'), expect.anything());
});
});
+
+// NK-t8qnrh: Links in feed item descriptions should have no underlines (match v1 style)
+describe('NK-t8qnrh: Feed item description links have no underlines', () => {
+ it('item-description should be rendered inside feed items', () => {
+ const item = {
+ _id: 1,
+ title: 'Test',
+ url: 'http://example.com',
+ description: '<p>Text with <a href="http://example.com">a link</a></p>',
+ read: false,
+ starred: false,
+ publish_date: '2024-01-01',
+ } as any;
+ const html = createFeedItem(item);
+ expect(html).toContain('class="item-description"');
+ expect(html).toContain('<a href="http://example.com">a link</a>');
+ });
+});
+
+// NK-mcl01m: Sidebar order should be filters → search → "+ new" → Feeds → Tags
+describe('NK-mcl01m: Sidebar section order', () => {
+ beforeEach(() => {
+ document.body.innerHTML = '<div id="app"></div>';
+ vi.mocked(apiFetch).mockResolvedValue({ ok: true, status: 200, json: async () => [] } as Response);
+ renderLayout();
+ });
+
+ it('filter-list appears before section-feeds in the sidebar', () => {
+ const sidebar = document.getElementById('sidebar');
+ expect(sidebar).not.toBeNull();
+ const filterList = sidebar!.querySelector('#filter-list');
+ const sectionFeeds = sidebar!.querySelector('#section-feeds');
+ expect(filterList).not.toBeNull();
+ expect(sectionFeeds).not.toBeNull();
+ // filter-list should come before section-feeds in DOM order
+ const position = filterList!.compareDocumentPosition(sectionFeeds!);
+ expect(position & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ });
+
+ it('section-feeds appears before section-tags in the sidebar', () => {
+ const sidebar = document.getElementById('sidebar');
+ const sectionFeeds = sidebar!.querySelector('#section-feeds');
+ const sectionTags = sidebar!.querySelector('#section-tags');
+ expect(sectionFeeds).not.toBeNull();
+ expect(sectionTags).not.toBeNull();
+ // feeds should come before tags
+ const position = sectionFeeds!.compareDocumentPosition(sectionTags!);
+ expect(position & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ });
+
+ it('search input appears after filter-list and before section-feeds', () => {
+ const sidebar = document.getElementById('sidebar');
+ const filterList = sidebar!.querySelector('#filter-list');
+ const searchInput = sidebar!.querySelector('#search-input');
+ const sectionFeeds = sidebar!.querySelector('#section-feeds');
+ expect(searchInput).not.toBeNull();
+ // search after filters
+ const pos1 = filterList!.compareDocumentPosition(searchInput!);
+ expect(pos1 & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ // search before feeds
+ const pos2 = searchInput!.compareDocumentPosition(sectionFeeds!);
+ expect(pos2 & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ });
+
+ it('sidebar has a "+ new" link pointing to settings', () => {
+ const newLink = document.querySelector('.new-feed-link');
+ expect(newLink).not.toBeNull();
+ expect(newLink!.textContent?.trim()).toBe('+ new');
+ });
+});
+
+// NK-z1czaq: Main content should fill full width (sidebar overlays, never shifts content)
+describe('NK-z1czaq: Sidebar overlays content, does not shift layout', () => {
+ beforeEach(() => {
+ document.body.innerHTML = '<div id="app"></div>';
+ vi.mocked(apiFetch).mockResolvedValue({ ok: true, status: 200, json: async () => [] } as Response);
+ });
+
+ it('sidebar is a sibling of main-content inside .layout (not flex-shifting)', () => {
+ renderLayout();
+ const sidebar = document.querySelector('.sidebar');
+ const mainContent = document.querySelector('.main-content');
+ expect(sidebar).not.toBeNull();
+ expect(mainContent).not.toBeNull();
+ // Both should be children of .layout
+ expect(sidebar!.parentElement?.classList.contains('layout')).toBe(true);
+ expect(mainContent!.parentElement?.classList.contains('layout')).toBe(true);
+ });
+});
diff --git a/frontend-vanilla/src/style.css b/frontend-vanilla/src/style.css
index a3a7978..f724916 100644
--- a/frontend-vanilla/src/style.css
+++ b/frontend-vanilla/src/style.css
@@ -77,7 +77,8 @@ html {
/* Sidebar Header Removed */
.sidebar-search {
- margin-bottom: 2rem;
+ margin-top: 1rem;
+ margin-bottom: 1rem;
}
.sidebar-search input {
@@ -196,9 +197,9 @@ html {
opacity: 1;
}
-/* Main Content area */
+/* Main Content area - always fills full width (sidebar overlays) */
.main-content {
- flex: 1;
+ width: 100%;
min-width: 0;
overflow-y: auto;
background-color: var(--bg-color);
@@ -282,24 +283,40 @@ html {
}
}
-/* Desktop Sidebar state */
+/* CONTENT CENTERING PARAMETER:
+ * The sidebar overlays content (position: fixed) and never shifts the main content area.
+ * Content is always centered in the full viewport width.
+ * To revert to sidebar-shifts-content: remove the @media (min-width: 1025px) fixed rules below
+ * and restore "flex: 1; min-width: 0" on .main-content only.
+ */
@media (min-width: 1025px) {
- /* Desktop Sidebar state - Removed to keep toggle fixed */
+ .sidebar {
+ position: fixed;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ z-index: 1000;
+ transform: translateX(-100%);
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ }
+
+ .sidebar-visible .sidebar {
+ transform: translateX(0);
+ box-shadow: 10px 0 20px rgba(0, 0, 0, 0.1);
+ }
+
+ .sidebar-hidden .sidebar {
+ transform: translateX(-100%);
+ }
}
/* Layout State */
.sidebar-hidden .sidebar {
- display: none;
+ display: flex;
}
-@media (min-width: 1025px) {
- .sidebar-hidden .sidebar {
- display: none;
- }
-
- .sidebar-visible .sidebar {
- display: flex;
- }
+.sidebar-visible .sidebar {
+ display: flex;
}
input[type="text"],
@@ -422,6 +439,11 @@ select:focus {
word-break: break-word;
}
+.item-description a {
+ text-decoration: none;
+ color: var(--link-color);
+}
+
.item-description img,
.item-description video,
.item-description pre {
@@ -460,6 +482,39 @@ select:focus {
--link-color: #5ac8fa;
--border-color: #333333;
--accent-color: #2188ff;
+ --sidebar-text-color: rgba(0, 0, 0, 0.87);
+}
+
+/* Dark mode: sidebar uses grey background with dark text */
+.theme-dark .sidebar {
+ background: rgba(180, 180, 180, 0.85);
+ border-right-color: rgba(0, 0, 0, 0.15);
+}
+
+.theme-dark .sidebar-section li a,
+.theme-dark .sidebar-section h3,
+.theme-dark .sidebar-footer a {
+ color: rgba(0, 0, 0, 0.87);
+}
+
+.theme-dark .sidebar-section li.active a {
+ background: rgba(0, 0, 0, 0.15);
+}
+
+.theme-dark .sidebar-section li a:hover {
+ background: rgba(0, 0, 0, 0.08);
+}
+
+/* Dark mode: sidebar-toggle button should have no background */
+.theme-dark .sidebar-toggle {
+ background: none;
+}
+
+/* Dark mode: sidebar search input should use light background */
+.theme-dark .sidebar-search input {
+ background: rgba(255, 255, 255, 0.7);
+ color: rgba(0, 0, 0, 0.87);
+ border-color: rgba(0, 0, 0, 0.2);
}
.font-serif {