aboutsummaryrefslogtreecommitdiffstats
path: root/frontend-vanilla/src/main.ts
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-15 19:36:03 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-15 19:36:03 -0800
commit59743dcaa87920a5125915454e0afa0a22b05ee7 (patch)
tree11666ff75c615face8dee0f479d0b3c7c79bb187 /frontend-vanilla/src/main.ts
parenta113bc13e569049c59baa2165d28a992d7bdde7b (diff)
downloadneko-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/src/main.ts')
-rw-r--r--frontend-vanilla/src/main.ts461
1 files changed, 251 insertions, 210 deletions
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();
+}