Phase 1: Design system foundation — tokens, fonts, sidebar layout
- index.css: @import "tailwindcss" + @theme block with full colour palette, shadows, radii, typography tokens, skeleton animation - index.html: Google Sans + Roboto Mono fonts, title → Flight Radar - src/lib/utils.ts: cn() helper (clsx + tailwind-merge) - Layout.tsx: 256px fixed sidebar on desktop (active pill nav, logo, Developer section divider), sticky top bar with page title + New Scan CTA (hidden on /scans), bottom nav bar on mobile with pill indicator - package.json/lock: add lucide-react, clsx, tailwind-merge - .gitignore: unblock frontend/package*.json and frontend/src/lib/ Build: 0 TypeScript errors · 0 console errors · all 6 criteria pass Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
5
flight-comparator/.gitignore
vendored
5
flight-comparator/.gitignore
vendored
@@ -46,10 +46,12 @@ htmlcov/
|
|||||||
*.csv
|
*.csv
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# JSON — keep fixture and airport data, ignore everything else
|
# JSON — keep fixture, airport data, and frontend manifests
|
||||||
*.json
|
*.json
|
||||||
!data/airports_by_country.json
|
!data/airports_by_country.json
|
||||||
!tests/confirmed_flights.json
|
!tests/confirmed_flights.json
|
||||||
|
!frontend/package.json
|
||||||
|
!frontend/package-lock.json
|
||||||
|
|
||||||
# Database files
|
# Database files
|
||||||
*.db
|
*.db
|
||||||
@@ -57,3 +59,4 @@ htmlcov/
|
|||||||
# Node
|
# Node
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
|
!frontend/src/lib/
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<title>Flight Radar</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@300;400;500&family=Roboto+Mono:wght@400&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
4348
flight-comparator/frontend/package-lock.json
generated
Normal file
4348
flight-comparator/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
flight-comparator/frontend/package.json
Normal file
39
flight-comparator/frontend/package.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.13.5",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.575.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.13.1",
|
||||||
|
"tailwind-merge": "^3.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
|
"@types/node": "^24.10.13",
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"autoprefixer": "^10.4.24",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^4.2.1",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.48.0",
|
||||||
|
"vite": "^7.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,72 +1,198 @@
|
|||||||
import { Link, Outlet, useLocation } from 'react-router-dom';
|
import { Link, Outlet, useLocation } from 'react-router-dom';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
ScanSearch,
|
||||||
|
MapPin,
|
||||||
|
ScrollText,
|
||||||
|
PlaneTakeoff,
|
||||||
|
Plus,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
|
type NavItem = {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRIMARY_NAV: NavItem[] = [
|
||||||
|
{ icon: LayoutDashboard, label: 'Dashboard', path: '/' },
|
||||||
|
{ icon: ScanSearch, label: 'Scans', path: '/scans' },
|
||||||
|
{ icon: MapPin, label: 'Airports', path: '/airports' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SECONDARY_NAV: NavItem[] = [
|
||||||
|
{ icon: ScrollText, label: 'Logs', path: '/logs' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALL_NAV = [...PRIMARY_NAV, ...SECONDARY_NAV];
|
||||||
|
|
||||||
|
function getPageTitle(pathname: string): string {
|
||||||
|
if (pathname === '/') return 'Dashboard';
|
||||||
|
if (pathname.startsWith('/scans/')) return 'Scan Details';
|
||||||
|
if (pathname === '/scans') return 'New Scan';
|
||||||
|
if (pathname === '/airports') return 'Airports';
|
||||||
|
if (pathname === '/logs') return 'Logs';
|
||||||
|
return 'Flight Radar';
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarNavLink({ item, active }: { item: NavItem; active: boolean }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={item.path}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 mx-3 px-4 py-3 rounded-full text-sm font-medium transition-colors duration-150',
|
||||||
|
active
|
||||||
|
? 'bg-primary-container text-on-primary-container'
|
||||||
|
: 'text-on-surface-variant hover:bg-surface-2 hover:text-on-surface',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon size={20} aria-hidden="true" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
const isActive = (path: string): boolean => {
|
||||||
return location.pathname === path;
|
if (path === '/') return location.pathname === '/';
|
||||||
|
return location.pathname.startsWith(path);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const showNewScan = location.pathname !== '/scans';
|
||||||
<div className="min-h-screen bg-gray-50">
|
const pageTitle = getPageTitle(location.pathname);
|
||||||
{/* Header */}
|
|
||||||
<header className="bg-white shadow-sm border-b border-gray-200">
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
<div className="min-h-screen bg-bg">
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-center gap-4">
|
|
||||||
<h1 className="text-2xl font-bold text-blue-600 flex items-center">
|
{/* ── Sidebar (desktop ≥ lg) ───────────────────────────────── */}
|
||||||
<span className="text-3xl mr-2">✈️</span>
|
<aside
|
||||||
Flight Radar
|
className="hidden lg:flex lg:fixed lg:inset-y-0 lg:left-0 lg:w-64 lg:flex-col bg-surface border-r border-outline z-30"
|
||||||
</h1>
|
aria-label="Sidebar navigation"
|
||||||
<nav className="flex flex-wrap justify-center gap-2">
|
>
|
||||||
<Link
|
{/* Logo */}
|
||||||
to="/"
|
<div className="flex items-center gap-3 px-7 h-16 border-b border-outline shrink-0">
|
||||||
className={`px-3 py-2 rounded-md text-sm font-medium ${
|
<PlaneTakeoff size={22} className="text-primary" aria-hidden="true" />
|
||||||
isActive('/')
|
<span className="text-lg font-medium text-on-surface tracking-tight">
|
||||||
? 'bg-blue-500 text-white'
|
Flight Radar
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
</span>
|
||||||
}`}
|
</div>
|
||||||
>
|
|
||||||
Dashboard
|
{/* Primary nav */}
|
||||||
</Link>
|
<nav className="flex-1 py-3 overflow-y-auto" aria-label="Primary">
|
||||||
<Link
|
<ul className="space-y-0.5" role="list">
|
||||||
to="/scans"
|
{PRIMARY_NAV.map((item) => (
|
||||||
className={`px-3 py-2 rounded-md text-sm font-medium ${
|
<li key={item.path}>
|
||||||
isActive('/scans')
|
<SidebarNavLink item={item} active={isActive(item.path)} />
|
||||||
? 'bg-blue-500 text-white'
|
</li>
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
))}
|
||||||
}`}
|
</ul>
|
||||||
>
|
|
||||||
Scans
|
{/* Divider + developer section */}
|
||||||
</Link>
|
<div className="mt-4 pt-4 border-t border-outline mx-4">
|
||||||
<Link
|
<p className="px-4 mb-1 text-xs font-medium uppercase tracking-wider text-on-surface-variant opacity-60">
|
||||||
to="/airports"
|
Developer
|
||||||
className={`px-3 py-2 rounded-md text-sm font-medium ${
|
</p>
|
||||||
isActive('/airports')
|
<ul className="space-y-0.5" role="list">
|
||||||
? 'bg-blue-500 text-white'
|
{SECONDARY_NAV.map((item) => (
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
<li key={item.path}>
|
||||||
}`}
|
<SidebarNavLink item={item} active={isActive(item.path)} />
|
||||||
>
|
</li>
|
||||||
Airports
|
))}
|
||||||
</Link>
|
</ul>
|
||||||
<Link
|
</div>
|
||||||
to="/logs"
|
</nav>
|
||||||
className={`px-3 py-2 rounded-md text-sm font-medium ${
|
</aside>
|
||||||
isActive('/logs')
|
|
||||||
? 'bg-blue-500 text-white'
|
{/* ── Main content column ──────────────────────────────────── */}
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
<div className="lg:pl-64 flex flex-col min-h-screen">
|
||||||
}`}
|
|
||||||
>
|
{/* Top bar */}
|
||||||
Logs
|
<header className="sticky top-0 z-20 flex items-center justify-between h-14 lg:h-16 px-4 lg:px-6 bg-surface border-b border-outline shadow-sm shrink-0">
|
||||||
</Link>
|
|
||||||
</nav>
|
{/* Mobile: wordmark */}
|
||||||
</div>
|
<div className="flex items-center gap-2 lg:hidden">
|
||||||
</div>
|
<PlaneTakeoff size={20} className="text-primary" aria-hidden="true" />
|
||||||
</header>
|
<span className="text-base font-medium text-on-surface">
|
||||||
|
Flight Radar
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop: page title */}
|
||||||
|
<h1 className="hidden lg:block text-xl font-medium text-on-surface">
|
||||||
|
{pageTitle}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{showNewScan && (
|
||||||
|
<Link
|
||||||
|
to="/scans"
|
||||||
|
className="flex items-center gap-1.5 h-9 px-4 rounded-full bg-primary text-on-primary text-sm font-medium hover:opacity-90 transition-opacity shadow-sm"
|
||||||
|
>
|
||||||
|
<Plus size={16} aria-hidden="true" />
|
||||||
|
<span>New Scan</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Page content */}
|
||||||
|
<main className="flex-1 px-4 py-6 lg:px-6 lg:py-8 pb-28 lg:pb-8">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Bottom nav (mobile < lg) ─────────────────────────────── */}
|
||||||
|
<nav
|
||||||
|
className="lg:hidden fixed bottom-0 left-0 right-0 z-30 bg-surface border-t border-outline"
|
||||||
|
aria-label="Main navigation"
|
||||||
|
>
|
||||||
|
<ul className="flex" role="list">
|
||||||
|
{ALL_NAV.map((item) => {
|
||||||
|
const active = isActive(item.path);
|
||||||
|
return (
|
||||||
|
<li key={item.path} className="flex-1">
|
||||||
|
<Link
|
||||||
|
to={item.path}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className="flex flex-col items-center gap-1 pt-2 pb-3 px-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center w-14 h-8 rounded-full transition-colors duration-150',
|
||||||
|
active ? 'bg-primary-container' : '',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon
|
||||||
|
size={20}
|
||||||
|
className={cn(
|
||||||
|
active
|
||||||
|
? 'text-on-primary-container'
|
||||||
|
: 'text-on-surface-variant',
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-xs font-medium',
|
||||||
|
active ? 'text-primary' : 'text-on-surface-variant',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
||||||
<Outlet />
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,81 @@
|
|||||||
@tailwind base;
|
@import "tailwindcss";
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
/* Brand colours */
|
||||||
|
--color-primary: #1A73E8;
|
||||||
|
--color-primary-container: #D2E3FC;
|
||||||
|
--color-on-primary: #FFFFFF;
|
||||||
|
--color-on-primary-container: #003E8C;
|
||||||
|
--color-secondary: #0F9D58;
|
||||||
|
--color-secondary-container: #C8E6C9;
|
||||||
|
--color-on-secondary: #FFFFFF;
|
||||||
|
--color-on-secondary-container: #00391A;
|
||||||
|
--color-tertiary: #F4B400;
|
||||||
|
--color-tertiary-container: #FFF0C0;
|
||||||
|
--color-on-tertiary: #3B2A00;
|
||||||
|
--color-error: #D93025;
|
||||||
|
--color-error-container: #FDECEA;
|
||||||
|
--color-on-error: #FFFFFF;
|
||||||
|
|
||||||
|
/* Surface roles */
|
||||||
|
--color-bg: #F8F9FA;
|
||||||
|
--color-surface: #FFFFFF;
|
||||||
|
--color-surface-2: #F1F3F4;
|
||||||
|
--color-surface-variant: #E8EAED;
|
||||||
|
--color-on-surface: #202124;
|
||||||
|
--color-on-surface-variant: #5F6368;
|
||||||
|
--color-outline: #DADCE0;
|
||||||
|
--color-outline-variant: #F1F3F4;
|
||||||
|
|
||||||
|
/* Shape */
|
||||||
|
--radius-xs: 4px;
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--radius-md: 12px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
--radius-xl: 20px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-sans: 'Google Sans', 'Roboto', system-ui, -apple-system, sans-serif;
|
||||||
|
--font-mono: 'Roboto Mono', 'JetBrains Mono', ui-monospace, monospace;
|
||||||
|
|
||||||
|
/* Elevation shadows */
|
||||||
|
--shadow-level-1: 0 1px 2px rgba(0,0,0,.08);
|
||||||
|
--shadow-level-2: 0 2px 6px rgba(0,0,0,.10);
|
||||||
|
--shadow-level-3: 0 4px 12px rgba(0,0,0,.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base */
|
||||||
|
body {
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeleton shimmer */
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(90deg, #E8EAED 25%, #F1F3F4 50%, #E8EAED 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slide-up (toasts) */
|
||||||
@keyframes slide-up {
|
@keyframes slide-up {
|
||||||
from {
|
from { transform: translateY(100%); opacity: 0; }
|
||||||
transform: translateY(100%);
|
to { transform: translateY(0); opacity: 1; }
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.animate-slide-up { animation: slide-up 0.3s ease-out; }
|
||||||
|
|
||||||
.animate-slide-up {
|
/* Reduced motion */
|
||||||
animation: slide-up 0.3s ease-out;
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
flight-comparator/frontend/src/lib/utils.ts
Normal file
6
flight-comparator/frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user