Skip to content

Commit 50250c7

Browse files
tianzhouclaude
andauthored
feat: implement frontend layout with master-detail structure (#126)
* feat: implement frontend layout with master-detail structure Implement a complete frontend layout with sidebar navigation and routing: - Add master-detail layout with 280px fixed sidebar and fluid content area - Create sidebar with DBHub logo, Home link, and dynamic source list - Implement React Router with routes for home and source detail views - Add API client for fetching data sources from /api/sources endpoint - Create HomeView with welcome content and getting started guide - Create SourceDetailView showing connection status and configuration - Add TypeScript types matching OpenAPI schema (DataSource, SSHTunnel) - Include database type icons with color-coding for visual distinction - Add loading states and error handling for async data fetching - Display source metadata: host, port, database, user, SSH tunnel info - Show configuration details: readonly mode, max rows, default source badge Dependencies: - react-router-dom ^7.9.5 for client-side routing - @types/react-router-dom ^5.3.3 for TypeScript support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: address PR review comments for frontend layout - Add input validation for sourceId to prevent path traversal attacks - Add error state and retry UI for API failures - Make sidebar width responsive across screen sizes (200px → 280px) - Remove incompatible @types/react-router-dom package - Add accessibility labels to main, aside, and nav elements 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: replace full-screen error with toast notification Replace the full-screen error UI with a bottom-right toast notification for better user experience when API calls fail. The sidebar remains visible and users can continue using the app while seeing the error. - Create reusable Toast component with auto-dismiss - Display error toast on fetchSources failure - Remove blocking error screen 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: address additional PR review comments - Add URL encoding for sourceId to handle special characters - Add safe JSON error parsing with content-type verification - Make database field optional to match OpenAPI schema - Add proper database type display names (SQL Server, PostgreSQL, etc.) - Use explicit abbreviation mapping for database icons (PG, MY, MB, MS, SL) - Remove redundant "configuration" word in HomeView text 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6aa1c94 commit 50250c7

File tree

14 files changed

+753
-9
lines changed

14 files changed

+753
-9
lines changed

frontend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@
99
"preview": "vite preview"
1010
},
1111
"dependencies": {
12+
"clsx": "^2.1.1",
1213
"react": "^18.3.1",
1314
"react-dom": "^18.3.1",
14-
"clsx": "^2.1.1",
15+
"react-router-dom": "^7.9.5",
1516
"tailwind-merge": "^2.5.5"
1617
},
1718
"devDependencies": {

frontend/public/logo-full-light.svg

Lines changed: 58 additions & 0 deletions
Loading

frontend/src/App.tsx

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,41 @@
1+
import { useEffect, useState } from 'react';
2+
import { BrowserRouter, Routes, Route } from 'react-router-dom';
3+
import Layout from './components/Layout';
4+
import HomeView from './components/views/HomeView';
5+
import SourceDetailView from './components/views/SourceDetailView';
6+
import Toast from './components/Toast';
7+
import { fetchSources } from './api/sources';
8+
import type { DataSource } from './types/datasource';
9+
110
function App() {
11+
const [sources, setSources] = useState<DataSource[]>([]);
12+
const [isLoading, setIsLoading] = useState(true);
13+
const [error, setError] = useState<string | null>(null);
14+
15+
useEffect(() => {
16+
fetchSources()
17+
.then((data) => {
18+
setSources(data);
19+
setIsLoading(false);
20+
})
21+
.catch((error) => {
22+
console.error('Failed to fetch sources:', error);
23+
setError(error instanceof Error ? error.message : 'Failed to load data sources');
24+
setIsLoading(false);
25+
});
26+
}, []);
27+
228
return (
3-
<div className="min-h-screen bg-background flex items-center justify-center">
4-
<div className="container mx-auto px-4">
5-
<h1 className="text-4xl font-bold text-center text-foreground">
6-
DBHub - Universal Database MCP Server
7-
</h1>
8-
</div>
9-
</div>
10-
)
29+
<BrowserRouter>
30+
<Routes>
31+
<Route path="/" element={<Layout sources={sources} isLoading={isLoading} />}>
32+
<Route index element={<HomeView />} />
33+
<Route path="source/:sourceId" element={<SourceDetailView />} />
34+
</Route>
35+
</Routes>
36+
{error && <Toast message={error} type="error" onClose={() => setError(null)} />}
37+
</BrowserRouter>
38+
);
1139
}
1240

1341
export default App

