Skip to content

Commit bdac9a9

Browse files
committed
Add personal user page with competitions, results, and records tabs
1 parent 596361e commit bdac9a9

12 files changed

Lines changed: 1066 additions & 1 deletion

File tree

src/App.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import LiveActivitiesAbout from './pages/LiveActivities/About';
3636
import Settings from './pages/Settings';
3737
import Support from './pages/Support';
3838
import Test from './pages/Test';
39+
import UserPage from './pages/User';
3940
import UserLogin from './pages/UserLogin';
4041
import { AppProvider } from './providers/AppProvider';
4142
import { AuthProvider, useAuth } from './providers/AuthProvider';
@@ -86,6 +87,24 @@ const PsychSheet = () => {
8687
return null;
8788
};
8889

90+
const CompetitionPersonByWcaIdRedirect = ({ to }: { to: 'results' | 'records' }) => {
91+
const { competitionId, wcaId } = useParams() as { competitionId: string; wcaId: string };
92+
const { wcif } = useWCIF();
93+
const person = wcif?.persons.find((p) => p.wcaId?.toUpperCase() === wcaId.toUpperCase());
94+
95+
if (!wcif) {
96+
return null;
97+
}
98+
99+
if (!person) {
100+
return <Navigate to={`/competitions/${competitionId}`} replace />;
101+
}
102+
103+
return (
104+
<Navigate to={`/competitions/${competitionId}/persons/${person.registrantId}/${to}`} replace />
105+
);
106+
};
107+
89108
const CompetitionRedirect = ({ to }: { to: string }) => {
90109
const { competitionId } = useParams() as { competitionId: string };
91110

@@ -103,6 +122,14 @@ const Navigation = () => {
103122
<Route path="/competitions/:competitionId" element={<CompetitionLayout />}>
104123
<Route index element={<CompetitionHome />} />
105124

125+
<Route
126+
path="persons/wca/:wcaId/results"
127+
element={<CompetitionPersonByWcaIdRedirect to="results" />}
128+
/>
129+
<Route
130+
path="persons/wca/:wcaId/records"
131+
element={<CompetitionPersonByWcaIdRedirect to="records" />}
132+
/>
106133
<Route path="persons/:registrantId/*" element={<CompetitionPerson />} />
107134
<Route path="personal-bests/:wcaId" element={<CompetitionPersonalBests />} />
108135
<Route path="personal-records/:wcaId" element={<CompetitionPersonalBests />} />
@@ -142,6 +169,7 @@ const Navigation = () => {
142169
<Route path="*" element={<p>Path not resolved</p>} />
143170
</Route>
144171
<Route path="/users/:userId" element={<UserLogin />} />
172+
<Route path="/me" element={<UserPage />} />
145173
<Route path="about" element={<About />} />
146174
<Route path="live-activities" element={<LiveActivitiesAbout />} />
147175
<Route path="settings" element={<Settings />} />

src/layouts/RootLayout/Header.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ export default function Header() {
7373
<div
7474
className="z-50 mt-2 border-2 shadow-xl bg-panel border-tertiary-weak"
7575
onClick={() => setIsPopoverOpen(!isPopoverOpen)}>
76+
<Link to="/me" className="block w-32 px-3 py-2 link-inline hover-bg-tertiary">
77+
My profile
78+
</Link>
7679
<Link to="/settings" className="block w-32 px-3 py-2 link-inline hover-bg-tertiary">
7780
Settings
7881
</Link>
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { Link } from 'react-router-dom';
2+
3+
interface CompetitionsTabProps {
4+
competitions: ApiCompetition[];
5+
assignmentStatus: Record<string, boolean> | undefined;
6+
isCheckingAssignments: boolean;
7+
}
8+
9+
const formatCompetitionDates = (competition: ApiCompetition) => {
10+
const start = new Date(`${competition.start_date}T00:00:00`);
11+
const end = new Date(`${competition.end_date}T00:00:00`);
12+
13+
if (competition.start_date === competition.end_date) {
14+
return start.toLocaleDateString(undefined, {
15+
month: 'short',
16+
day: 'numeric',
17+
year: 'numeric',
18+
});
19+
}
20+
21+
return `${start.toLocaleDateString(undefined, {
22+
month: 'short',
23+
day: 'numeric',
24+
})} - ${end.toLocaleDateString(undefined, {
25+
month: 'short',
26+
day: 'numeric',
27+
year: 'numeric',
28+
})}`;
29+
};
30+
31+
export function CompetitionsTab({
32+
competitions,
33+
assignmentStatus,
34+
isCheckingAssignments,
35+
}: CompetitionsTabProps) {
36+
if (competitions.length === 0) {
37+
return <p className="type-body-sm text-muted">No upcoming competitions.</p>;
38+
}
39+
40+
return (
41+
<div className="space-y-2">
42+
{competitions.map((competition) => {
43+
const hasAssignments = assignmentStatus?.[competition.id];
44+
const statusText =
45+
hasAssignments == null && isCheckingAssignments
46+
? 'Checking assignments'
47+
: hasAssignments
48+
? 'Assignments generated'
49+
: 'No assignments yet';
50+
51+
return (
52+
<Link
53+
key={competition.id}
54+
to={`/competitions/${competition.id}`}
55+
className="block rounded-md border border-tertiary-weak bg-panel px-3 py-2 shadow-sm hover-bg-tertiary">
56+
<div className="flex items-start justify-between gap-3">
57+
<div className="min-w-0 space-y-1">
58+
<div className="type-label text-default">{competition.name}</div>
59+
<div className="type-body-sm text-subtle">
60+
{competition.city}, {competition.country_iso2} -{' '}
61+
{formatCompetitionDates(competition)}
62+
</div>
63+
</div>
64+
<span
65+
className={
66+
hasAssignments
67+
? 'shrink-0 text-right type-body-sm text-green-600'
68+
: 'shrink-0 text-right type-body-sm text-muted'
69+
}>
70+
{statusText}
71+
</span>
72+
</div>
73+
</Link>
74+
);
75+
})}
76+
</div>
77+
);
78+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { hasFlag } from 'country-flag-icons';
2+
import getUnicodeFlagIcon from 'country-flag-icons/unicode';
3+
4+
const fallbackAvatarUrl =
5+
'https://assets.worldcubeassociation.org/assets/326cd49/assets/missing_avatar_thumb-d77f478a307a91a9d4a083ad197012a391d5410f6dd26cb0b0e3118a5de71438.png';
6+
7+
interface ProfileHeaderProps {
8+
countryIso2?: string;
9+
user: User;
10+
}
11+
12+
export function ProfileHeader({ countryIso2, user }: ProfileHeaderProps) {
13+
const avatarUrl = user.avatar?.thumb_url || user.avatar?.url || fallbackAvatarUrl;
14+
15+
return (
16+
<div className="flex px-1 space-x-1">
17+
<a
18+
href={`https://worldcubeassociation.org/persons/${user.wca_id}`}
19+
target="_blank"
20+
rel="noreferrer">
21+
<img src={avatarUrl} alt={user.name} className="object-contain w-24 h-24" />
22+
</a>
23+
<div className="flex flex-col w-full">
24+
<div className="flex items-center flex-shrink w-full space-x-1">
25+
<h3 className="type-heading sm:type-title">{user.name}</h3>
26+
</div>
27+
<div className="flex space-x-1 align-center">
28+
{countryIso2 && hasFlag(countryIso2) && (
29+
<div className="flex flex-shrink type-body sm:type-heading">
30+
{getUnicodeFlagIcon(countryIso2)}
31+
</div>
32+
)}
33+
{user.wca_id && <span className="my-1 type-body-sm">{user.wca_id}</span>}
34+
</div>
35+
</div>
36+
</div>
37+
);
38+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import classNames from 'classnames';
2+
import { UserPageTab } from '../userProfileData';
3+
4+
interface ProfileTabsProps {
5+
activeTab: UserPageTab;
6+
onChange: (tab: UserPageTab) => void;
7+
}
8+
9+
const tabs: { id: UserPageTab; label: string }[] = [
10+
{ id: 'competitions', label: 'Competitions' },
11+
{ id: 'results', label: 'Results' },
12+
{ id: 'records', label: 'Records' },
13+
];
14+
15+
export function ProfileTabs({ activeTab, onChange }: ProfileTabsProps) {
16+
return (
17+
<nav className="overflow-hidden rounded-md border border-tertiary-weak bg-panel shadow-sm">
18+
<div className="grid grid-flow-col auto-cols-fr">
19+
{tabs.map((tab) => {
20+
const isActive = tab.id === activeTab;
21+
22+
return (
23+
<button
24+
key={tab.id}
25+
type="button"
26+
className={classNames(
27+
'flex min-h-12 items-center justify-center gap-2 border-r border-tertiary-weak px-2 py-2 text-center type-body-sm hover-transition hover:bg-gray-100 last:border-r-0 dark:hover:bg-gray-700 sm:type-body',
28+
{
29+
'bg-active text-primary': isActive,
30+
'text-muted': !isActive,
31+
},
32+
)}
33+
onClick={() => onChange(tab.id)}>
34+
<span className="truncate">{tab.label}</span>
35+
</button>
36+
);
37+
})}
38+
</div>
39+
</nav>
40+
);
41+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import {
2+
formatUserResult,
3+
getUserEventName,
4+
WcaPersonResponse,
5+
WcaPersonalRecord,
6+
} from '../userProfileData';
7+
8+
interface RecordsTabProps {
9+
records: WcaPersonResponse['person']['personal_records'] | undefined;
10+
isLoading: boolean;
11+
error: unknown;
12+
}
13+
14+
function RankingLine({ label, record }: { label: string; record: WcaPersonalRecord | undefined }) {
15+
if (!record) {
16+
return (
17+
<div className="type-body-sm text-muted">
18+
{label}: <span>-</span>
19+
</div>
20+
);
21+
}
22+
23+
return (
24+
<div className="type-body-sm text-muted">
25+
{label}: #{record.world_rank} world / #{record.continent_rank} continent / #
26+
{record.country_rank} country
27+
</div>
28+
);
29+
}
30+
31+
export function RecordsTab({ records, isLoading, error }: RecordsTabProps) {
32+
if (isLoading) {
33+
return <p className="type-body-sm text-muted">Loading records...</p>;
34+
}
35+
36+
if (error) {
37+
return <p className="type-body-sm text-red-600">Unable to load records.</p>;
38+
}
39+
40+
const entries = Object.entries(records || {}).sort(([a], [b]) =>
41+
getUserEventName(a).localeCompare(getUserEventName(b)),
42+
);
43+
44+
if (entries.length === 0) {
45+
return <p className="type-body-sm text-muted">No records available.</p>;
46+
}
47+
48+
return (
49+
<div className="space-y-2">
50+
{entries.map(([eventId, record]) => (
51+
<div
52+
key={eventId}
53+
className="rounded-md border border-tertiary-weak bg-panel px-3 py-2 shadow-sm">
54+
<div className="flex items-start justify-between gap-3">
55+
<div className="min-w-0 space-y-1">
56+
<div className="type-label text-default">{getUserEventName(eventId)}</div>
57+
<RankingLine label="Single" record={record.single} />
58+
<RankingLine label="Average" record={record.average} />
59+
</div>
60+
<div className="shrink-0 space-y-1 text-right type-body-sm text-muted">
61+
<div>{formatUserResult(eventId, 'single', record.single?.best)}</div>
62+
<div>{formatUserResult(eventId, 'average', record.average?.best)}</div>
63+
</div>
64+
</div>
65+
</div>
66+
))}
67+
</div>
68+
);
69+
}

0 commit comments

Comments
 (0)