refactor: implement new UI/UX architecture with redesigned desktop and mobile layouts, CSS-in-CSS modularization, and updated components.

This commit is contained in:
2026-08-29 00:53:57 -07:00
parent dfbf646332
commit cd98c0120e
47 changed files with 8039 additions and 7236 deletions
+272 -323
View File
@@ -1,20 +1,20 @@
import React, { useState, useEffect, useCallback } from 'react';
import { api } from './services/api';
import { Category, Feed, FeedEntry, FilterState, Stats, User, ViewMode } from './types';
import { LoginView } from './components/LoginView';
import { Header } from './components/Header';
import { ThreePaneView } from './components/ThreePaneView';
import { WaterfallView } from './components/WaterfallView';
import { SettingsView } from './components/SettingsView';
import { AddFeedModal } from './components/AddFeedModal';
import { OPMLModal } from './components/OPMLModal';
import { KeyboardShortcutsModal } from './components/KeyboardShortcutsModal';
import { Category, Feed, FeedEntry, FilterState, Stats, User, ViewMode, ViewPreference } from './types';
import { LoginView } from './components/common/LoginView';
import { Header } from './components/common/Header';
import { ThreePaneView } from './components/desktop/ThreePaneView';
import { WaterfallView } from './components/mobile/WaterfallView';
import { SettingsView } from './components/settings/SettingsView';
import { AddFeedModal } from './components/common/AddFeedModal';
import { OPMLModal } from './components/common/OPMLModal';
import { KeyboardShortcutsModal } from './components/common/KeyboardShortcutsModal';
export const App: React.FC = () => {
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [authLoading, setAuthLoading] = useState(true);
// App State with localStorage Persistence
// App Filter State with Persistence
const [filter, setFilter] = useState<FilterState>(() => {
const saved = localStorage.getItem('rsspipe_filter');
if (saved) {
@@ -27,49 +27,82 @@ export const App: React.FC = () => {
return { view: 'inbox', search: '' };
});
const [viewMode, setViewMode] = useState<ViewMode>(() => {
const saved = localStorage.getItem('rsspipe_view_mode') as ViewMode | null;
if (saved && (saved === 'split' || saved === 'waterfall' || saved === 'settings')) {
return saved;
}
return window.innerWidth < 768 ? 'waterfall' : 'split';
// View Preference: 'auto' | 'mobile' | 'desktop'
const [viewPreference, setViewPreference] = useState<ViewPreference>(() => {
const saved = localStorage.getItem('rsspipe_view_preference') as ViewPreference | null;
return saved && (saved === 'auto' || saved === 'mobile' || saved === 'desktop') ? saved : 'auto';
});
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() => {
return localStorage.getItem('rsspipe_sidebar_collapsed') === 'true';
// Active View Mode: 'split' | 'waterfall' | 'settings'
const [viewMode, setViewMode] = useState<ViewMode>(() => {
const savedPref = localStorage.getItem('rsspipe_view_preference') as ViewPreference | null;
if (savedPref === 'mobile') return 'waterfall';
if (savedPref === 'desktop') return 'split';
const isNarrow = typeof window !== 'undefined' ? window.innerWidth < 768 : false;
return isNarrow ? 'waterfall' : 'split';
});
const [lastContentMode, setLastContentMode] = useState<'split' | 'waterfall'>('split');
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(() => {
return localStorage.getItem('rsspipe_selected_entry_id');
});
const [activeArticle, setActiveArticle] = useState<FeedEntry | null>(null);
const [categories, setCategories] = useState<Category[]>([]);
const [feeds, setFeeds] = useState<Feed[]>([]);
const [entries, setEntries] = useState<FeedEntry[]>([]);
const [stats, setStats] = useState<Stats | null>(null);
const [focusedIndex, setFocusedIndex] = useState(0);
const [isRefreshing, setIsRefreshing] = useState(false);
const [showAddFeedModal, setShowAddFeedModal] = useState(false);
const [showOPMLModal, setShowOPMLModal] = useState(false);
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
// Persist state updates to localStorage
// Global Audio Engine State
const [currentAudioUrl, setCurrentAudioUrl] = useState<string | null>(null);
const [isPlayingAudio, setIsPlayingAudio] = useState(false);
const [playingEntry, setPlayingEntry] = useState<FeedEntry | null>(null);
// Persist filter
useEffect(() => {
localStorage.setItem('rsspipe_filter', JSON.stringify(filter));
}, [filter]);
// Persist view preference & synchronize active view layout
useEffect(() => {
localStorage.setItem('rsspipe_view_mode', viewMode);
localStorage.setItem('rsspipe_view_preference', viewPreference);
const handleAutoResize = () => {
if (viewMode === 'settings') return;
if (viewPreference === 'mobile') {
setViewMode('waterfall');
} else if (viewPreference === 'desktop') {
setViewMode('split');
} else {
const isNarrow = window.innerWidth < 768;
setViewMode(isNarrow ? 'waterfall' : 'split');
}
};
handleAutoResize();
window.addEventListener('resize', handleAutoResize);
return () => window.removeEventListener('resize', handleAutoResize);
}, [viewPreference, viewMode]);
useEffect(() => {
if (viewMode === 'split' || viewMode === 'waterfall') {
setLastContentMode(viewMode);
}
}, [viewMode]);
useEffect(() => {
localStorage.setItem('rsspipe_sidebar_collapsed', sidebarCollapsed ? 'true' : 'false');
}, [sidebarCollapsed]);
if (selectedEntryId) {
localStorage.setItem('rsspipe_selected_entry_id', selectedEntryId);
}
}, [selectedEntryId]);
// Check auth session
// Check auth session on startup
useEffect(() => {
const checkAuth = async () => {
const token = localStorage.getItem('rsspipe_jwt');
@@ -95,7 +128,7 @@ export const App: React.FC = () => {
return () => window.removeEventListener('auth:unauthorized', handleUnauthorized);
}, []);
// Load sidebar data (Feeds, Categories, Stats)
// Load sidebar data (Categories, Feeds, Stats)
const loadSidebarData = useCallback(async () => {
if (!currentUser) return;
try {
@@ -112,24 +145,34 @@ export const App: React.FC = () => {
}
}, [currentUser]);
// Load entries matching current filter
// Load entries matching filter
const loadEntries = useCallback(async () => {
if (!currentUser) return;
try {
const res = await api.listEntries({
view: filter.view,
category_id: filter.categoryId,
feed_id: filter.feedId,
search: filter.search,
limit: 100,
category_id: filter.categoryId,
search: filter.search || undefined,
limit: 200,
});
setEntries(res.entries);
setEntries(res.entries || []);
if (res.entries && res.entries.length > 0) {
setSelectedEntryId((prev) => {
if (prev && res.entries.some((e) => e.id === prev)) {
return prev;
}
return res.entries[0].id;
});
} else {
setSelectedEntryId(null);
}
} catch (err) {
console.error('Failed to load entries:', err);
}
}, [currentUser, filter]);
// Initial load
useEffect(() => {
if (currentUser) {
loadSidebarData();
@@ -137,166 +180,109 @@ export const App: React.FC = () => {
}
}, [currentUser, loadSidebarData, loadEntries]);
// Sync focused index & active article when entries change without disrupting reading state
useEffect(() => {
if (entries.length > 0) {
if (activeArticle) {
const found = entries.find((e) => e.id === activeArticle.id);
if (found) {
setActiveArticle(found);
setFocusedIndex(entries.findIndex((e) => e.id === activeArticle.id));
return;
}
// Active article remains in reader canvas from prior feed, but list is unselected
setFocusedIndex(-1);
return;
}
if (selectedEntryId) {
const found = entries.find((e) => e.id === selectedEntryId);
if (found) {
setActiveArticle(found);
setFocusedIndex(entries.findIndex((e) => e.id === selectedEntryId));
return;
}
// If not found in current list, load directly so reading pane stays populated
api.getEntry(selectedEntryId).then((entry) => {
setActiveArticle(entry);
}).catch(() => {
// fallback to top
if (entries[0]) {
setFocusedIndex(0);
setActiveArticle(entries[0]);
setSelectedEntryId(entries[0].id);
localStorage.setItem('rsspipe_selected_entry_id', entries[0].id);
}
});
return;
}
setFocusedIndex(0);
setActiveArticle(entries[0]);
setSelectedEntryId(entries[0].id);
localStorage.setItem('rsspipe_selected_entry_id', entries[0].id);
}
}, [entries]);
const handleSelectEntry = (index: number) => {
setFocusedIndex(index);
if (entries[index]) {
setActiveArticle(entries[index]);
setSelectedEntryId(entries[index].id);
localStorage.setItem('rsspipe_selected_entry_id', entries[index].id);
// Global Refresh Action with guaranteed animation feedback duration
const handleRefreshAll = async () => {
if (isRefreshing) return;
setIsRefreshing(true);
const minDelay = new Promise((resolve) => setTimeout(resolve, 600));
try {
await Promise.all([
api.refreshAllFeeds(),
minDelay,
]);
await Promise.all([loadSidebarData(), loadEntries()]);
} catch (err) {
console.error('Refresh all failed:', err);
} finally {
setIsRefreshing(false);
}
};
// Refresh data only on initial mount or when user clicks refresh / changes filter
// Toggle Read Action with optimistic update
const handleToggleRead = async (entry: FeedEntry, e?: React.MouseEvent) => {
if (e) e.stopPropagation();
const newStatus = !entry.is_read;
// Optimistic update
// Toggle Read Action
const handleToggleRead = async (entry: FeedEntry) => {
const nextRead = !entry.is_read;
setEntries((prev) =>
prev.map((item) =>
item.id === entry.id ? { ...item, is_read: newStatus } : item
)
prev.map((e) => (e.id === entry.id ? { ...e, is_read: nextRead } : e))
);
if (activeArticle && activeArticle.id === entry.id) {
setActiveArticle((prev) => (prev ? { ...prev, is_read: newStatus } : prev));
}
try {
await api.updateRead(entry.id, newStatus);
await api.updateRead(entry.id, nextRead);
loadSidebarData();
} catch (err) {
console.error('Failed to update read status:', err);
// Revert on error
setEntries((prev) =>
prev.map((item) =>
item.id === entry.id ? { ...item, is_read: !newStatus } : item
)
);
if (activeArticle && activeArticle.id === entry.id) {
setActiveArticle((prev) => (prev ? { ...prev, is_read: !newStatus } : prev));
}
console.error('Update read failed:', err);
}
};
// Toggle Star Action with optimistic update
// Toggle Star Action
const handleToggleStar = async (entry: FeedEntry, e?: React.MouseEvent) => {
if (e) e.stopPropagation();
const newStatus = !entry.is_starred;
// Optimistic update
const nextStar = !entry.is_starred;
setEntries((prev) =>
prev.map((item) =>
item.id === entry.id ? { ...item, is_starred: newStatus } : item
)
prev.map((e) => (e.id === entry.id ? { ...e, is_starred: nextStar } : e))
);
if (activeArticle && activeArticle.id === entry.id) {
setActiveArticle((prev) => (prev ? { ...prev, is_starred: newStatus } : prev));
}
try {
await api.updateStar(entry.id, newStatus);
await api.updateStar(entry.id, nextStar);
loadSidebarData();
} catch (err) {
console.error('Failed to update star status:', err);
setEntries((prev) =>
prev.map((item) =>
item.id === entry.id ? { ...item, is_starred: !newStatus } : item
)
);
if (activeArticle && activeArticle.id === entry.id) {
setActiveArticle((prev) => (prev ? { ...prev, is_starred: !newStatus } : prev));
}
console.error('Update star failed:', err);
}
};
// Mark All Read
// Mark All As Read Action
const handleMarkAllRead = async () => {
try {
await api.markAllRead(filter.feedId, filter.categoryId);
setEntries((prev) => prev.map((item) => ({ ...item, is_read: true })));
if (activeArticle) {
setActiveArticle((prev) => (prev ? { ...prev, is_read: true } : prev));
}
setEntries((prev) => prev.map((e) => ({ ...e, is_read: true })));
loadSidebarData();
} catch (err) {
console.error('Failed to mark all read:', err);
console.error('Mark all read failed:', err);
}
};
// Refresh All Feeds
const handleRefreshAll = async () => {
setIsRefreshing(true);
try {
await api.refreshAllFeeds();
setTimeout(() => {
loadSidebarData();
loadEntries();
setIsRefreshing(false);
}, 2500);
} catch (err) {
console.error('Failed to refresh feeds:', err);
setIsRefreshing(false);
// Update Single Entry Action
const handleUpdateEntry = (updated: FeedEntry) => {
setEntries((prev) => prev.map((e) => (e.id === updated.id ? updated : e)));
loadSidebarData();
};
// Audio Playback Actions
const handlePlayAudio = (entry: FeedEntry, streamUrl: string) => {
if (currentAudioUrl === streamUrl) {
setIsPlayingAudio(!isPlayingAudio);
} else {
setCurrentAudioUrl(streamUrl);
setPlayingEntry(entry);
setIsPlayingAudio(true);
}
};
const handleTogglePlayAudio = () => {
setIsPlayingAudio(!isPlayingAudio);
};
const handleCloseAudio = () => {
setIsPlayingAudio(false);
setCurrentAudioUrl(null);
setPlayingEntry(null);
};
// View Preference Change Handler
const handleChangeViewPreference = (pref: ViewPreference) => {
setViewPreference(pref);
if (pref === 'desktop') {
setViewMode('split');
} else if (pref === 'mobile') {
setViewMode('waterfall');
} else {
const isNarrow = window.innerWidth < 768;
setViewMode(isNarrow ? 'waterfall' : 'split');
}
};
// Global Keyboard Shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't intercept inside input fields
if (
document.activeElement?.tagName === 'INPUT' ||
document.activeElement?.tagName === 'TEXTAREA' ||
document.activeElement?.tagName === 'SELECT'
) {
if (e.key === 'Escape') {
(document.activeElement as HTMLElement).blur();
}
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
return;
}
@@ -304,157 +290,99 @@ export const App: React.FC = () => {
setShowAddFeedModal(false);
setShowOPMLModal(false);
setShowShortcutsModal(false);
if (viewMode === 'settings') {
setViewMode(lastContentMode);
}
return;
}
switch (e.key) {
case '[': // Toggle Sidebar
e.preventDefault();
setSidebarCollapsed((prev) => !prev);
break;
if (e.key === '?' || (e.shiftKey && e.key === '/')) {
e.preventDefault();
setShowShortcutsModal(true);
return;
}
case 'j':
case 'J': // Next item
e.preventDefault();
if (entries.length > 0) {
setFocusedIndex((prev) => {
const next = prev < 0 ? 0 : Math.min(prev + 1, entries.length - 1);
if (entries[next]) {
setActiveArticle(entries[next]);
setSelectedEntryId(entries[next].id);
localStorage.setItem('rsspipe_selected_entry_id', entries[next].id);
}
return next;
});
}
break;
if (e.key === '1') {
setViewMode('split');
return;
}
if (e.key === '2') {
setViewMode('waterfall');
return;
}
if (e.key === '3') {
setViewMode('settings');
return;
}
case 'k':
case 'K': // Previous item
e.preventDefault();
if (entries.length > 0) {
setFocusedIndex((prev) => {
const next = prev < 0 ? 0 : Math.max(prev - 1, 0);
if (entries[next]) {
setActiveArticle(entries[next]);
setSelectedEntryId(entries[next].id);
localStorage.setItem('rsspipe_selected_entry_id', entries[next].id);
}
return next;
});
}
break;
case 'v':
case 'V': // Open original link
{
const targetUrl = activeArticle?.url || entries[focusedIndex]?.url;
if (targetUrl) {
e.preventDefault();
window.open(targetUrl, '_blank');
}
}
break;
case 's':
case 'S': // Toggle Star
{
const target = activeArticle || entries[focusedIndex];
if (target) {
e.preventDefault();
handleToggleStar(target);
}
}
break;
case 'm':
case 'M': // Toggle Read / Unread
{
const target = activeArticle || entries[focusedIndex];
if (target) {
e.preventDefault();
handleToggleRead(target);
}
}
break;
case 'e':
case 'E': // Archive / Mark read and advance
{
const target = activeArticle || entries[focusedIndex];
if (target) {
e.preventDefault();
if (!target.is_read) {
handleToggleRead(target);
}
if (entries.length > 0) {
setFocusedIndex((prev) => {
const next = prev < 0 ? 0 : Math.min(prev + 1, entries.length - 1);
if (entries[next]) {
setActiveArticle(entries[next]);
setSelectedEntryId(entries[next].id);
localStorage.setItem('rsspipe_selected_entry_id', entries[next].id);
}
return next;
});
}
}
}
break;
case 'a':
case 'A': // Add feed modal
if (!e.shiftKey && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
setShowAddFeedModal(true);
}
break;
case 'r':
case 'R': // Refresh feeds
if (e.key === 'r' || e.key === 'R') {
if (!e.metaKey && !e.ctrlKey) {
e.preventDefault();
handleRefreshAll();
break;
return;
}
}
case '1': // Switch to Desktop 3-Split View
if (e.key === 'a' || e.key === 'A') {
if (e.shiftKey) {
e.preventDefault();
setViewMode('split');
break;
case '2': // Switch to Xiaohongshu Waterfall View
handleMarkAllRead();
} else if (!e.metaKey && !e.ctrlKey) {
e.preventDefault();
setViewMode('waterfall');
break;
setShowAddFeedModal(true);
}
return;
}
case '3': // Switch to Settings View
// 3-Split specific shortcuts
if (viewMode === 'split' && entries.length > 0) {
const currentIndex = entries.findIndex((item) => item.id === selectedEntryId);
if (e.key === 'j' || e.key === 'ArrowDown') {
e.preventDefault();
setViewMode('settings');
break;
case '?': // Shortcuts modal
e.preventDefault();
setShowShortcutsModal(true);
break;
default:
if (e.shiftKey && e.key === 'A') {
e.preventDefault();
if (window.confirm('Mark all items in this view as read?')) {
handleMarkAllRead();
const nextIndex = Math.min(currentIndex + 1, entries.length - 1);
if (entries[nextIndex]) {
const nextEntry = entries[nextIndex];
setSelectedEntryId(nextEntry.id);
if (!nextEntry.is_read) {
handleToggleRead(nextEntry);
}
}
break;
} else if (e.key === 'k' || e.key === 'ArrowUp') {
e.preventDefault();
const prevIndex = Math.max(currentIndex - 1, 0);
if (entries[prevIndex]) {
const prevEntry = entries[prevIndex];
setSelectedEntryId(prevEntry.id);
if (!prevEntry.is_read) {
handleToggleRead(prevEntry);
}
}
} else if (e.key === 's') {
e.preventDefault();
const currentEntry = entries[currentIndex];
if (currentEntry) handleToggleStar(currentEntry);
} else if (e.key === 'm') {
e.preventDefault();
const currentEntry = entries[currentIndex];
if (currentEntry) handleToggleRead(currentEntry);
} else if (e.key === 'o') {
const currentEntry = entries[currentIndex];
if (currentEntry?.url) {
window.open(currentEntry.url, '_blank');
}
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [entries, focusedIndex, activeArticle]);
}, [viewMode, entries, selectedEntryId, isRefreshing, lastContentMode]);
if (authLoading) {
return (
<div className="login-container">
<div style={{ color: 'var(--text-main)', fontSize: '1.1rem', fontWeight: 600 }}>
<div style={{ display: 'flex', height: '100vh', width: '100vw', alignItems: 'center', justifyContent: 'center', background: 'var(--bg-app)' }}>
<div style={{ fontSize: '0.95rem', fontWeight: 600, color: 'var(--text-muted)' }}>
Loading RSSPipe...
</div>
</div>
@@ -462,62 +390,80 @@ export const App: React.FC = () => {
}
if (!currentUser) {
return <LoginView onLoginSuccess={(user) => setCurrentUser(user)} />;
return <LoginView onLoginSuccess={(u) => setCurrentUser(u)} />;
}
return (
<div className="app-container">
{/* App Header */}
<Header
search={filter.search}
onSearchChange={(val) => setFilter((prev) => ({ ...prev, search: val }))}
viewMode={viewMode}
onViewModeChange={setViewMode}
isRefreshing={isRefreshing}
onRefreshAll={handleRefreshAll}
onOpenAddFeed={() => setShowAddFeedModal(true)}
onOpenShortcuts={() => setShowShortcutsModal(true)}
onLogout={async () => {
await api.logout();
setCurrentUser(null);
}}
sidebarCollapsed={sidebarCollapsed}
onToggleSidebar={() => setSidebarCollapsed((prev) => !prev)}
/>
{/* Top Header is rendered in 3-Split desktop view */}
{viewMode === 'split' && (
<Header
search={filter.search}
onSearchChange={(search) => setFilter((prev) => ({ ...prev, search }))}
isRefreshing={isRefreshing}
onRefreshAll={handleRefreshAll}
onOpenAddFeed={() => setShowAddFeedModal(true)}
onOpenSettings={() => setViewMode('settings')}
onOpenShortcuts={() => setShowShortcutsModal(true)}
onLogout={async () => {
await api.logout();
setCurrentUser(null);
}}
/>
)}
{/* Main View Mode Content */}
{/* Main View Router */}
{viewMode === 'split' ? (
<ThreePaneView
filter={filter}
onFilterChange={(newF) => setFilter((prev) => ({ ...prev, ...newF }))}
onFilterChange={(newFilter) => setFilter((prev) => ({ ...prev, ...newFilter }))}
categories={categories}
feeds={feeds}
stats={stats}
entries={entries}
selectedIndex={focusedIndex}
activeArticle={activeArticle}
onSelectEntry={handleSelectEntry}
onToggleRead={handleToggleRead}
onToggleStar={handleToggleStar}
onMarkAllRead={handleMarkAllRead}
onUpdateEntry={(updated) => {
setActiveArticle(updated);
setEntries((prev) => prev.map((e) => (e.id === updated.id ? updated : e)));
selectedEntryId={selectedEntryId}
onSelectEntry={(entry) => {
setSelectedEntryId(entry.id);
if (!entry.is_read) {
handleToggleRead(entry);
}
}}
sidebarCollapsed={sidebarCollapsed}
onToggleStar={handleToggleStar}
onToggleRead={handleToggleRead}
onMarkAllRead={handleMarkAllRead}
onUpdateEntry={handleUpdateEntry}
stats={stats}
onOpenSettings={() => setViewMode('settings')}
onOpenAddFeed={() => setShowAddFeedModal(true)}
onPlayAudio={handlePlayAudio}
currentAudioUrl={currentAudioUrl}
isPlayingAudio={isPlayingAudio}
/>
) : viewMode === 'waterfall' ? (
<WaterfallView
filter={filter}
onFilterChange={(newF) => setFilter((prev) => ({ ...prev, ...newF }))}
onFilterChange={(newFilter) => setFilter((prev) => ({ ...prev, ...newFilter }))}
categories={categories}
feeds={feeds}
entries={entries}
stats={stats}
currentUser={currentUser}
onToggleRead={handleToggleRead}
onToggleStar={handleToggleStar}
onUpdateEntry={(updated) => {
setActiveArticle(updated);
setEntries((prev) => prev.map((e) => (e.id === updated.id ? updated : e)));
onUpdateEntry={handleUpdateEntry}
onRefreshData={() => {
loadSidebarData();
loadEntries();
}}
onOpenAddFeed={() => setShowAddFeedModal(true)}
onPlayAudio={handlePlayAudio}
currentAudioUrl={currentAudioUrl}
isPlayingAudio={isPlayingAudio}
onTogglePlayAudio={handleTogglePlayAudio}
onCloseAudio={handleCloseAudio}
playingEntry={playingEntry}
viewPreference={viewPreference}
onChangeViewPreference={handleChangeViewPreference}
onSwitchToDesktop={() => setViewMode('split')}
/>
) : (
<SettingsView
@@ -530,10 +476,13 @@ export const App: React.FC = () => {
loadEntries();
}}
onOpenAddFeed={() => setShowAddFeedModal(true)}
onBack={() => setViewMode(lastContentMode)}
viewPreference={viewPreference}
onChangeViewPreference={handleChangeViewPreference}
/>
)}
{/* Modals */}
{/* Shared Modal Dialogs */}
{showAddFeedModal && (
<AddFeedModal
categories={categories}