From 111e6c6b11494f1b0ff37840bbe3bd463975a992 Mon Sep 17 00:00:00 2001 From: Pietro Bonaldo Date: Wed, 24 Jun 2026 19:23:36 +0200 Subject: [PATCH 01/28] ui-refactor M1: vite + tailwind theme + app shell Replace Parcel with Vite, introduce a 'paper FIDS' design language (cream background, yellow accent, JetBrains Mono + Space Grotesk), and add a new app shell with a hamburger right-side sheet nav. Existing pages still render in a transitional state; they will be rebuilt in subsequent milestones. Co-Authored-By: Claude Opus 4.7 (1M context) --- .postcssrc | 6 -- Dockerfile | 2 +- client/App.tsx | 25 +++--- client/components/shell/AppShell.tsx | 64 +++++++++++++++ client/components/shell/BoardClock.tsx | 24 ++++++ client/components/shell/NavSheet.tsx | 107 +++++++++++++++++++++++++ client/index.html | 25 +++--- client/index.js | 9 --- client/lib/cn.ts | 6 ++ client/main.tsx | 27 +++++++ client/style.css | 69 ++++++++++++++-- package.json | 37 ++++++--- postcss.config.js | 6 ++ tailwind.config.js | 73 ++++++++++++++--- tsconfig.json | 26 ++++++ vite.config.ts | 26 ++++++ 16 files changed, 459 insertions(+), 73 deletions(-) delete mode 100644 .postcssrc create mode 100644 client/components/shell/AppShell.tsx create mode 100644 client/components/shell/BoardClock.tsx create mode 100644 client/components/shell/NavSheet.tsx delete mode 100644 client/index.js create mode 100644 client/lib/cn.ts create mode 100644 client/main.tsx create mode 100644 postcss.config.js create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.postcssrc b/.postcssrc deleted file mode 100644 index 39ef0bb..0000000 --- a/.postcssrc +++ /dev/null @@ -1,6 +0,0 @@ -{ - "plugins": { - "tailwindcss": {}, - "cssnano": {} - } -} diff --git a/Dockerfile b/Dockerfile index 988a57c..4ce9fa5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ RUN npm i --package-lock-only RUN npm ci COPY ./client ./client -COPY ./tailwind.config.js ./.postcssrc ./ +COPY ./tailwind.config.js ./postcss.config.js ./vite.config.ts ./tsconfig.json ./ RUN npm run build # RUNTIME diff --git a/client/App.tsx b/client/App.tsx index 2e5cc70..9d34c97 100644 --- a/client/App.tsx +++ b/client/App.tsx @@ -1,29 +1,22 @@ -import React from 'react'; -import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'; +import { BrowserRouter, Routes, Route } from 'react-router-dom' -import { BASE_URL } from './api'; +import { BASE_URL } from './api' -import Login from './pages/Login'; -import New from './pages/New'; +import Login from './pages/Login' +import New from './pages/New' import Home from './pages/Home' import AllFlights from './pages/AllFlights' -import Statistics from './pages/Statistics'; -import Settings from './pages/Settings'; +import Statistics from './pages/Statistics' +import Settings from './pages/Settings' -import Navbar from './components/Navbar'; +import { AppShell } from './components/shell/AppShell' export function App() { return ( } /> - - -
- -
- }> + }> } /> } /> } /> @@ -32,5 +25,5 @@ export function App() {
- ); + ) } diff --git a/client/components/shell/AppShell.tsx b/client/components/shell/AppShell.tsx new file mode 100644 index 0000000..bdbb0e2 --- /dev/null +++ b/client/components/shell/AppShell.tsx @@ -0,0 +1,64 @@ +import { useState } from 'react' +import { Link, Outlet, useLocation } from 'react-router-dom' +import { Menu } from 'lucide-react' +import { NavSheet } from './NavSheet' +import { BoardClock } from './BoardClock' + +const TITLES: Record = { + '/': 'Dashboard', + '/flights': 'Flights', + '/new': 'New Flight', + '/statistics': 'Statistics', + '/settings': 'Settings', +} + +function pageTitle(pathname: string) { + if (TITLES[pathname]) return TITLES[pathname] + const match = Object.keys(TITLES) + .filter((p) => p !== '/') + .find((p) => pathname.startsWith(p)) + return match ? TITLES[match] : 'Jetlog' +} + +export function AppShell() { + const [menuOpen, setMenuOpen] = useState(false) + const location = useLocation() + + return ( +
+
+
+
+ + Jetlog + + / + + {pageTitle(location.pathname)} + +
+ +
+ + +
+
+
+ +
+ +
+ + setMenuOpen(false)} /> +
+ ) +} diff --git a/client/components/shell/BoardClock.tsx b/client/components/shell/BoardClock.tsx new file mode 100644 index 0000000..2f0bf93 --- /dev/null +++ b/client/components/shell/BoardClock.tsx @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react' + +export function BoardClock() { + const [now, setNow] = useState(() => new Date()) + + useEffect(() => { + const t = window.setInterval(() => setNow(new Date()), 1000) + return () => window.clearInterval(t) + }, []) + + const hh = String(now.getUTCHours()).padStart(2, '0') + const mm = String(now.getUTCMinutes()).padStart(2, '0') + const ss = String(now.getUTCSeconds()).padStart(2, '0') + + return ( +
+ UTC + + {hh}:{mm} + :{ss} + +
+ ) +} diff --git a/client/components/shell/NavSheet.tsx b/client/components/shell/NavSheet.tsx new file mode 100644 index 0000000..08a13f7 --- /dev/null +++ b/client/components/shell/NavSheet.tsx @@ -0,0 +1,107 @@ +import { useEffect } from 'react' +import { Link, useLocation, useNavigate } from 'react-router-dom' +import { Home, PlaneTakeoff, Plus, BarChart3, Settings as Cog, LogOut, X } from 'lucide-react' +import { cn } from '@/lib/cn' +import TokenStorage from '@/storage/tokenStorage' + +interface NavSheetProps { + open: boolean + onClose: () => void +} + +const NAV_ITEMS = [ + { to: '/', label: 'Home', icon: Home }, + { to: '/flights', label: 'Flights', icon: PlaneTakeoff }, + { to: '/new', label: 'New Flight', icon: Plus }, + { to: '/statistics', label: 'Statistics', icon: BarChart3 }, + { to: '/settings', label: 'Settings', icon: Cog }, +] + +export function NavSheet({ open, onClose }: NavSheetProps) { + const location = useLocation() + const navigate = useNavigate() + + useEffect(() => { + if (!open) return + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + window.addEventListener('keydown', onKey) + document.body.style.overflow = 'hidden' + return () => { + window.removeEventListener('keydown', onKey) + document.body.style.overflow = '' + } + }, [open, onClose]) + + const handleLogout = () => { + TokenStorage.clearToken() + onClose() + navigate('/login') + } + + if (!open) return null + + return ( +
+
+ +
+ ) +} diff --git a/client/index.html b/client/index.html index cbf1a41..6485cf5 100644 --- a/client/index.html +++ b/client/index.html @@ -2,24 +2,19 @@ - - - + + + + + + Jetlog -
- + - - diff --git a/client/index.js b/client/index.js deleted file mode 100644 index 7d17473..0000000 --- a/client/index.js +++ /dev/null @@ -1,9 +0,0 @@ -import { createRoot } from 'react-dom/client'; -import { App } from './App'; - -import TokenStorage from './storage/tokenStorage'; -TokenStorage.loadStoredToken(); - -const container = document.getElementById("app"); -const root = createRoot(container); -root.render(); diff --git a/client/lib/cn.ts b/client/lib/cn.ts new file mode 100644 index 0000000..62987cf --- /dev/null +++ b/client/lib/cn.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/client/main.tsx b/client/main.tsx new file mode 100644 index 0000000..c2f171f --- /dev/null +++ b/client/main.tsx @@ -0,0 +1,27 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { App } from './App' +import TokenStorage from './storage/tokenStorage' +import './style.css' + +TokenStorage.loadStoredToken() + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + staleTime: 30_000, + }, + }, +}) + +const container = document.getElementById('app')! +createRoot(container).render( + + + + + , +) diff --git a/client/style.css b/client/style.css index dfa716a..f0a128a 100644 --- a/client/style.css +++ b/client/style.css @@ -2,16 +2,73 @@ @tailwind components; @tailwind utilities; +@layer base { + :root { + color-scheme: light; + --tnum: 'tnum' 1, 'lnum' 1; + } + + html, + body, + #app { + height: 100%; + } + + body { + @apply bg-paper text-ink font-sans antialiased; + font-feature-settings: var(--tnum); + } + + /* Subtle paper texture: faint horizontal scanlines */ + body::before { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + background-image: repeating-linear-gradient( + 0deg, + rgba(20, 19, 15, 0.018) 0px, + rgba(20, 19, 15, 0.018) 1px, + transparent 1px, + transparent 3px + ); + z-index: 1; + } + + #app { + position: relative; + z-index: 2; + } + + *:focus-visible { + @apply outline-none ring-2 ring-accent ring-offset-2 ring-offset-paper; + } + + ::selection { + background-color: #f5c518; + color: #14130f; + } +} + @layer components { - .container { - @apply p-4 mr-5 mb-4 grow border border-gray-400 rounded shadow-md; + .board-label { + @apply font-mono uppercase tracking-board text-ink-muted text-[11px]; + } + + .board-value { + @apply font-mono tabular-nums text-ink; + } + + .hairline { + @apply border-rule; } - hr { - @apply w-full mb-4 + .panel { + @apply bg-paper border border-rule; } - span { - @apply font-mono text-primary-500 + /* Transitional: old pages still use these classes; will be removed as pages migrate. */ + .legacy-container { + @apply p-4 mr-5 mb-4 grow border border-rule rounded shadow-paper; } } diff --git a/package.json b/package.json index ca34e52..1fc9655 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,33 @@ { "name": "jetlog", "version": "1.1.5", - "source": "client/index.html", + "type": "module", "scripts": { - "build": "PARCEL_WORKERS=0 parcel build --public-url ./ --dist-dir dist", - "watch": "PARCEL_WORKERS=0 parcel watch --dist-dir dist" + "dev": "vite", + "build": "vite build", + "preview": "vite preview" }, "dependencies": { + "@hookform/resolvers": "^3.9.0", + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-popover": "^1.1.2", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-switch": "^1.1.1", + "@radix-ui/react-tabs": "^1.1.1", + "@tanstack/react-query": "^5.59.0", + "@tanstack/react-table": "^8.20.5", "axios": "^1.7.2", - "parcel": "^2.0.0", - "postcss": "^8.4.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "lucide-react": "^0.451.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-hook-form": "^7.53.0", "react-router-dom": "^6.24.0", "react-simple-maps": "^3.0.0", - "tailwindcss": "^3.4.4" + "recharts": "^2.13.0", + "tailwind-merge": "^2.5.2", + "zod": "^3.23.8" }, "overrides": { "react-simple-maps": { @@ -27,8 +40,14 @@ "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@types/react-simple-maps": "^3.0.6", - "buffer": "^6.0.3", - "cssnano": "^7.0.4", - "process": "^0.11.10" + "@vitejs/plugin-react": "^4.3.2", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.13", + "typescript": "^5.6.2", + "vite": "^5.4.8" + }, + "allowScripts": { + "esbuild@0.21.5": true } } diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..d41ad63 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/tailwind.config.js b/tailwind.config.js index bf7465d..87c3840 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,19 +1,70 @@ -const colors = require('tailwindcss/colors') - /** @type {import('tailwindcss').Config} */ -module.exports = { - mode: "jit", - content: [ - "./client/**/*.{html,js,ts,tsx}" - ], +export default { + content: ['./client/**/*.{html,js,ts,tsx}'], theme: { extend: { colors: { - primary: colors.yellow, - gray: colors.neutral, - danger: colors.red + paper: { + DEFAULT: '#FAF7F0', + soft: '#F3EFE3', + stripe: '#F0EBDC', + }, + ink: { + DEFAULT: '#14130F', + soft: '#3B392F', + muted: '#76725F', + faint: '#A8A48F', + }, + rule: '#D9D2BC', + accent: { + DEFAULT: '#F5C518', + soft: '#FBE680', + deep: '#C99A00', + }, + danger: { + DEFAULT: '#B23B2A', + soft: '#E8B8B0', + }, + ok: { + DEFAULT: '#3E7A4F', + }, + }, + fontFamily: { + mono: ['"JetBrains Mono"', 'ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace'], + sans: ['"Space Grotesk"', 'ui-sans-serif', 'system-ui', 'sans-serif'], + }, + borderRadius: { + DEFAULT: '2px', + sm: '2px', + md: '4px', + }, + boxShadow: { + paper: '0 1px 0 0 rgba(20, 19, 15, 0.06)', + }, + letterSpacing: { + board: '0.12em', + }, + keyframes: { + 'flap-in': { + '0%': { transform: 'rotateX(-90deg)', opacity: '0' }, + '60%': { transform: 'rotateX(10deg)', opacity: '1' }, + '100%': { transform: 'rotateX(0deg)', opacity: '1' }, + }, + 'slide-in-right': { + '0%': { transform: 'translateX(100%)' }, + '100%': { transform: 'translateX(0)' }, + }, + 'fade-in': { + '0%': { opacity: '0' }, + '100%': { opacity: '1' }, + }, + }, + animation: { + 'flap-in': 'flap-in 500ms cubic-bezier(0.4, 0, 0.2, 1)', + 'slide-in-right': 'slide-in-right 220ms cubic-bezier(0.4, 0, 0.2, 1)', + 'fade-in': 'fade-in 180ms ease-out', }, }, }, - plugins: [] + plugins: [], } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8445de9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": false, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": false, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "baseUrl": ".", + "paths": { + "@/*": ["client/*"] + } + }, + "include": ["client"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..aec4425 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'node:path' + +export default defineConfig({ + root: 'client', + base: './', + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, 'client'), + }, + }, + build: { + outDir: '../dist', + emptyOutDir: true, + target: 'es2022', + }, + server: { + port: 5173, + proxy: { + '/api': 'http://localhost:3000', + '/config': 'http://localhost:3000', + }, + }, +}) From 876f29711e271714a9511eeaa563ccb676532fbf Mon Sep 17 00:00:00 2001 From: Pietro Bonaldo Date: Wed, 24 Jun 2026 19:25:48 +0200 Subject: [PATCH 02/28] ui-refactor M2: design-system primitives + tanstack query layer Add UI primitives (Button, Input, Label, Panel, Badge, Dialog, Sheet, Switch, Tabs, Combobox, Select, DataBlock, SplitFlap, Spinner) in client/components/ui, all styled in the paper/FIDS language. Add a typed query/mutation layer (client/api/queries.ts) on top of the existing axios client, providing useFlights / useStatistics / useCurrentUser / useDecorations / etc. Old API singleton is kept as the transport. Co-Authored-By: Claude Opus 4.7 (1M context) --- client/api/queries.ts | 143 +++++++++++++++++++++++++++++ client/components/ui/Badge.tsx | 27 ++++++ client/components/ui/Button.tsx | 47 ++++++++++ client/components/ui/Combobox.tsx | 130 ++++++++++++++++++++++++++ client/components/ui/DataBlock.tsx | 22 +++++ client/components/ui/Dialog.tsx | 48 ++++++++++ client/components/ui/Input.tsx | 20 ++++ client/components/ui/Label.tsx | 21 +++++ client/components/ui/Panel.tsx | 39 ++++++++ client/components/ui/Select.tsx | 27 ++++++ client/components/ui/Sheet.tsx | 43 +++++++++ client/components/ui/Spinner.tsx | 14 +++ client/components/ui/SplitFlap.tsx | 45 +++++++++ client/components/ui/Switch.tsx | 31 +++++++ client/components/ui/Tabs.tsx | 51 ++++++++++ 15 files changed, 708 insertions(+) create mode 100644 client/api/queries.ts create mode 100644 client/components/ui/Badge.tsx create mode 100644 client/components/ui/Button.tsx create mode 100644 client/components/ui/Combobox.tsx create mode 100644 client/components/ui/DataBlock.tsx create mode 100644 client/components/ui/Dialog.tsx create mode 100644 client/components/ui/Input.tsx create mode 100644 client/components/ui/Label.tsx create mode 100644 client/components/ui/Panel.tsx create mode 100644 client/components/ui/Select.tsx create mode 100644 client/components/ui/Sheet.tsx create mode 100644 client/components/ui/Spinner.tsx create mode 100644 client/components/ui/SplitFlap.tsx create mode 100644 client/components/ui/Switch.tsx create mode 100644 client/components/ui/Tabs.tsx diff --git a/client/api/queries.ts b/client/api/queries.ts new file mode 100644 index 0000000..3356633 --- /dev/null +++ b/client/api/queries.ts @@ -0,0 +1,143 @@ +import { + useMutation, + useQuery, + useQueryClient, + type UseQueryOptions, +} from '@tanstack/react-query' +import API, { ENABLE_EXTERNAL_APIS } from '@/api' +import type { Flight, Airport, Airline, Statistics, User, Coord, Trajectory } from '@/models' + +export { ENABLE_EXTERNAL_APIS } + +// ---------- Users ---------- + +export function useCurrentUser() { + return useQuery({ + queryKey: ['user', 'me'], + queryFn: () => API.get('/users/me'), + staleTime: 5 * 60_000, + }) +} + +export function useUsernames() { + return useQuery({ + queryKey: ['users'], + queryFn: () => API.get('/users'), + staleTime: 60_000, + }) +} + +// ---------- Flights ---------- + +export interface FlightsFilters { + limit?: number + offset?: number + order?: 'ascending' | 'descending' + sort?: 'date' | 'duration' | 'distance' | 'seat' + start?: string + end?: string + username?: string + metric?: boolean +} + +export function useFlights(filters: FlightsFilters) { + return useQuery({ + queryKey: ['flights', filters], + queryFn: () => API.get('/flights', filters), + }) +} + +export function useFlight(id?: number) { + return useQuery({ + queryKey: ['flight', id], + queryFn: () => API.get('/flights', { id }), + enabled: id !== undefined && id !== null, + }) +} + +export function useDeleteFlight() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: number) => API.delete(`/flights/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['flights'] }), + }) +} + +export function useUpdateFlight() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, data }: { id: number; data: Partial }) => + API.patch(`/flights/${id}`, data), + onSuccess: (_d, { id }) => { + qc.invalidateQueries({ queryKey: ['flights'] }) + qc.invalidateQueries({ queryKey: ['flight', id] }) + }, + }) +} + +export function useCreateFlight() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (data: Partial) => API.post('/flights', data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['flights'] }), + }) +} + +// ---------- Statistics ---------- + +export interface StatsFilters { + start?: string + end?: string + username?: string + metric?: boolean +} + +export function useStatistics(filters: StatsFilters = {}, options?: UseQueryOptions) { + return useQuery({ + queryKey: ['statistics', filters], + queryFn: () => API.get('/statistics', filters), + ...options, + }) +} + +// ---------- Geography ---------- + +export function useWorldGeography(visited: boolean) { + return useQuery({ + queryKey: ['geo', 'world', visited], + queryFn: () => API.get('/geography/world', { visited }), + staleTime: 10 * 60_000, + }) +} + +export interface Decorations { + markers: Coord[] + lines: Trajectory[] +} + +export function useDecorations(flightId?: number) { + return useQuery({ + queryKey: ['geo', 'decorations', flightId], + queryFn: () => + API.get('/geography/decorations', flightId ? { flight_id: flightId } : {}), + staleTime: 60_000, + }) +} + +// ---------- Airports / Airlines ---------- + +export function searchAirports(q: string): Promise { + return API.get('/airports', { q }) +} + +export function searchAirlines(q: string): Promise { + return API.get('/airlines', { q }) +} + +export function fetchAirport(icao: string): Promise { + return API.get(`/airports/${icao}`) +} + +export function fetchAirline(icao: string): Promise { + return API.get(`/airlines/${icao}`) +} diff --git a/client/components/ui/Badge.tsx b/client/components/ui/Badge.tsx new file mode 100644 index 0000000..5173696 --- /dev/null +++ b/client/components/ui/Badge.tsx @@ -0,0 +1,27 @@ +import type { HTMLAttributes } from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '@/lib/cn' + +const badge = cva( + 'inline-flex items-center gap-1.5 px-2 py-0.5 font-mono uppercase tracking-board text-[10px] border', + { + variants: { + variant: { + default: 'bg-paper border-rule text-ink-soft', + accent: 'bg-accent-soft border-accent-deep text-ink', + ok: 'bg-paper border-ok text-ok', + danger: 'bg-paper border-danger text-danger', + muted: 'bg-paper-soft border-rule text-ink-muted', + }, + }, + defaultVariants: { variant: 'default' }, + }, +) + +export interface BadgeProps + extends HTMLAttributes, + VariantProps {} + +export function Badge({ className, variant, ...props }: BadgeProps) { + return +} diff --git a/client/components/ui/Button.tsx b/client/components/ui/Button.tsx new file mode 100644 index 0000000..476a014 --- /dev/null +++ b/client/components/ui/Button.tsx @@ -0,0 +1,47 @@ +import { forwardRef, type ButtonHTMLAttributes } from 'react' +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '@/lib/cn' + +const button = cva( + 'inline-flex items-center justify-center gap-2 font-mono uppercase tracking-board text-[12px] border transition-colors select-none disabled:opacity-40 disabled:pointer-events-none whitespace-nowrap', + { + variants: { + variant: { + default: + 'bg-paper text-ink border-ink hover:bg-ink hover:text-paper', + accent: + 'bg-accent text-ink border-accent-deep hover:bg-accent-deep hover:text-paper', + ghost: + 'bg-transparent text-ink border-transparent hover:bg-paper-soft', + danger: + 'bg-paper text-danger border-danger hover:bg-danger hover:text-paper', + outline: + 'bg-transparent text-ink border-rule hover:border-ink hover:bg-paper-soft', + }, + size: { + sm: 'h-7 px-2.5', + md: 'h-9 px-3.5', + lg: 'h-11 px-5 text-[13px]', + icon: 'h-9 w-9', + }, + }, + defaultVariants: { variant: 'default', size: 'md' }, + }, +) + +export interface ButtonProps + extends ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +export const Button = forwardRef( + ({ className, variant, size, asChild, ...props }, ref) => { + const Comp = asChild ? Slot : 'button' + return ( + + ) + }, +) +Button.displayName = 'Button' diff --git a/client/components/ui/Combobox.tsx b/client/components/ui/Combobox.tsx new file mode 100644 index 0000000..10e8a27 --- /dev/null +++ b/client/components/ui/Combobox.tsx @@ -0,0 +1,130 @@ +import { useEffect, useRef, useState } from 'react' +import * as Popover from '@radix-ui/react-popover' +import { ChevronDown, Search } from 'lucide-react' +import { cn } from '@/lib/cn' + +export interface ComboboxOption { + value: string + label: string + sub?: string + raw?: T +} + +interface ComboboxProps { + value?: string + placeholder?: string + onSearch: (query: string) => Promise[]> + onSelect: (option: ComboboxOption) => void + displayValue?: string + disabled?: boolean + minChars?: number + className?: string +} + +export function Combobox({ + placeholder = 'Search...', + onSearch, + onSelect, + displayValue, + disabled, + minChars = 2, + className, +}: ComboboxProps) { + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const [options, setOptions] = useState[]>([]) + const [loading, setLoading] = useState(false) + const debounceRef = useRef(null) + + useEffect(() => { + if (debounceRef.current) window.clearTimeout(debounceRef.current) + if (query.length < minChars) { + setOptions([]) + return + } + setLoading(true) + debounceRef.current = window.setTimeout(async () => { + try { + const results = await onSearch(query) + setOptions(results) + } finally { + setLoading(false) + } + }, 200) + return () => { + if (debounceRef.current) window.clearTimeout(debounceRef.current) + } + }, [query, minChars, onSearch]) + + return ( + + + + + + +
+ + setQuery(e.target.value)} + placeholder={placeholder} + className="flex-1 bg-transparent font-mono text-sm focus:outline-none" + /> +
+
+ {query.length < minChars && ( +

+ Type at least {minChars} characters +

+ )} + {loading && ( +

Searching...

+ )} + {!loading && query.length >= minChars && options.length === 0 && ( +

No results

+ )} + {options.map((opt) => ( + + ))} +
+
+
+
+ ) +} diff --git a/client/components/ui/DataBlock.tsx b/client/components/ui/DataBlock.tsx new file mode 100644 index 0000000..a48663a --- /dev/null +++ b/client/components/ui/DataBlock.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from 'react' +import { cn } from '@/lib/cn' + +interface DataBlockProps { + label: string + value: ReactNode + sub?: ReactNode + className?: string + valueClassName?: string +} + +export function DataBlock({ label, value, sub, className, valueClassName }: DataBlockProps) { + return ( +
+ {label} + + {value} + + {sub && {sub}} +
+ ) +} diff --git a/client/components/ui/Dialog.tsx b/client/components/ui/Dialog.tsx new file mode 100644 index 0000000..ff72b1c --- /dev/null +++ b/client/components/ui/Dialog.tsx @@ -0,0 +1,48 @@ +import * as RDialog from '@radix-ui/react-dialog' +import { X } from 'lucide-react' +import type { ReactNode } from 'react' +import { cn } from '@/lib/cn' + +interface DialogProps { + open: boolean + onOpenChange: (open: boolean) => void + title?: string + description?: string + children: ReactNode + className?: string +} + +export function Dialog({ open, onOpenChange, title, description, children, className }: DialogProps) { + return ( + + + + +
+ {title} + + + +
+ {description && ( + + {description} + + )} +
{children}
+
+
+
+ ) +} diff --git a/client/components/ui/Input.tsx b/client/components/ui/Input.tsx new file mode 100644 index 0000000..222f2f1 --- /dev/null +++ b/client/components/ui/Input.tsx @@ -0,0 +1,20 @@ +import { forwardRef, type InputHTMLAttributes } from 'react' +import { cn } from '@/lib/cn' + +export const Input = forwardRef>( + ({ className, ...props }, ref) => ( + + ), +) +Input.displayName = 'Input' diff --git a/client/components/ui/Label.tsx b/client/components/ui/Label.tsx new file mode 100644 index 0000000..9ccf4dc --- /dev/null +++ b/client/components/ui/Label.tsx @@ -0,0 +1,21 @@ +import type { LabelHTMLAttributes } from 'react' +import { cn } from '@/lib/cn' + +interface LabelProps extends LabelHTMLAttributes { + required?: boolean +} + +export function Label({ className, required, children, ...props }: LabelProps) { + return ( + + ) +} diff --git a/client/components/ui/Panel.tsx b/client/components/ui/Panel.tsx new file mode 100644 index 0000000..fd7859a --- /dev/null +++ b/client/components/ui/Panel.tsx @@ -0,0 +1,39 @@ +import type { HTMLAttributes } from 'react' +import { cn } from '@/lib/cn' + +export function Panel({ className, ...props }: HTMLAttributes) { + return ( +
+ ) +} + +export function PanelHeader({ className, ...props }: HTMLAttributes) { + return ( +
+ ) +} + +export function PanelTitle({ className, ...props }: HTMLAttributes) { + return ( +

+ ) +} + +export function PanelBody({ className, ...props }: HTMLAttributes) { + return
+} diff --git a/client/components/ui/Select.tsx b/client/components/ui/Select.tsx new file mode 100644 index 0000000..87a9680 --- /dev/null +++ b/client/components/ui/Select.tsx @@ -0,0 +1,27 @@ +import { forwardRef, type SelectHTMLAttributes } from 'react' +import { ChevronDown } from 'lucide-react' +import { cn } from '@/lib/cn' + +export const Select = forwardRef>( + ({ className, children, ...props }, ref) => ( +
+ + +
+ ), +) +Select.displayName = 'Select' diff --git a/client/components/ui/Sheet.tsx b/client/components/ui/Sheet.tsx new file mode 100644 index 0000000..a53551a --- /dev/null +++ b/client/components/ui/Sheet.tsx @@ -0,0 +1,43 @@ +import * as RDialog from '@radix-ui/react-dialog' +import { X } from 'lucide-react' +import type { ReactNode } from 'react' +import { cn } from '@/lib/cn' + +interface SheetProps { + open: boolean + onOpenChange: (open: boolean) => void + title?: string + side?: 'right' | 'bottom' + children: ReactNode + className?: string +} + +export function Sheet({ open, onOpenChange, title, side = 'right', children, className }: SheetProps) { + const sideClasses = + side === 'right' + ? 'right-0 top-0 h-full w-[92%] max-w-md border-l animate-slide-in-right' + : 'left-0 right-0 bottom-0 max-h-[85vh] border-t animate-fade-in' + + return ( + + + + +
+ {title} + + + +
+
{children}
+
+
+
+ ) +} diff --git a/client/components/ui/Spinner.tsx b/client/components/ui/Spinner.tsx new file mode 100644 index 0000000..77d9c7a --- /dev/null +++ b/client/components/ui/Spinner.tsx @@ -0,0 +1,14 @@ +import { cn } from '@/lib/cn' + +export function Spinner({ className }: { className?: string }) { + return ( + + ) +} diff --git a/client/components/ui/SplitFlap.tsx b/client/components/ui/SplitFlap.tsx new file mode 100644 index 0000000..3c43a9b --- /dev/null +++ b/client/components/ui/SplitFlap.tsx @@ -0,0 +1,45 @@ +import { useEffect, useState, type CSSProperties } from 'react' +import { cn } from '@/lib/cn' + +interface SplitFlapProps { + value: string | number + className?: string + delay?: number +} + +/** + * One-shot split-flap reveal on mount. Each character flaps in with a staggered delay. + * Falls back gracefully if prefers-reduced-motion is set. + */ +export function SplitFlap({ value, className, delay = 0 }: SplitFlapProps) { + const chars = String(value).split('') + const [reduced, setReduced] = useState(false) + + useEffect(() => { + const mq = window.matchMedia('(prefers-reduced-motion: reduce)') + setReduced(mq.matches) + }, []) + + return ( + + {chars.map((c, i) => ( + + {c} + + ))} + + ) +} diff --git a/client/components/ui/Switch.tsx b/client/components/ui/Switch.tsx new file mode 100644 index 0000000..b4f8709 --- /dev/null +++ b/client/components/ui/Switch.tsx @@ -0,0 +1,31 @@ +import * as RSwitch from '@radix-ui/react-switch' +import { cn } from '@/lib/cn' + +interface SwitchProps { + checked?: boolean + defaultChecked?: boolean + onCheckedChange?: (checked: boolean) => void + disabled?: boolean + id?: string + 'aria-label'?: string +} + +export function Switch(props: SwitchProps) { + return ( + + + + ) +} diff --git a/client/components/ui/Tabs.tsx b/client/components/ui/Tabs.tsx new file mode 100644 index 0000000..d712f60 --- /dev/null +++ b/client/components/ui/Tabs.tsx @@ -0,0 +1,51 @@ +import * as RTabs from '@radix-ui/react-tabs' +import { forwardRef, type ComponentPropsWithoutRef } from 'react' +import { cn } from '@/lib/cn' + +export const Tabs = RTabs.Root + +export const TabsList = forwardRef< + HTMLDivElement, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = 'TabsList' + +export const TabsTrigger = forwardRef< + HTMLButtonElement, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = 'TabsTrigger' + +export const TabsContent = forwardRef< + HTMLDivElement, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = 'TabsContent' From 828d0c23742e051d7d01553cbf8188e422622338 Mon Sep 17 00:00:00 2001 From: Pietro Bonaldo Date: Wed, 24 Jun 2026 19:26:23 +0200 Subject: [PATCH 03/28] ui-refactor M3: rebuild Login page Boarding-pass style sign-in card with airport-board top strip, yellow accent CTA, and tanstack-query mutation flow. Co-Authored-By: Claude Opus 4.7 (1M context) --- client/pages/Login.tsx | 168 ++++++++++++++++++++++++++++++----------- 1 file changed, 122 insertions(+), 46 deletions(-) diff --git a/client/pages/Login.tsx b/client/pages/Login.tsx index 255eec3..b460c5f 100644 --- a/client/pages/Login.tsx +++ b/client/pages/Login.tsx @@ -1,55 +1,131 @@ -import React, {useState} from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useState, type FormEvent } from 'react' +import { useNavigate } from 'react-router-dom' +import { useMutation } from '@tanstack/react-query' +import { PlaneTakeoff } from 'lucide-react' -import API from '../api'; -import TokenStorage from '../storage/tokenStorage'; -import {Heading, Checkbox, Input, Button} from '../components/Elements' +import API from '@/api' +import TokenStorage from '@/storage/tokenStorage' +import { Button } from '@/components/ui/Button' +import { Input } from '@/components/ui/Input' +import { Label } from '@/components/ui/Label' +import { Switch } from '@/components/ui/Switch' export default function Login() { - const navigate = useNavigate(); - const [remember, setRemember] = useState(false); - const [failedLogin, setFailedLogin] = useState(false); - - const handleSubmit = (event) => { - event.preventDefault(); - const formData = new FormData(event.currentTarget); - - API.post("/auth/token", formData) - .then((data) => { - const token = data.access_token; - TokenStorage.storeToken(token, remember); - - navigate("/"); - }) - .catch((err) => { - if (err.response.status === 401) { - setFailedLogin(true); + const navigate = useNavigate() + const [remember, setRemember] = useState(false) + const [error, setError] = useState(null) + + const login = useMutation({ + mutationFn: (formData: FormData) => API.post('/auth/token', formData), + onSuccess: (data) => { + TokenStorage.storeToken(data.access_token, remember) + navigate('/') + }, + onError: (err: any) => { + if (err?.response?.status === 401) { + setError('Incorrect username or password') + } else { + setError('Unable to log in. Please try again.') } - }); + }, + }) + + const handleSubmit = (event: FormEvent) => { + event.preventDefault() + setError(null) + login.mutate(new FormData(event.currentTarget)) } return ( -
-
- -
- { failedLogin ? -

Incorrect username or password

- : <> - } - - - -

Remember me

- setRemember(e.target.checked)}/> - -
- - - +
+ {/* Top board strip */} +
+
+ + Jetlog + + Sign in +
+ Authorized access +
+ +
+
+
+
+ +
+
+

+ Boarding pass +

+

+ Identify yourself to continue +

+
+
+ +
+
+ + +
+ +
+ + +
+ +
+ +
+ + {error && ( +
+ {error} +
+ )} + + +
+
+
+ +
+ Jetlog · Flight log + v1.1 +
-
- ); + ) } From 569c63a6c5e43532bfba90e954f249a53f0afc96 Mon Sep 17 00:00:00 2001 From: Pietro Bonaldo Date: Wed, 24 Jun 2026 19:27:50 +0200 Subject: [PATCH 04/28] ui-refactor M4: rebuild Home (map + split-flap counters + recent strip) New WorldMap component using the cream/yellow palette and responsive viewBox sizing. New Home page lays out: stat strip with split-flap reveals, restyled world map, recent-flights mini-board. Old client/components/WorldMap.tsx kept for now: still used by SingleFlight, will be removed when the flights detail view is rebuilt in M5. Co-Authored-By: Claude Opus 4.7 (1M context) --- client/components/map/WorldMap.tsx | 174 +++++++++++++++++++++++++++++ client/pages/Home.tsx | 165 +++++++++++++++++++++++++-- 2 files changed, 329 insertions(+), 10 deletions(-) create mode 100644 client/components/map/WorldMap.tsx diff --git a/client/components/map/WorldMap.tsx b/client/components/map/WorldMap.tsx new file mode 100644 index 0000000..89bc731 --- /dev/null +++ b/client/components/map/WorldMap.tsx @@ -0,0 +1,174 @@ +import { useMemo } from 'react' +import { ComposableMap, ZoomableGroup, Geographies, Geography, Marker, Line } from 'react-simple-maps' +import { useDecorations, useWorldGeography } from '@/api/queries' +import ConfigStorage from '@/storage/configStorage' + +interface WorldMapProps { + flightId?: number + className?: string + interactive?: boolean +} + +const MAP_W = 1000 +const MAP_H = 480 + +const COLORS = { + land: '#EFE7CE', + landStroke: '#14130F', + visited: '#F5C518', + visitedStroke: '#C99A00', + line: '#14130F', + marker: '#14130F', + markerRing: '#F5C518', +} + +export function WorldMap({ flightId, className, interactive = true }: WorldMapProps) { + const showVisited = ConfigStorage.getSetting('showVisitedCountries') === 'true' + const freqMarker = ConfigStorage.getSetting('frequencyBasedMarker') === 'true' + const freqLine = ConfigStorage.getSetting('frequencyBasedLine') === 'true' + const restrict = ConfigStorage.getSetting('restrictWorldMap') === 'true' + + const { data: world } = useWorldGeography(showVisited) + const { data: decor } = useDecorations(flightId) + + const lines = decor?.lines ?? [] + const markers = decor?.markers ?? [] + + const { initialZoom, center } = useMemo(() => { + if (!restrict || markers.length < 2) { + return { initialZoom: 1, center: [0, 0] as [number, number] } + } + const lats = markers.map((m) => m.latitude) + const lons = markers.map((m) => m.longitude) + const south = Math.min(...lats) + const north = Math.max(...lats) + const west = Math.min(...lons) + const east = Math.max(...lons) + const maxSpan = Math.max(east - west, north - south) + const z = Math.min(150 / maxSpan, 3) + if (z < 1) return { initialZoom: 1, center: [0, 0] as [number, number] } + return { + initialZoom: z, + center: [(west + east) / 2, (south + north) / 2] as [number, number], + } + }, [markers, restrict]) + + return ( +
+ + interactive} + translateExtent={[ + [0, 0], + [MAP_W, MAP_H], + ]} + > + {world && ( + + {({ geographies }) => + geographies.map((geo: any) => ( + + )) + } + + )} + + {lines.map((line, i) => ( + + ))} + + {markers.map((marker, i) => { + const r = freqMarker ? Math.min(2 + marker.frequency * 0.4, 5.5) : 2.5 + return ( + + + + + ) + })} + + +
+ ) +} + +interface SingleFlightMapProps { + flightId: number + distance: number + className?: string +} + +export function SingleFlightMap({ flightId, distance, className }: SingleFlightMapProps) { + const { data: decor } = useDecorations(flightId) + const lines = decor?.lines ?? [] + const markers = decor?.markers ?? [] + + if (lines.length === 0 || markers.length < 2) return null + + // spherical midpoint + const toRad = (d: number) => (d * Math.PI) / 180 + const toDeg = (r: number) => (r * 180) / Math.PI + const p1 = markers[0] + const p2 = markers[1] + const lat1 = toRad(p1.latitude) + const lon1 = toRad(p1.longitude) + const lat2 = toRad(p2.latitude) + const lon2 = toRad(p2.longitude) + const x = (Math.cos(lat1) * Math.cos(lon1) + Math.cos(lat2) * Math.cos(lon2)) / 2 + const y = (Math.cos(lat1) * Math.sin(lon1) + Math.cos(lat2) * Math.sin(lon2)) / 2 + const z = (Math.sin(lat1) + Math.sin(lat2)) / 2 + const cLon = toDeg(Math.atan2(y, x)) + const cLat = toDeg(Math.atan2(z, Math.sqrt(x * x + y * y))) + const scale = Math.min(20000 / Math.max(distance, 1), 10) * 160 + + return ( +
+ + {lines.map((line, i) => ( + + ))} + {markers.map((m, i) => ( + + + + + ))} + +
+ ) +} diff --git a/client/pages/Home.tsx b/client/pages/Home.tsx index ee6189a..699e746 100644 --- a/client/pages/Home.tsx +++ b/client/pages/Home.tsx @@ -1,16 +1,161 @@ -import React from 'react'; +import { Link } from 'react-router-dom' +import { ArrowRight, Plane } from 'lucide-react' -import { ShortStats } from '../components/Stats'; -import WorldMap from '../components/WorldMap'; +import { useFlights, useStatistics } from '@/api/queries' +import { WorldMap } from '@/components/map/WorldMap' +import { Panel, PanelHeader, PanelTitle, PanelBody } from '@/components/ui/Panel' +import { SplitFlap } from '@/components/ui/SplitFlap' +import { Spinner } from '@/components/ui/Spinner' +import { Badge } from '@/components/ui/Badge' +import ConfigStorage from '@/storage/configStorage' +import type { Flight } from '@/models' -export default function Home() { +function formatNumber(n: number) { + return n.toLocaleString('en-US') +} + +interface StatCellProps { + label: string + value: string + unit?: string + delay?: number +} + +function StatCell({ label, value, unit, delay = 0 }: StatCellProps) { return ( - <> - +
+
{label}
+
+ + + + {unit && {unit}} +
+
+ ) +} + +function CountersStrip() { + const metric = ConfigStorage.getSetting('metricUnits') !== 'false' + const { data: stats, isLoading } = useStatistics({ metric }) + + if (isLoading || !stats) { + return ( + + + + ) + } -
- + return ( + +
+ + + + +
- - ); +
+ ) +} + +function RecentFlightRow({ flight }: { flight: Flight }) { + const origin = flight.origin?.iata || flight.origin?.icao || '—' + const dest = flight.destination?.iata || flight.destination?.icao || '—' + const dep = flight.departureTime?.slice(0, 5) || '—' + return ( + + + {flight.date} + + + {origin} + + + + {dest} + + + {dep} + + + {flight.airline?.iata || flight.airline?.icao || '—'} + + + ) +} + +function RecentFlights() { + const { data: flights, isLoading } = useFlights({ + limit: 5, + sort: 'date', + order: 'descending', + }) + + return ( + + + Recent flights + + All flights + + + {isLoading ? ( + + + + ) : flights && flights.length > 0 ? ( +
+ {flights.map((f) => ( + + ))} +
+ ) : ( + + + No flights yet. Add your first flight. + + )} +
+ ) +} + +export default function Home() { + return ( +
+ + + + + World map + + Visited airports & routes + + + + + + +
+ ) } From a4bcbc1e3681a244f6192d94ab6d028fdba4f80b Mon Sep 17 00:00:00 2001 From: Pietro Bonaldo Date: Wed, 24 Jun 2026 19:30:59 +0200 Subject: [PATCH 05/28] ui-refactor M5: rebuild Flights list + detail New /flights list uses TanStack Table with zebra rows, sticky sortable headers, filter-pill bar with a Sheet for advanced filters, and a mobile card layout. Whole-row click opens the detail view as requested. New flight detail view: airport-board banner with origin/dest codes, data blocks for departure/arrival/airline/aircraft, restyled route map, and inline edit mode (Combobox for airport/airline search). Delete with confirm. The old SingleFlight, AllFlights table, and components/WorldMap file are no longer imported but kept on disk for now and will be deleted in the cleanup milestone. Co-Authored-By: Claude Opus 4.7 (1M context) --- client/components/flights/FlightDetail.tsx | 498 ++++++++++++++++++++ client/components/flights/FlightFilters.tsx | 177 +++++++ client/components/flights/FlightsTable.tsx | 271 +++++++++++ client/lib/format.ts | 28 ++ client/pages/AllFlights.tsx | 218 ++------- 5 files changed, 1025 insertions(+), 167 deletions(-) create mode 100644 client/components/flights/FlightDetail.tsx create mode 100644 client/components/flights/FlightFilters.tsx create mode 100644 client/components/flights/FlightsTable.tsx create mode 100644 client/lib/format.ts diff --git a/client/components/flights/FlightDetail.tsx b/client/components/flights/FlightDetail.tsx new file mode 100644 index 0000000..bfb6c3f --- /dev/null +++ b/client/components/flights/FlightDetail.tsx @@ -0,0 +1,498 @@ +import { useState, type FormEvent } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { ArrowLeft, ArrowRight, Pencil, Trash2, X, Check } from 'lucide-react' +import { useQueryClient } from '@tanstack/react-query' + +import API from '@/api' +import { + useCurrentUser, + useFlight, + useDeleteFlight, + searchAirports, + searchAirlines, +} from '@/api/queries' +import type { Airport, Airline, Flight } from '@/models' +import ConfigStorage from '@/storage/configStorage' + +import { Panel, PanelHeader, PanelTitle, PanelBody } from '@/components/ui/Panel' +import { Button } from '@/components/ui/Button' +import { Input } from '@/components/ui/Input' +import { Label } from '@/components/ui/Label' +import { Select } from '@/components/ui/Select' +import { Combobox, type ComboboxOption } from '@/components/ui/Combobox' +import { Spinner } from '@/components/ui/Spinner' +import { Badge } from '@/components/ui/Badge' +import { DataBlock } from '@/components/ui/DataBlock' +import { SingleFlightMap } from '@/components/map/WorldMap' + +import { formatDuration, formatDistance, formatTime, airportCode } from '@/lib/format' + +interface FlightDetailProps { + flightId: number +} + +const SEAT_OPTIONS = ['', 'aisle', 'middle', 'window'] +const AIRCRAFT_SIDES = ['', 'left', 'right', 'center'] +const CLASSES = ['', 'private', 'first', 'business', 'economy+', 'economy'] +const PURPOSES = ['', 'leisure', 'business', 'crew', 'other'] + +export function FlightDetail({ flightId }: FlightDetailProps) { + const navigate = useNavigate() + const metric = ConfigStorage.getSetting('metricUnits') !== 'false' + const localAirportTime = ConfigStorage.getSetting('localAirportTime') === 'true' + const { data: flight, isLoading } = useFlight(flightId) + const { data: me } = useCurrentUser() + + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState>({}) + const [saving, setSaving] = useState(false) + const qc = useQueryClient() + + const deleteMut = useDeleteFlight() + + if (isLoading || !flight) { + return ( +
+ +
+ ) + } + + const isOwner = me?.username === flight.username + + const enterEdit = () => { + setDraft({ + date: flight.date, + departureTime: flight.departureTime, + arrivalTime: flight.arrivalTime, + arrivalDate: flight.arrivalDate, + duration: flight.duration, + origin: flight.origin, + destination: flight.destination, + distance: flight.distance, + seat: flight.seat, + aircraftSide: flight.aircraftSide, + ticketClass: flight.ticketClass, + purpose: flight.purpose, + airplane: flight.airplane, + airline: flight.airline, + tailNumber: flight.tailNumber, + flightNumber: flight.flightNumber, + notes: flight.notes, + }) + setEditing(true) + } + + const handleSave = async (e: FormEvent) => { + e.preventDefault() + setSaving(true) + const payload: any = {} + for (const [k, v] of Object.entries(draft)) { + if (v === '' || v === undefined || v === null) continue + if (k === 'origin' || k === 'destination') { + payload[k] = (v as Airport).icao + } else if (k === 'airline') { + payload[k] = (v as Airline).icao + } else { + payload[k] = v + } + } + try { + await API.patch( + `/flights/${flightId}?timezones=${localAirportTime}`, + payload, + ) + await qc.invalidateQueries({ queryKey: ['flight', flightId] }) + await qc.invalidateQueries({ queryKey: ['flights'] }) + setEditing(false) + } finally { + setSaving(false) + } + } + + const handleDelete = () => { + if (!confirm('Delete this flight?')) return + deleteMut.mutate(flightId, { + onSuccess: () => navigate('/flights'), + }) + } + + return ( +
+ {/* Back + header */} +
+ + All flights + +
+ {isOwner && !editing && ( + <> + + + + )} + {editing && ( + <> + + + + )} +
+
+ + {/* Banner */} + +
+
+ {flight.username} + {flight.date} + {flight.flightNumber && {flight.flightNumber}} +
+
+
+ {airportCode(flight.origin)} +
+
+ + {formatDuration(flight.duration)} + + + + {formatDistance(flight.distance, metric)} + +
+
+ {airportCode(flight.destination)} +
+
+
+ + + + +
+
+
+ +
+ + + Route + + {flight.distance ? ( + + ) : ( + No route data + )} + + + + + {editing ? 'Edit details' : 'Details'} + + + {editing ? ( + + ) : ( + + )} + + +
+ + {flight.notes && !editing && ( + + + Notes + + +

+ {flight.notes} +

+
+
+ )} +
+ ) +} + +function ReadFields({ flight }: { flight: Flight }) { + return ( +
+ + + + + + + {flight.connection && ( +
+ Connection + + Linked flight #{flight.connection} + +
+ )} +
+ ) +} + +interface EditFieldsProps { + draft: Partial + setDraft: (d: Partial) => void +} + +function EditFields({ draft, setDraft }: EditFieldsProps) { + const set = (k: K, v: Flight[K] | undefined) => + setDraft({ ...draft, [k]: v as any }) + + const onAirportSearch = async (q: string): Promise[]> => { + const res = await searchAirports(q) + return res.map((a) => ({ + value: a.icao, + label: `${a.iata || a.icao} · ${a.municipality}`, + sub: `${a.name} · ${a.country}`, + raw: a, + })) + } + const onAirlineSearch = async (q: string): Promise[]> => { + const res = await searchAirlines(q) + return res.map((a) => ({ + value: a.icao, + label: `${a.iata || a.icao} · ${a.name}`, + raw: a, + })) + } + + return ( +
+
+ + set('date', e.target.value)} + /> +
+ +
+ + + displayValue={ + draft.origin + ? `${draft.origin.iata || draft.origin.icao} · ${draft.origin.municipality}` + : undefined + } + onSearch={onAirportSearch} + onSelect={(o) => set('origin', o.raw as Airport)} + /> +
+
+ + + displayValue={ + draft.destination + ? `${draft.destination.iata || draft.destination.icao} · ${draft.destination.municipality}` + : undefined + } + onSearch={onAirportSearch} + onSelect={(o) => set('destination', o.raw as Airport)} + /> +
+ +
+ + set('departureTime', e.target.value)} + /> +
+
+ + set('arrivalTime', e.target.value)} + /> +
+ +
+ + set('arrivalDate', e.target.value)} + /> +
+
+ + + set('duration', e.target.value ? Number(e.target.value) : undefined) + } + /> +
+ +
+ + + set('distance', e.target.value ? Number(e.target.value) : undefined) + } + /> +
+
+ + + displayValue={ + draft.airline + ? `${draft.airline.iata || draft.airline.icao} · ${draft.airline.name}` + : undefined + } + onSearch={onAirlineSearch} + onSelect={(o) => set('airline', o.raw as Airline)} + /> +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + set('airplane', e.target.value)} + /> +
+
+ + set('tailNumber', e.target.value)} + /> +
+ +
+ + set('flightNumber', e.target.value)} + /> +
+ +
+ + - ); -} - -interface CheckboxProps { - name?: string; - checked?: boolean; - onChange?: ((event: ChangeEvent) => any)|null; -} -export function Checkbox({ checked, name, onChange }: CheckboxProps) { - return ( - {}} - checked={checked} /> - ) -} - -interface OptionProps { - text: string; - value?: string; -} -function Option({text, value}: OptionProps) { - return ( - - ); -} - -interface SelectProps { - name?: string; - options: OptionProps[]; -} -export function Select({name, options}: SelectProps) { - return ( - - ); -} - -interface DialogProps { - title: string; - buttonLevel?: "default"|"success"|"danger"; - formBody: any; // ? - onSubmit: React.FormEventHandler; -} -export function Dialog({ title, buttonLevel = "default", formBody, onSubmit }: DialogProps) { - const modalId = Math.random().toString(36).slice(2, 10); // to support multiple modals in one page - - const openModal = () => { - const modalElement = document.getElementById(modalId) as HTMLDialogElement; - modalElement.showModal(); - } - - const closeModal = () => { - const modalElement = document.getElementById(modalId) as HTMLDialogElement; - modalElement.close(); - } - - const handleSubmit = (event) => { - closeModal(); - event.preventDefault(); - onSubmit(event); - } - - return ( - <> -
- - - - - ); -} diff --git a/client/components/FetchConnection.tsx b/client/components/FetchConnection.tsx deleted file mode 100644 index 5b48b0e..0000000 --- a/client/components/FetchConnection.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import React, { useEffect } from 'react'; -import { useState } from 'react'; -import { Button } from '../components/Elements' -import API from '../api'; -import { Flight } from '../models'; - -interface FetchConnectionProps { - name: string; - date: string; - origin: string|undefined; - destination: string|undefined; - value?: number; - onFetched?: (c: number) => void; -} - -export default function FetchConnection({ name, date, origin, destination, value, onFetched }: FetchConnectionProps) { - const [searched, setSearched] = useState(false); - const [connectionFlight, setConnectionFlight] = useState(); // only needed for printing flight info - - // if value is initially set, we must - // find matching flight (only first render) - useEffect(() => { - if (value) { - API.get(`flights?id=${value}`) - .then((data: Flight) => { setConnectionFlight(data) }); - } - }, []) - - // whenever origin or destination or date is changed, - // we can search again - useEffect(() => { - setSearched(false); - }, [date, origin, destination]); - - // this method returns an actual instance of - // Flight so that its class methods can be used - const createInstance = (obj) => { - let correct: Flight = new Flight(); - Object.assign(correct, obj); - - return correct; - } - - const searchConnection = () => { - // connection flight must be within 2 days after - // and 1 day before the actual flight, and should - // have origin where actual flight has destination, - // and destination != actual flight origin - const start = new Date(date); - start.setDate(start.getDate() - 1); - - const end = new Date(date); - end.setDate(end.getDate() + 2); - - const fmt = d => d.toISOString().substring(0, 10); - - API.get(`/flights?start=${fmt(start)}&end=${fmt(end)}&origin=${destination}`) - .then((data: Flight[]) => { - if (!onFetched) return; // only keep going if we have to do something - - // only keep results where connection flight destination != actual flight origin, - // so that quick trips aren't counted as a connection back and forth - data = data.filter((flight: Flight) => flight.destination.icao != origin) - - if (data.length > 1) { - // this should be very rare, for now we handle it - // with a crude prompt - const choice = prompt(`Multiple possible connections found, select one by entering its number: - ${ data.map((f: Flight, i) => `\n[${i}] ${createInstance(f).toString()}`) }`); - - if (!choice) { - alert("Your input must be a valid index!"); - return; - } - - const parsed = Number.parseInt(choice); - - if (!Number.isInteger(parsed) || parsed < 0 || parsed > data.length - 1) { - alert("Your input must be a valid index!"); - return; - } - - const connection: Flight = data[choice]; - setConnectionFlight(connection); - onFetched(connection.id); - } else if (data.length == 1) { - const connection: Flight = data[0]; - setConnectionFlight(connection); - onFetched(connection.id); - } - - setSearched(true); - }); - } - - return ( - <> - { !searched && -