diff --git a/src/App.css b/src/App.css index 9e60301..1d1a421 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 fdc70b9..7312224 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from "react"; import DriverCard from "./components/DriverCard"; import SearchAndFilter from "./components/SearchAndFilter"; +import DriverDetail from "./components/DriverDetail"; const sessionDate = new Intl.DateTimeFormat("en-GB", { dateStyle: "medium", @@ -45,6 +46,10 @@ function App() { () => (sessionStorage.getItem("sortOrder") as SortOrder) ?? "none", ); + useEffect(() => { + sessionStorage.setItem("search", search); + }, [search]); + useEffect(() => { sessionStorage.setItem("team", team); }, [team]); @@ -134,6 +139,23 @@ function App() { } }); + // Detail panel selection: Previous/Next step through the currently filtered + // list only, so it stays in sync with whatever the left list is showing. + const [selectedIndex, setSelectedIndex] = useState(0); + const clampedIndex = filteredDrivers.length + ? Math.min(selectedIndex, filteredDrivers.length - 1) + : 0; + const selectedDriver = filteredDrivers[clampedIndex]; + + function selectDriverByNumber(driverNumber: number) { + const index = filteredDrivers.findIndex( + (driver) => driver.driver_number === driverNumber, + ); + if (index !== -1) { + setSelectedIndex(index); + } + } + const meetingsByKey = new Map( allMeetings.map((meeting) => [meeting.meeting_key, meeting]), ); @@ -165,6 +187,8 @@ function App() { return ( <> +

F1 The Website

+ driver.team_name)), )} /> -
-

F1 The Website

- {driversWithStandingsPending - ? "loading drivers..." - : driversWithStandingsError - ? "Could not load drivers." - : filteredDrivers.length === 0 - ? "No drivers match your filters." - : filteredDrivers.map((driver) => ( - toggleFavorite(driver.driver_number)} - teamStanding={teamStandings.find( - (team) => 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, - }} - /> - ))} + +
+
+ {driversWithStandingsPending + ? "loading drivers..." + : driversWithStandingsError + ? "Could not load drivers." + : filteredDrivers.length === 0 + ? "No drivers match your filters." + : filteredDrivers.map((driver) => ( + toggleFavorite(driver.driver_number)} + isSelected={selectedDriver?.driver_number === driver.driver_number} + onSelect={() => selectDriverByNumber(driver.driver_number)} + teamStanding={teamStandings.find( + (team) => 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, + }} + /> + ))} +
+ + team.team_name === selectedDriver.team_name) + : undefined + } + teamStandingsPending={teamStandingsPending} + teamStandingsError={teamStandingsError} + hasPrev={clampedIndex > 0} + hasNext={clampedIndex < filteredDrivers.length - 1} + onPrev={() => setSelectedIndex((index) => Math.max(0, index - 1))} + onNext={() => + setSelectedIndex((index) => Math.min(filteredDrivers.length - 1, index + 1)) + } + />
{sessionsError || sessionResultsError ? ( diff --git a/src/components/DriverCard.tsx b/src/components/DriverCard.tsx index 9a3104b..67c688e 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; @@ -18,23 +21,35 @@ interface DriverCardProps { teamStandingsError: boolean; isFavorite: boolean; onToggleFavorite: () => void; + isSelected?: boolean; + onSelect?: () => void; } export default function DriverCard({ driver, teamStanding, teamStandingsPending, - teamStandingsError, + teamStandingsError, isFavorite, onToggleFavorite, + 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'} + +
+
+ + )} +
+ ) +}