frontend/src/api/sources.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { DataSource } from '../types/datasource';
2+
3+
const API_BASE = '/api';
4+
5+
async function parseJsonResponse<T>(response: Response): Promise<T> {
6+
const contentType = response.headers.get('content-type');
7+
if (contentType && contentType.includes('application/json')) {
8+
return response.json();
9+
}
10+
// Fallback to text if not JSON
11+
const text = await response.text();
12+
throw new Error(text || response.statusText);
13+
}
14+
15+
export async function fetchSources(): Promise<DataSource[]> {
16+
const response = await fetch(`${API_BASE}/sources`);
17+
18+
if (!response.ok) {
19+
const errorMessage = await parseJsonResponse<{ error: string }>(response)
20+
.then((data) => data.error)
21+
.catch(() => response.statusText);
22+
throw new Error(`Failed to fetch sources: ${errorMessage}`);
23+
}
24+
25+
return response.json();
26+
}
27+
28+
export async function fetchSource(sourceId: string): Promise<DataSource> {
29+
// Validate sourceId to prevent path traversal attacks
30+
if (!sourceId || sourceId.trim() === '') {
31+
throw new Error('Source ID cannot be empty');
32+
}
33+
if (sourceId.includes('/') || sourceId.includes('..')) {
34+
throw new Error('Invalid source ID format');
35+
}
36+
37+
const response = await fetch(`${API_BASE}/sources/${encodeURIComponent(sourceId)}`);
38+
39+
if (!response.ok) {
40+
const errorMessage = await parseJsonResponse<{ error: string }>(response)
41+
.then((data) => data.error)
42+
.catch(() => response.statusText);
43+
44+
if (response.status === 404) {
45+
throw new Error(`Source not found: ${sourceId}`);
46+
}
47+
throw new Error(`Failed to fetch source: ${errorMessage}`);
48+
}
49+
50+
return response.json();
51+
}

