Skip to content

Commit 65754b7

Browse files
authored
Merge pull request #168 from kingksjo/feat/url-driven-filters-issue-129
Add URL-driven filters for list pages
2 parents 41f22cc + c813009 commit 65754b7

11 files changed

Lines changed: 853 additions & 107 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,6 @@
99
.idea
1010
.vscode
1111
*.swp
12+
13+
# Agents
14+
GEMINI.md

frontend/src/App.tsx

Lines changed: 32 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from "react-router-dom";
88
import { ThemeProvider } from "./context/ThemeContext";
99
import { VaultProvider } from "./context/VaultContext";
10+
import { ToastProvider } from "./context/ToastContext";
1011
import Navbar from "./components/Navbar";
1112
import "./index.css";
1213

@@ -62,35 +63,37 @@ function App() {
6263
>
6364
<ThemeProvider>
6465
<VaultProvider>
65-
<Router>
66-
<div className="app-container">
67-
<Navbar
68-
walletAddress={walletAddress}
69-
onConnect={handleConnect}
70-
onDisconnect={handleDisconnect}
71-
/>
72-
<main
73-
className="container"
74-
style={{ marginTop: "100px", paddingBottom: "60px" }}
75-
>
76-
<Suspense fallback={<LoadingPage />}>
77-
{/* Replaced Routes with SentryRoutes to capture performance events */}
78-
<SentryRoutes>
79-
<Route
80-
path="/"
81-
element={<Home walletAddress={walletAddress} />}
82-
/>
83-
<Route
84-
path="/portfolio"
85-
element={<Portfolio walletAddress={walletAddress} />}
86-
/>
87-
<Route path="/analytics" element={<Analytics />} />
88-
<Route path="*" element={<Navigate to="/" replace />} />
89-
</SentryRoutes>
90-
</Suspense>
91-
</main>
92-
</div>
93-
</Router>
66+
<ToastProvider>
67+
<Router>
68+
<div className="app-container">
69+
<Navbar
70+
walletAddress={walletAddress}
71+
onConnect={handleConnect}
72+
onDisconnect={handleDisconnect}
73+
/>
74+
<main
75+
className="container"
76+
style={{ marginTop: "100px", paddingBottom: "60px" }}
77+
>
78+
<Suspense fallback={<LoadingPage />}>
79+
{/* Replaced Routes with SentryRoutes to capture performance events */}
80+
<SentryRoutes>
81+
<Route
82+
path="/"
83+
element={<Home walletAddress={walletAddress} />}
84+
/>
85+
<Route
86+
path="/portfolio"
87+
element={<Portfolio walletAddress={walletAddress} />}
88+
/>
89+
<Route path="/analytics" element={<Analytics />} />
90+
<Route path="*" element={<Navigate to="/" replace />} />
91+
</SentryRoutes>
92+
</Suspense>
93+
</main>
94+
</div>
95+
</Router>
96+
</ToastProvider>
9497
</VaultProvider>
9598
</ThemeProvider>
9699
</Sentry.ErrorBoundary>

frontend/src/components/DataTable.tsx

Lines changed: 12 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { KeyboardEvent, ReactNode } from "react";
2+
import { Pagination } from "./Pagination";
23

34
export type TableSortDirection = "asc" | "desc";
45

@@ -30,6 +31,7 @@ interface DataTableProps<T> {
3031
onSortChange?: (columnId: string) => void;
3132
pagination?: PaginationState;
3233
onPageChange?: (page: number) => void;
34+
onPageSizeChange?: (pageSize: number) => void;
3335
renderRowDetails?: (row: T) => ReactNode;
3436
}
3537

@@ -56,6 +58,7 @@ export function DataTable<T>({
5658
onSortChange,
5759
pagination,
5860
onPageChange,
61+
onPageSizeChange,
5962
renderRowDetails,
6063
}: DataTableProps<T>) {
6164
const handleHeaderKeyDown = (
@@ -157,29 +160,15 @@ export function DataTable<T>({
157160
</table>
158161
</div>
159162

160-
{pagination && pagination.totalPages > 1 && (
161-
<div className="data-table-pagination">
162-
<div className="data-table-pagination-summary">
163-
Page {pagination.page} of {pagination.totalPages}
164-
</div>
165-
<div className="data-table-pagination-actions">
166-
<button
167-
type="button"
168-
className="btn btn-outline"
169-
onClick={() => onPageChange?.(pagination.page - 1)}
170-
disabled={pagination.page <= 1}
171-
>
172-
Previous
173-
</button>
174-
<button
175-
type="button"
176-
className="btn btn-outline"
177-
onClick={() => onPageChange?.(pagination.page + 1)}
178-
disabled={pagination.page >= pagination.totalPages}
179-
>
180-
Next
181-
</button>
182-
</div>
163+
{pagination && (
164+
<div className="data-table-pagination" style={{ padding: 0 }}>
165+
<Pagination
166+
page={pagination.page}
167+
pageSize={pagination.pageSize}
168+
totalItems={pagination.totalItems}
169+
onPageChange={onPageChange}
170+
onPageSizeChange={onPageSizeChange}
171+
/>
183172
</div>
184173
)}
185174
</div>
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import React from "react";
2+
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
3+
4+
export interface PaginationProps {
5+
page: number;
6+
pageSize: number;
7+
totalItems: number;
8+
onPageChange?: (page: number) => void;
9+
onPageSizeChange?: (pageSize: number) => void;
10+
pageSizeOptions?: number[];
11+
}
12+
13+
export const Pagination: React.FC<PaginationProps> = ({
14+
page,
15+
pageSize,
16+
totalItems,
17+
onPageChange,
18+
onPageSizeChange,
19+
pageSizeOptions = [4, 6, 8, 10, 20, 50],
20+
}) => {
21+
const totalPages = Math.ceil(totalItems / pageSize) || 1;
22+
const startItem = totalItems === 0 ? 0 : (page - 1) * pageSize + 1;
23+
const endItem = Math.min(page * pageSize, totalItems);
24+
25+
const getPageNumbers = () => {
26+
const maxVisiblePages = 5;
27+
if (totalPages <= maxVisiblePages) {
28+
return Array.from({ length: totalPages }, (_, i) => i + 1);
29+
}
30+
31+
const pages: (number | string)[] = [];
32+
if (page <= 3) {
33+
pages.push(1, 2, 3, 4, "...", totalPages);
34+
} else if (page >= totalPages - 2) {
35+
pages.push(
36+
1,
37+
"...",
38+
totalPages - 3,
39+
totalPages - 2,
40+
totalPages - 1,
41+
totalPages,
42+
);
43+
} else {
44+
pages.push(1, "...", page - 1, page, page + 1, "...", totalPages);
45+
}
46+
return pages;
47+
};
48+
49+
return (
50+
<div className="pagination-container" aria-label="Pagination">
51+
<div className="pagination-summary" aria-live="polite">
52+
Showing <strong>{startItem}{endItem}</strong> of{" "}
53+
<strong>{totalItems}</strong> results
54+
</div>
55+
56+
<div className="pagination-controls-wrapper">
57+
{onPageSizeChange && (
58+
<div className="pagination-size-selector">
59+
<label htmlFor="pageSizeSelect" className="sr-only">
60+
Rows per page
61+
</label>
62+
<span className="pagination-size-label" aria-hidden="true">
63+
Rows:
64+
</span>
65+
<select
66+
id="pageSizeSelect"
67+
className="pagination-select"
68+
value={pageSize}
69+
onChange={(e) => onPageSizeChange(Number(e.target.value))}
70+
aria-label="Rows per page"
71+
>
72+
{pageSizeOptions.map((size) => (
73+
<option key={size} value={size}>
74+
{size}
75+
</option>
76+
))}
77+
</select>
78+
</div>
79+
)}
80+
81+
<nav className="pagination-nav-buttons" aria-label="Page navigation">
82+
<button
83+
type="button"
84+
className="btn btn-outline pagination-btn-nav"
85+
onClick={() => onPageChange?.(page - 1)}
86+
disabled={page <= 1}
87+
aria-label="Go to previous page"
88+
>
89+
<ChevronLeft size={16} />
90+
</button>
91+
92+
<div className="pagination-pages">
93+
{getPageNumbers().map((p, i) => {
94+
if (p === "...") {
95+
return (
96+
<span
97+
key={`ellipsis-${i}`}
98+
className="pagination-ellipsis"
99+
aria-hidden="true"
100+
>
101+
<MoreHorizontal size={16} />
102+
</span>
103+
);
104+
}
105+
const pageNum = p as number;
106+
return (
107+
<button
108+
key={pageNum}
109+
type="button"
110+
className={`pagination-page-btn ${
111+
pageNum === page ? "active" : ""
112+
}`}
113+
onClick={() => onPageChange?.(pageNum)}
114+
aria-current={pageNum === page ? "page" : undefined}
115+
aria-label={`Go to page ${pageNum}`}
116+
>
117+
{pageNum}
118+
</button>
119+
);
120+
})}
121+
</div>
122+
123+
<button
124+
type="button"
125+
className="btn btn-outline pagination-btn-nav"
126+
onClick={() => onPageChange?.(page + 1)}
127+
disabled={page >= totalPages}
128+
aria-label="Go to next page"
129+
>
130+
<ChevronRight size={16} />
131+
</button>
132+
</nav>
133+
</div>
134+
</div>
135+
);
136+
};

frontend/src/components/Tabs.css

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
.tabs-root {
2+
display: flex;
3+
flex-direction: column;
4+
width: 100%;
5+
}
6+
7+
.tabs-list {
8+
display: flex;
9+
overflow-x: auto;
10+
scrollbar-width: none;
11+
-ms-overflow-style: none;
12+
background: var(--bg-muted);
13+
padding: 6px;
14+
border-radius: 12px;
15+
gap: 8px;
16+
}
17+
18+
.tabs-list::-webkit-scrollbar {
19+
display: none;
20+
}
21+
22+
.tabs-trigger {
23+
flex: 1;
24+
min-width: max-content;
25+
padding: 10px 16px;
26+
border-radius: 8px;
27+
background: transparent;
28+
color: var(--text-secondary);
29+
font-weight: 500;
30+
font-size: 0.95rem;
31+
transition: all 0.2s;
32+
border: 1px solid transparent;
33+
cursor: pointer;
34+
white-space: nowrap;
35+
}
36+
37+
.tabs-trigger:hover {
38+
color: var(--text-primary);
39+
}
40+
41+
.tabs-trigger.active {
42+
background: var(--border-glass);
43+
color: var(--text-primary);
44+
font-weight: 600;
45+
border: 1px solid var(--border-glass);
46+
}
47+
48+
.tabs-trigger:focus-visible {
49+
outline: 2px solid var(--accent-cyan);
50+
outline-offset: 2px;
51+
}
52+
53+
.tabs-content {
54+
animation: fadeInTabs 0.2s ease-in-out;
55+
outline: none;
56+
}
57+
58+
.tabs-content:focus-visible {
59+
outline: 2px solid var(--accent-cyan);
60+
outline-offset: 2px;
61+
border-radius: 8px;
62+
}
63+
64+
@keyframes fadeInTabs {
65+
from { opacity: 0; transform: translateY(4px); }
66+
to { opacity: 1; transform: translateY(0); }
67+
}

0 commit comments

Comments
 (0)