From 1077063d2ad5594a26ab959d62905e7e1630a1bf Mon Sep 17 00:00:00 2001 From: Ludvig Zapffe Date: Wed, 16 Sep 2026 13:58:45 +0200 Subject: [PATCH] feat: add detailed driver overview and selection functionality --- src/App.css | 111 +++++++++++++++++++++++++++++++- src/App.tsx | 74 +++++++++++++++------ src/components/DriverCard.tsx | 17 +++-- src/components/DriverDetail.tsx | 110 +++++++++++++++++++++++++++++++ 4 files changed, 287 insertions(+), 25 deletions(-) create mode 100644 src/components/DriverDetail.tsx diff --git a/src/App.css b/src/App.css index 9fd856e..1de9cb7 100644 --- a/src/App.css +++ b/src/App.css @@ -20,6 +20,12 @@ body { padding: 10px; margin: 9px; + + cursor: pointer; +} + +.driver-card-selected { + box-shadow: 0 0 0 3px white; } .driver-card img { @@ -35,10 +41,11 @@ body { font-weight: 400; } -.standing-drivers { +.content-row { display: flex; - flex-direction: column; align-items: flex-start; + gap: 2rem; + flex-wrap: wrap; } .standing-drivers { @@ -52,6 +59,106 @@ body { align-self: center; } +.driver-detail { + background-color: #15151e; + border-radius: 30px; + + color: white; + font-family: "Titillium Web", sans-serif; + + width: 360px; + padding: 2rem; + margin: 10px; + + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + + position: sticky; + top: 1rem; +} + +.driver-detail-nav { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + +.driver-detail-nav-button { + background-color: transparent; + border: 1px solid grey; + border-radius: 10px; + color: white; + font-size: 1rem; + cursor: pointer; +} + +.driver-detail-nav-button:disabled { + color: grey; + border-color: grey; + cursor: not-allowed; +} + +.driver-detail-photo { + height: 240px; + width: auto; + max-width: 100%; + border-radius: 25px; +} + +.driver-detail-heading { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 1rem; +} + +.driver-detail-name { + font-size: 22px; + font-weight: 400; + margin: 0; + text-align: center; +} + +.driver-detail-number { + font-size: 50px; + font-weight: 700; +} + +.driver-detail-stats { + display: flex; + flex-direction: column; + width: 100%; + margin-top: 0.5rem; +} + +.driver-detail-stat-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.65rem 0; +} + +.driver-detail-stat-label { + color: grey; + font-size: 1rem; +} + +.driver-detail-stat-value { + font-size: 1.25rem; + font-weight: 600; +} + +.driver-detail-divider { + width: 100%; + margin: 0 auto; + border-top: 1px solid grey; +} + .meeting-list { width: 100%; max-width: 760px; diff --git a/src/App.tsx b/src/App.tsx index e44d72b..62bbe91 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,11 @@ import './App.css' +import { useState } from 'react' import api from './api/api' import type { Session, SessionResult } from './api/api' import { useQuery } from '@tanstack/react-query' import DriverCard from './components/DriverCard' +import DriverDetail from './components/DriverDetail' const sessionDate = new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium', timeZone: 'UTC' }) @@ -15,6 +17,15 @@ function App() { queryFn: getDriversWithStandings }) + const [selectedIndex, setSelectedIndex] = useState(0) + const clampedIndex = driversWithStandings.length ? Math.min(selectedIndex, driversWithStandings.length - 1) : 0 + const selectedDriver = driversWithStandings[clampedIndex] + + function selectDriverByNumber(driverNumber: number) { + const index = driversWithStandings.findIndex((driver) => driver.driver_number === driverNumber) + if (index !== -1) { setSelectedIndex(index) } + } + const {data: teamStandings = [], isPending: teamStandingsPending, isError: teamStandingsError} = useQuery({ queryKey: ['teamStandings'], queryFn: getAllTeamStandings, @@ -61,25 +72,50 @@ function App() { return ( <> -
-

F1 The Website

- { driversWithStandingsPending ? "loading drivers..." : driversWithStandingsError ? "Could not load drivers." : - driversWithStandings.map((driver) => ( - team.team_name === driver.team_name)} - teamStandingsPending={teamStandingsPending} - teamStandingsError={teamStandingsError} - driver={{ - name: driver.full_name, - number: driver.driver_number, - headshot_url: driver.headshot_url, - team_colour: driver.team_colour, - team_name: driver.team_name, - points_current: driver.points_current, - position_current: driver.position_current - }} /> - )) - } +

F1 The Website

+
+
+ { driversWithStandingsPending ? "loading drivers..." : driversWithStandingsError ? "Could not load drivers." : + driversWithStandings.map((driver) => ( + team.team_name === driver.team_name)} + teamStandingsPending={teamStandingsPending} + teamStandingsError={teamStandingsError} + isSelected={selectedDriver?.driver_number === driver.driver_number} + onSelect={() => selectDriverByNumber(driver.driver_number)} + driver={{ + name: driver.full_name, + number: driver.driver_number, + headshot_url: driver.headshot_url, + team_colour: driver.team_colour, + team_name: driver.team_name, + points_current: driver.points_current, + position_current: driver.position_current + }} /> + )) + } +
+ + team.team_name === selectedDriver.team_name) : undefined} + teamStandingsPending={teamStandingsPending} + teamStandingsError={teamStandingsError} + hasPrev={clampedIndex > 0} + hasNext={clampedIndex < driversWithStandings.length - 1} + onPrev={() => setSelectedIndex((index) => Math.max(0, index - 1))} + onNext={() => setSelectedIndex((index) => Math.min(driversWithStandings.length - 1, index + 1))} + />
{sessionsError || sessionResultsError ? ( diff --git a/src/components/DriverCard.tsx b/src/components/DriverCard.tsx index f8a4e14..f5b4a73 100644 --- a/src/components/DriverCard.tsx +++ b/src/components/DriverCard.tsx @@ -1,6 +1,9 @@ import type { TeamStandings } from '../api/api' -interface DriverProps { +// Default "avatar" used when api does not return a driver photo +const DEFAULT_AVATAR_URL = 'https://canvas.ntnu.no/images/messages/avatar-50.png' + +export interface DriverProps { name: string; number: number; headshot_url: string; @@ -16,15 +19,21 @@ interface DriverCardProps { teamStanding?: TeamStandings; teamStandingsPending: boolean; teamStandingsError: boolean; + isSelected?: boolean; + onSelect?: () => void; } -export default function DriverCard({ driver, teamStanding, teamStandingsPending, teamStandingsError }: DriverCardProps) { +export default function DriverCard({ driver, teamStanding, teamStandingsPending, teamStandingsError, isSelected, onSelect }: DriverCardProps) { return ( -
+

{driver.number}

{driver.name}

- {driver.name} + {driver.name}

{driver.points_current}

{driver.position_current}

diff --git a/src/components/DriverDetail.tsx b/src/components/DriverDetail.tsx new file mode 100644 index 0000000..79e5fcd --- /dev/null +++ b/src/components/DriverDetail.tsx @@ -0,0 +1,110 @@ +import type { TeamStandings } from '../api/api' +import type { DriverProps } from './DriverCard' + +// Default "avatar" used when api does not return a driver photo +const DEFAULT_AVATAR_URL = 'https://canvas.ntnu.no/images/messages/avatar-50.png' + +interface DriverDetailProps { + driver?: DriverProps; + teamStanding?: TeamStandings; + teamStandingsPending: boolean; + teamStandingsError: boolean; + isLoading: boolean; + isError: boolean; + hasPrev: boolean; + hasNext: boolean; + onPrev: () => void; + onNext: () => void; +} + +export default function DriverDetail({ + driver, + teamStanding, + teamStandingsPending, + teamStandingsError, + isLoading, + isError, + hasPrev, + hasNext, + onPrev, + onNext, +}: DriverDetailProps) { + + return ( +

+
+ + +
+ + {isLoading ? ( +

Loading driver...

+ ) : isError ? ( +

Could not load driver details.

+ ) : !driver ? ( +

No driver selected.

+ ) : ( + <> + {driver.name} { event.currentTarget.src = DEFAULT_AVATAR_URL }} + /> + +
+

{driver.name}

+ {driver.number} +
+ +
+
+ Team + {driver.team_name} +
+
+ +
+ Position + {driver.position_current || '—'} +
+
+ +
+ Driver points + {driver.points_current} +
+
+ +
+ Team points + + {teamStandingsPending + ? 'Loading...' + : teamStandingsError + ? 'Unavailable' + : teamStanding?.points_current ?? 'Unavailable'} + +
+
+ + )} +
+ ) +}