Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 109 additions & 2 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ body {

padding: 10px;
margin: 9px;

cursor: pointer;
}

.driver-card-selected {
box-shadow: 0 0 0 3px white;
}

.driver-card img {
Expand All @@ -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 {
Expand All @@ -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;
Expand Down
117 changes: 88 additions & 29 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -45,6 +46,10 @@ function App() {
() => (sessionStorage.getItem("sortOrder") as SortOrder) ?? "none",
);

useEffect(() => {
sessionStorage.setItem("search", search);
}, [search]);

useEffect(() => {
sessionStorage.setItem("team", team);
}, [team]);
Expand Down Expand Up @@ -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]),
);
Expand Down Expand Up @@ -165,6 +187,8 @@ function App() {

return (
<>
<h1 className="header">F1 The Website</h1>

<SearchAndFilter
showFavorites={showFavorites}
onShowFavoritesChange={setShowFavorites}
Expand All @@ -178,35 +202,70 @@ function App() {
new Set(driversWithStandings.map((driver) => driver.team_name)),
)}
/>
<div className="standing-drivers">
<h1 className="header">F1 The Website</h1>
{driversWithStandingsPending
? "loading drivers..."
: driversWithStandingsError
? "Could not load drivers."
: filteredDrivers.length === 0
? "No drivers match your filters."
: filteredDrivers.map((driver) => (
<DriverCard
key={driver.driver_number}
isFavorite={favorites.includes(driver.driver_number)}
onToggleFavorite={() => 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,
}}
/>
))}

<div className="content-row">
<div className="standing-drivers">
{driversWithStandingsPending
? "loading drivers..."
: driversWithStandingsError
? "Could not load drivers."
: filteredDrivers.length === 0
? "No drivers match your filters."
: filteredDrivers.map((driver) => (
<DriverCard
key={driver.driver_number}
isFavorite={favorites.includes(driver.driver_number)}
onToggleFavorite={() => 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,
}}
/>
))}
</div>

<DriverDetail
isLoading={driversWithStandingsPending}
isError={driversWithStandingsError}
driver={
selectedDriver
? {
name: selectedDriver.full_name,
number: selectedDriver.driver_number,
headshot_url: selectedDriver.headshot_url,
team_colour: selectedDriver.team_colour,
team_name: selectedDriver.team_name,
points_current: selectedDriver.points_current,
position_current: selectedDriver.position_current,
}
: undefined
}
teamStanding={
selectedDriver
? teamStandings.find((team) => 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))
}
/>
</div>

{sessionsError || sessionResultsError ? (
Expand Down
25 changes: 20 additions & 5 deletions src/components/DriverCard.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -18,31 +21,43 @@ 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 (
<div className="driver-card" style={{ border: `2px solid #${driver.team_colour}` }}>
<div
className={isSelected ? "driver-card driver-card-selected" : "driver-card"}
style={{ border: `2px solid #${driver.team_colour}` }}
onClick={onSelect}
>
<button
className="favorite-button"
type="button"
onClick={onToggleFavorite}
onClick={(event) => {
// Don't let favoriting also select the card underneath it.
event.stopPropagation()
onToggleFavorite()
}}
aria-label={`Favorite ${driver.name}`}
aria-pressed={isFavorite}
>
{isFavorite ? "★" : "☆"}
</button>
<h1 style={{ color: `#${driver.team_colour}` }}>{driver.number}</h1>
<h2>{driver.name}</h2>
<img style={{ backgroundColor: `#${driver.team_colour}` }} src={driver.headshot_url ? driver.headshot_url : 'https://placehold.net/avatar.svg'} alt={driver.name} />
<img style={{ backgroundColor: `#${driver.team_colour}` }} src={driver.headshot_url ? driver.headshot_url : DEFAULT_AVATAR_URL} alt={driver.name} />
<p>{driver.points_current}</p>
<p>{driver.position_current}</p>
<p>
Expand Down
Loading