frontend/src/components/Layout.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Outlet } from 'react-router-dom';
2+
import Sidebar from './Sidebar/Sidebar';
3+
import type { DataSource } from '../types/datasource';
4+
5+
interface LayoutProps {
6+
sources: DataSource[];
7+
isLoading: boolean;
8+
}
9+
10+
export default function Layout({ sources, isLoading }: LayoutProps) {
11+
return (
12+
<div className="flex h-screen bg-background">
13+
<Sidebar sources={sources} isLoading={isLoading} />
14+
<main className="flex-1 overflow-auto" aria-label="Main content">
15+
<Outlet />
16+
</main>
17+
</div>
18+
);
19+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export default function Logo() {
2+
return (
3+
<div className="px-6 py-4 border-b border-border">
4+
<img
5+
src="/logo-full-light.svg"
6+
alt="DBHub"
7+
className="w-[200px] h-auto"
8+
/>
9+
</div>
10+
);
11+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { Link, useLocation } from 'react-router-dom';
2+
import { cn } from '../../lib/utils';
3+
4+
interface NavItemProps {
5+
to: string;
6+
icon: React.ReactNode;
7+
label: string;
8+
}
9+
10+
export default function NavItem({ to, icon, label }: NavItemProps) {
11+
const location = useLocation();
12+
const isActive = location.pathname === to;
13+
14+
return (
15+
<Link
16+
to={to}
17+
className={cn(
18+
'flex items-center gap-3 px-6 py-3 text-sm font-medium transition-colors',
19+
'hover:bg-accent hover:text-accent-foreground',
20+
isActive && 'bg-accent text-accent-foreground'
21+
)}
22+
>
23+
<span className="w-5 h-5 flex items-center justify-center">
24+
{icon}
25+
</span>
26+
<span>{label}</span>
27+
</Link>
28+
);
29+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import Logo from './Logo';
2+
import NavItem from './NavItem';
3+
import SourceList from './SourceList';
4+
import type { DataSource } from '../../types/datasource';
5+
6+
interface SidebarProps {
7+
sources: DataSource[];
8+
isLoading: boolean;
9+
}
10+
11+
function HomeIcon() {
12+
return (
13+
<svg
14+
xmlns="http://www.w3.org/2000/svg"
15+
width="20"
16+
height="20"
17+
viewBox="0 0 24 24"
18+
fill="none"
19+
stroke="currentColor"
20+
strokeWidth="2"
21+
strokeLinecap="round"
22+
strokeLinejoin="round"
23+
>
24+
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
25+
<polyline points="9 22 9 12 15 12 15 22" />
26+
</svg>
27+
);
28+
}
29+
30+
export default function Sidebar({ sources, isLoading }: SidebarProps) {
31+
return (
32+
<aside className="w-[200px] sm:w-[220px] md:w-[240px] lg:w-[280px] border-r border-border bg-card flex flex-col" aria-label="Navigation sidebar">
33+
<Logo />
34+
<nav className="flex-1 flex flex-col overflow-hidden" aria-label="Sidebar navigation">
35+
<NavItem to="/" icon={<HomeIcon />} label="Home" />
36+
<SourceList sources={sources} isLoading={isLoading} />
37+
</nav>
38+
</aside>
39+
);
40+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { Link, useParams } from 'react-router-dom';
2+
import { cn } from '../../lib/utils';
3+
import type { DataSource, DatabaseType } from '../../types/datasource';
4+
5+
interface SourceListProps {
6+
sources: DataSource[];
7+
isLoading: boolean;
8+
}
9+
10+
const DB_COLORS: Record<DatabaseType, string> = {
11+
postgres: '#336791',
12+
mysql: '#4479A1',
13+
mariadb: '#003545',
14+
sqlserver: '#CC2927',
15+
sqlite: '#003B57',
16+
};
17+
18+
const DB_ABBREVIATIONS: Record<DatabaseType, string> = {
19+
postgres: 'PG',
20+
mysql: 'MY',
21+
mariadb: 'MB',
22+
sqlserver: 'MS',
23+
sqlite: 'SL',
24+
};
25+
26+
function DatabaseIcon({ type }: { type: DatabaseType }) {
27+
return (
28+
<div
29+
className="w-5 h-5 rounded-full flex items-center justify-center text-white text-xs font-bold"
30+
style={{ backgroundColor: DB_COLORS[type] }}
31+
>
32+
{DB_ABBREVIATIONS[type]}
33+
</div>
34+
);
35+
}
36+
37+
export default function SourceList({ sources, isLoading }: SourceListProps) {
38+
const { sourceId } = useParams<{ sourceId: string }>();
39+
40+
if (isLoading) {
41+
return (
42+
<div className="px-6 py-3 text-sm text-muted-foreground">
43+
Loading sources...
44+
</div>
45+
);
46+
}
47+
48+
if (sources.length === 0) {
49+
return (
50+
<div className="px-6 py-3 text-sm text-muted-foreground">
51+
No sources configured
52+
</div>
53+
);
54+
}
55+
56+
return (
57+
<div className="flex-1 overflow-auto">
58+
<div className="px-6 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
59+
Data Sources
60+
</div>
61+
{sources.map((source) => {
62+
const isActive = sourceId === source.id;
63+
return (
64+
<Link
65+
key={source.id}
66+
to={`/source/${source.id}`}
67+
className={cn(
68+
'flex items-center gap-3 px-6 py-3 text-sm font-medium transition-colors',
69+
'hover:bg-accent hover:text-accent-foreground',
70+
isActive && 'bg-accent text-accent-foreground'
71+
)}
72+
>
73+
<DatabaseIcon type={source.type} />
74+
<span className="flex-1 truncate">{source.id}</span>
75+
{source.is_default && (
76+
<span className="text-xs text-muted-foreground">default</span>
77+
)}
78+
</Link>
79+
);
80+
})}
81+
</div>
82+
);
83+
}

frontend/src/components/Toast.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { useEffect } from 'react';
2+
3+
interface ToastProps {
4+
message: string;
5+
type?: 'error' | 'success' | 'info';
6+
onClose: () => void;
7+
duration?: number;
8+
}
9+
10+
export default function Toast({ message, type = 'error', onClose, duration = 5000 }: ToastProps) {
11+
useEffect(() => {
12+
const timer = setTimeout(() => {
13+
onClose();
14+
}, duration);
15+
16+
return () => clearTimeout(timer);
17+
}, [duration, onClose]);
18+
19+
const bgColor = type === 'error' ? 'bg-red-600' : type === 'success' ? 'bg-green-600' : 'bg-blue-600';
20+
21+
return (
22+
<div className="fixed bottom-4 right-4 z-50 animate-in slide-in-from-right">
23+
<div className={`${bgColor} text-white px-6 py-4 rounded-lg shadow-lg max-w-md flex items-start gap-3`}>
24+
<div className="flex-1">
25+
<p className="text-sm font-medium">{message}</p>
26+
</div>
27+
<button
28+
onClick={onClose}
29+
className="text-white hover:text-gray-200 transition-colors"
30+
aria-label="Close"
31+
>
32+
<svg
33+
xmlns="http://www.w3.org/2000/svg"
34+
width="20"
35+
height="20"
36+
viewBox="0 0 24 24"
37+
fill="none"
38+
stroke="currentColor"
39+
strokeWidth="2"
40+
strokeLinecap="round"
41+
strokeLinejoin="round"
42+
>
43+
<line x1="18" y1="6" x2="6" y2="18" />
44+
<line x1="6" y1="6" x2="18" y2="18" />
45+
</svg>
46+
</button>
47+
</div>
48+
</div>
49+
);
50+
}

0 commit comments

Comments
 (0)