Skip to content
Draft
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
28 changes: 28 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import LiveActivitiesAbout from './pages/LiveActivities/About';
import Settings from './pages/Settings';
import Support from './pages/Support';
import Test from './pages/Test';
import UserPage from './pages/User';
import UserLogin from './pages/UserLogin';
import { AppProvider } from './providers/AppProvider';
import { AuthProvider, useAuth } from './providers/AuthProvider';
Expand Down Expand Up @@ -86,6 +87,24 @@ const PsychSheet = () => {
return null;
};

const CompetitionPersonByWcaIdRedirect = ({ to }: { to: 'results' | 'records' }) => {
const { competitionId, wcaId } = useParams() as { competitionId: string; wcaId: string };
const { wcif } = useWCIF();
const person = wcif?.persons.find((p) => p.wcaId?.toUpperCase() === wcaId.toUpperCase());

if (!wcif) {
return null;
}

if (!person) {
return <Navigate to={`/competitions/${competitionId}`} replace />;
}

return (
<Navigate to={`/competitions/${competitionId}/persons/${person.registrantId}/${to}`} replace />
);
};

const CompetitionRedirect = ({ to }: { to: string }) => {
const { competitionId } = useParams() as { competitionId: string };

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

<Route
path="persons/wca/:wcaId/results"
element={<CompetitionPersonByWcaIdRedirect to="results" />}
/>
<Route
path="persons/wca/:wcaId/records"
element={<CompetitionPersonByWcaIdRedirect to="records" />}
/>
<Route path="persons/:registrantId/*" element={<CompetitionPerson />} />
<Route path="personal-bests/:wcaId" element={<CompetitionPersonalBests />} />
<Route path="personal-records/:wcaId" element={<CompetitionPersonalBests />} />
Expand Down Expand Up @@ -142,6 +169,7 @@ const Navigation = () => {
<Route path="*" element={<p>Path not resolved</p>} />
</Route>
<Route path="/users/:userId" element={<UserLogin />} />
<Route path="/me" element={<UserPage />} />
<Route path="about" element={<About />} />
<Route path="live-activities" element={<LiveActivitiesAbout />} />
<Route path="settings" element={<Settings />} />
Expand Down
3 changes: 3 additions & 0 deletions src/layouts/RootLayout/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ export default function Header() {
<div
className="z-50 mt-2 border-2 shadow-xl bg-panel border-tertiary-weak"
onClick={() => setIsPopoverOpen(!isPopoverOpen)}>
<Link to="/me" className="block w-32 px-3 py-2 link-inline hover-bg-tertiary">
My profile
</Link>
<Link to="/settings" className="block w-32 px-3 py-2 link-inline hover-bg-tertiary">
Settings
</Link>
Expand Down
78 changes: 78 additions & 0 deletions src/pages/User/components/CompetitionsTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Link } from 'react-router-dom';

interface CompetitionsTabProps {
competitions: ApiCompetition[];
assignmentStatus: Record<string, boolean> | undefined;
isCheckingAssignments: boolean;
}

const formatCompetitionDates = (competition: ApiCompetition) => {
const start = new Date(`${competition.start_date}T00:00:00`);
const end = new Date(`${competition.end_date}T00:00:00`);

if (competition.start_date === competition.end_date) {
return start.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}

return `${start.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
})} - ${end.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})}`;
};

export function CompetitionsTab({
competitions,
assignmentStatus,
isCheckingAssignments,
}: CompetitionsTabProps) {
if (competitions.length === 0) {
return <p className="type-body-sm text-muted">No upcoming competitions.</p>;
}

return (
<div className="space-y-2">
{competitions.map((competition) => {
const hasAssignments = assignmentStatus?.[competition.id];
const statusText =
hasAssignments == null && isCheckingAssignments
? 'Checking assignments'
: hasAssignments
? 'Assignments generated'
: 'No assignments yet';

return (
<Link
key={competition.id}
to={`/competitions/${competition.id}`}
className="block rounded-md border border-tertiary-weak bg-panel px-3 py-2 shadow-sm hover-bg-tertiary">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="type-label text-default">{competition.name}</div>
<div className="type-body-sm text-subtle">
{competition.city}, {competition.country_iso2} -{' '}
{formatCompetitionDates(competition)}
</div>
</div>
<span
className={
hasAssignments
? 'shrink-0 text-right type-body-sm text-green-600'
: 'shrink-0 text-right type-body-sm text-muted'
}>
{statusText}
</span>
</div>
</Link>
);
})}
</div>
);
}
38 changes: 38 additions & 0 deletions src/pages/User/components/ProfileHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { hasFlag } from 'country-flag-icons';
import getUnicodeFlagIcon from 'country-flag-icons/unicode';

const fallbackAvatarUrl =
'https://assets.worldcubeassociation.org/assets/326cd49/assets/missing_avatar_thumb-d77f478a307a91a9d4a083ad197012a391d5410f6dd26cb0b0e3118a5de71438.png';

interface ProfileHeaderProps {
countryIso2?: string;
user: User;
}

export function ProfileHeader({ countryIso2, user }: ProfileHeaderProps) {
const avatarUrl = user.avatar?.thumb_url || user.avatar?.url || fallbackAvatarUrl;

return (
<div className="flex px-1 space-x-1">
<a
href={`https://worldcubeassociation.org/persons/${user.wca_id}`}
target="_blank"
rel="noreferrer">
<img src={avatarUrl} alt={user.name} className="object-contain w-24 h-24" />
</a>
<div className="flex flex-col w-full">
<div className="flex items-center flex-shrink w-full space-x-1">
<h3 className="type-heading sm:type-title">{user.name}</h3>
</div>
<div className="flex space-x-1 align-center">
{countryIso2 && hasFlag(countryIso2) && (
<div className="flex flex-shrink type-body sm:type-heading">
{getUnicodeFlagIcon(countryIso2)}
</div>
)}
{user.wca_id && <span className="my-1 type-body-sm">{user.wca_id}</span>}
</div>
</div>
</div>
);
}
41 changes: 41 additions & 0 deletions src/pages/User/components/ProfileTabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import classNames from 'classnames';
import { UserPageTab } from '../userProfileData';

interface ProfileTabsProps {
activeTab: UserPageTab;
onChange: (tab: UserPageTab) => void;
}

const tabs: { id: UserPageTab; label: string }[] = [
{ id: 'competitions', label: 'Competitions' },
{ id: 'results', label: 'Results' },
{ id: 'records', label: 'Records' },
];

export function ProfileTabs({ activeTab, onChange }: ProfileTabsProps) {
return (
<nav className="overflow-hidden rounded-md border border-tertiary-weak bg-panel shadow-sm">
<div className="grid grid-flow-col auto-cols-fr">
{tabs.map((tab) => {
const isActive = tab.id === activeTab;

return (
<button
key={tab.id}
type="button"
className={classNames(
'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',
{
'bg-active text-primary': isActive,
'text-muted': !isActive,
},
)}
onClick={() => onChange(tab.id)}>
<span className="truncate">{tab.label}</span>
</button>
);
})}
</div>
</nav>
);
}
69 changes: 69 additions & 0 deletions src/pages/User/components/RecordsTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import {
formatUserResult,
getUserEventName,
WcaPersonResponse,
WcaPersonalRecord,
} from '../userProfileData';

interface RecordsTabProps {
records: WcaPersonResponse['person']['personal_records'] | undefined;
isLoading: boolean;
error: unknown;
}

function RankingLine({ label, record }: { label: string; record: WcaPersonalRecord | undefined }) {
if (!record) {
return (
<div className="type-body-sm text-muted">
{label}: <span>-</span>
</div>
);
}

return (
<div className="type-body-sm text-muted">
{label}: #{record.world_rank} world / #{record.continent_rank} continent / #
{record.country_rank} country
</div>
);
}

export function RecordsTab({ records, isLoading, error }: RecordsTabProps) {
if (isLoading) {
return <p className="type-body-sm text-muted">Loading records...</p>;
}

if (error) {
return <p className="type-body-sm text-red-600">Unable to load records.</p>;
}

const entries = Object.entries(records || {}).sort(([a], [b]) =>
getUserEventName(a).localeCompare(getUserEventName(b)),
);

if (entries.length === 0) {
return <p className="type-body-sm text-muted">No records available.</p>;
}

return (
<div className="space-y-2">
{entries.map(([eventId, record]) => (
<div
key={eventId}
className="rounded-md border border-tertiary-weak bg-panel px-3 py-2 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="type-label text-default">{getUserEventName(eventId)}</div>
<RankingLine label="Single" record={record.single} />
<RankingLine label="Average" record={record.average} />
</div>
<div className="shrink-0 space-y-1 text-right type-body-sm text-muted">
<div>{formatUserResult(eventId, 'single', record.single?.best)}</div>
<div>{formatUserResult(eventId, 'average', record.average?.best)}</div>
</div>
</div>
</div>
))}
</div>
);
}
Loading
Loading