Skip to content

Commit 596361e

Browse files
committed
Add competition admin section
1 parent 385e07e commit 596361e

9 files changed

Lines changed: 150 additions & 39 deletions

File tree

src/App.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { usePageTracking } from './hooks/usePageTracking';
66
import { CompetitionLayout } from './layouts/CompetitionLayout';
77
import { RootLayout } from './layouts/RootLayout';
88
import About from './pages/About';
9+
import CompetitionAdmin from './pages/Competition/Admin';
910
import CompetitionEvents from './pages/Competition/ByGroup/Events';
1011
import CompetitionGroup from './pages/Competition/ByGroup/Group';
1112
import CompetitionGroupList from './pages/Competition/ByGroup/GroupList';
@@ -85,6 +86,12 @@ const PsychSheet = () => {
8586
return null;
8687
};
8788

89+
const CompetitionRedirect = ({ to }: { to: string }) => {
90+
const { competitionId } = useParams() as { competitionId: string };
91+
92+
return <Navigate to={`/competitions/${competitionId}/${to}`} replace />;
93+
};
94+
8895
const Navigation = () => {
8996
usePageTracking(import.meta.env.VITE_GA_MEASUREMENT_ID);
9097

@@ -112,11 +119,16 @@ const Navigation = () => {
112119

113120
<Route path="psych-sheet" element={<PsychSheet />} />
114121
<Route path="psych-sheet/:eventId" element={<PsychSheetEvent />} />
115-
<Route path="remote" element={<CompetitionRemote />} />
116122
<Route path="results" element={<CompetitionResults />} />
117123
<Route path="results/:roundId" element={<CompetitionResults />} />
118124

119-
<Route path="scramblers" element={<CompetitionScramblerSchedule />} />
125+
<Route path="admin" element={<CompetitionAdmin />} />
126+
<Route path="admin/remote" element={<CompetitionRemote />} />
127+
<Route path="admin/scramblers" element={<CompetitionScramblerSchedule />} />
128+
<Route path="admin/stats" element={<CompetitionStats />} />
129+
<Route path="admin/sum-of-ranks" element={<CompetitionSumOfRanks />} />
130+
<Route path="remote" element={<CompetitionRedirect to="admin/remote" />} />
131+
<Route path="scramblers" element={<CompetitionRedirect to="admin/scramblers" />} />
120132
<Route path="stream" element={<CompetitionStreamSchedule />} />
121133
<Route path="information" element={<CompetitionInformation />} />
122134
<Route path="live" element={<CompetitionLive />} />
@@ -125,8 +137,8 @@ const Navigation = () => {
125137
<Route path="personal-schedule" element={<PersonalSchedule />} />
126138
<Route path="explore" element={<CompetitionGroupsOverview />} />
127139
<Route path="groups-schedule" element={<CompetitionGroupsSchedule />} />
128-
<Route path="stats" element={<CompetitionStats />} />
129-
<Route path="sum-of-ranks" element={<CompetitionSumOfRanks />} />
140+
<Route path="stats" element={<CompetitionRedirect to="admin/stats" />} />
141+
<Route path="sum-of-ranks" element={<CompetitionRedirect to="admin/sum-of-ranks" />} />
130142
<Route path="*" element={<p>Path not resolved</p>} />
131143
</Route>
132144
<Route path="/users/:userId" element={<UserLogin />} />

src/components/StyledNavLink/StyledNavLink.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@ export const StyledNavLink: React.FC<StyledNavLinkProps> = ({
1111
to,
1212
text,
1313
className,
14+
end = true,
1415
...props
1516
}: StyledNavLinkProps) => (
1617
<NavLink
1718
{...props}
18-
end
19+
end={end}
1920
to={to}
2021
className={({ isActive }) =>
2122
classNames('link-nav', className, {

src/containers/CompetitionStats/CompetitionStats.tsx

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,29 +21,26 @@ export function CompetitionStatsContainer() {
2121
const acceptedRegistrationsCount = acceptedRegistrations?.length || 0;
2222

2323
return (
24-
<Container className="space-y-2 p-2">
25-
<div className="type-heading flex justify-evenly">
24+
<Container className="space-y-4 p-2">
25+
<div className="grid grid-cols-2 gap-2 type-heading">
2626
<StatsBox title="Competitors" value={acceptedRegistrationsCount} />
2727
<StatsBox title="Events" value={eventCount} />
2828
</div>
29-
<hr />
30-
<div
31-
className="grid w-full"
32-
style={{
33-
gridTemplateColumns: `repeat(${eventCount}, 1fr)`,
34-
}}>
35-
{wcif?.events?.map(({ id }) => (
36-
<span key={id} className={`cubing-icon event-${id} mx-1 type-body`} />
37-
))}
38-
{wcif?.events?.map(({ id }) => (
39-
<span key={id} className="type-body-sm text-center">
40-
{
41-
acceptedRegistrations?.filter(({ registration }) =>
42-
registration?.eventIds.includes(id),
43-
).length
44-
}
45-
</span>
46-
))}
29+
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
30+
{wcif?.events?.map(({ id }) => {
31+
const registrationCount =
32+
acceptedRegistrations?.filter(({ registration }) => registration?.eventIds.includes(id))
33+
.length ?? 0;
34+
35+
return (
36+
<div
37+
key={id}
38+
className="flex items-center justify-between gap-2 rounded-md border border-tertiary-weak bg-panel px-3 py-2 shadow-sm">
39+
<span className={`cubing-icon event-${id} shrink-0 type-body`} />
40+
<span className="type-label text-default">{registrationCount}</span>
41+
</div>
42+
);
43+
})}
4744
</div>
4845
</Container>
4946
);

src/containers/CompetitionSumOfRanks/CompetitionSumOfRanks.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ export function CompetitionSumOfRanksContainer({
101101
<KinchTooltip />
102102
</span>
103103
</th>
104-
<th className="px-3 py-2 text-right font-semibold">Medals</th>
104+
<th className="hidden px-3 py-2 text-right font-semibold md:table-cell">Medals</th>
105105
</tr>
106106
</thead>
107107
<tbody>
@@ -117,7 +117,9 @@ export function CompetitionSumOfRanksContainer({
117117
</td>
118118
<td className="px-3 py-2 text-right font-semibold">{ranking.sumOfRanks}</td>
119119
<td className="px-3 py-2 text-right">{formatKinch(ranking.kinch)}</td>
120-
<td className="px-3 py-2 text-right">{formatMedals(ranking)}</td>
120+
<td className="hidden px-3 py-2 text-right md:table-cell">
121+
{formatMedals(ranking)}
122+
</td>
121123
</tr>
122124
))}
123125
</tbody>

src/i18n/en/translation.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,9 @@ header:
158158
results: 'Results'
159159
scramblers: 'Scramblers'
160160
stream: 'Stream'
161+
admin: 'Admin'
162+
sumOfRanks: 'Sum of Rankings'
163+
stats: 'Stats'
161164
home:
162165
subtitle: 'Learn all you need about your WCA competition assignments!'
163166
explanation: 'Note: This site is a convenience tool for organizers, delegates, and competitors. It uses scheduled data, so check with organizers for the latest details. Start and end times may change.'

src/i18n/fr/translation.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,9 @@ header:
118118
rankings: 'Classements'
119119
scramblers: 'Mélangeurs'
120120
stream: 'Stream'
121+
admin: 'Admin'
122+
sumOfRanks: 'Somme des classements'
123+
stats: 'Stats'
121124
home:
122125
subtitle: 'Apprenez tout ce que vous devez savoir sur les tâches qui vous ont été attribuées aux compétitions WCA !'
123126
explanation: 'Remarque : Ce site web est un outil pratique pour les organisateurs, délégués et compétiteurs. Les informations fournies sont basées sur le planning prévisionnel. Pensez à consulter régulièrement la page de la compétition pour obtenir les informations les plus récentes. Les horaires de début et de fin peuvent varier.'

src/layouts/CompetitionLayout/CompetitionLayout.tabs.tsx

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,22 +11,31 @@ interface CompetitionLayoutTabsProps {
1111
wcif?: Competition;
1212
}
1313

14+
export interface CompetitionLayoutTab {
15+
href: string;
16+
text: string;
17+
end?: boolean;
18+
hiddenOnMobile?: boolean;
19+
}
20+
21+
interface CompetitionLayoutTabs {
22+
tabs: CompetitionLayoutTab[];
23+
adminTabs: CompetitionLayoutTab[];
24+
}
25+
1426
export const useCompetitionLayoutTabs = ({ competitionId, wcif }: CompetitionLayoutTabsProps) => {
1527
const { t } = useTranslation();
1628
const { user } = useAuth();
1729
const userId = user?.id;
1830

19-
return useMemo(() => {
31+
return useMemo<CompetitionLayoutTabs>(() => {
2032
const hasStream = wcif && streamActivities(wcif).length > 0;
2133
const person = wcif?.persons.find((p) => p.wcaUserId === userId);
2234
const isPersonStaff = person && isStaff(person);
2335
const canManageRemote = isCompetitionDelegateOrOrganizer(wcif, userId ? { id: userId } : null);
2436

25-
const _tabs: {
26-
href: string;
27-
text: string;
28-
hiddenOnMobile?: boolean;
29-
}[] = [];
37+
const _tabs: CompetitionLayoutTab[] = [];
38+
const _adminTabs: CompetitionLayoutTab[] = [];
3039

3140
_tabs.push({
3241
href: `/competitions/${competitionId}`,
@@ -54,15 +63,15 @@ export const useCompetitionLayoutTabs = ({ competitionId, wcif }: CompetitionLay
5463
);
5564

5665
if (isPersonStaff) {
57-
_tabs.push({
58-
href: `/competitions/${competitionId}/scramblers`,
66+
_adminTabs.push({
67+
href: `/competitions/${competitionId}/admin/scramblers`,
5968
text: t('header.tabs.scramblers'),
6069
});
6170
}
6271

6372
if (canManageRemote) {
64-
_tabs.push({
65-
href: `/competitions/${competitionId}/remote`,
73+
_adminTabs.push({
74+
href: `/competitions/${competitionId}/admin/remote`,
6675
text: t('header.tabs.remote', {
6776
defaultValue: 'Remote',
6877
}),
@@ -76,6 +85,34 @@ export const useCompetitionLayoutTabs = ({ competitionId, wcif }: CompetitionLay
7685
});
7786
}
7887

79-
return _tabs;
88+
if (isPersonStaff || canManageRemote) {
89+
_adminTabs.push(
90+
{
91+
href: `/competitions/${competitionId}/admin/sum-of-ranks`,
92+
text: t('header.tabs.sumOfRanks', {
93+
defaultValue: 'Sum of Rankings',
94+
}),
95+
},
96+
{
97+
href: `/competitions/${competitionId}/admin/stats`,
98+
text: t('header.tabs.stats', {
99+
defaultValue: 'Stats',
100+
}),
101+
},
102+
);
103+
}
104+
105+
if (_adminTabs.length > 0) {
106+
_tabs.push({
107+
href: `/competitions/${competitionId}/admin`,
108+
text: t('header.tabs.admin'),
109+
end: false,
110+
});
111+
}
112+
113+
return {
114+
tabs: _tabs,
115+
adminTabs: _adminTabs,
116+
};
80117
}, [wcif, competitionId, userId, t]);
81118
};

src/layouts/CompetitionLayout/CompetitionLayout.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export function CompetitionLayout() {
2020

2121
const { data: wcif, dataUpdatedAt, isFetching } = useWcif(competitionId!);
2222

23-
const tabs = useCompetitionLayoutTabs({
23+
const { tabs } = useCompetitionLayoutTabs({
2424
competitionId: competitionId!,
2525
wcif: wcif,
2626
});
@@ -39,6 +39,7 @@ export function CompetitionLayout() {
3939
className={classNames({
4040
'hidden md:block': i.hiddenOnMobile,
4141
})}
42+
end={i.end}
4243
to={i.href}
4344
text={i.text}
4445
/>
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { useEffect } from 'react';
2+
import { Link } from 'react-router-dom';
3+
import { Container, NoteBox } from '@/components';
4+
import { useCompetitionLayoutTabs } from '@/layouts/CompetitionLayout/CompetitionLayout.tabs';
5+
import { useWCIF } from '@/providers/WCIFProvider';
6+
7+
const adminDescriptions: Record<string, string> = {
8+
scramblers: 'View scrambler assignments by event and round.',
9+
remote: 'Control Live Activities updates for the competition.',
10+
'sum-of-ranks': 'Review competitor rankings across completed rounds.',
11+
stats: 'View competition registration and event counts.',
12+
};
13+
14+
const adminItemId = (href: string) => href.split('/').pop() ?? href;
15+
16+
export default function CompetitionAdmin() {
17+
const { competitionId, wcif, setTitle } = useWCIF();
18+
const { adminTabs } = useCompetitionLayoutTabs({ competitionId, wcif });
19+
20+
useEffect(() => {
21+
setTitle('Admin');
22+
}, [setTitle]);
23+
24+
return (
25+
<Container className="space-y-4 p-2 pt-4">
26+
<h1 className="type-heading">Admin</h1>
27+
{adminTabs.length === 0 ? (
28+
<NoteBox text="No admin tools are available for your account at this competition." />
29+
) : (
30+
<div className="space-y-2">
31+
{adminTabs.map((tab) => {
32+
const itemId = adminItemId(tab.href);
33+
34+
return (
35+
<Link
36+
key={tab.href}
37+
to={tab.href}
38+
className="block rounded-md border border-tertiary-weak bg-panel px-4 py-3 shadow-sm hover-bg-tertiary">
39+
<div className="flex items-center justify-between gap-4">
40+
<div className="min-w-0 space-y-1">
41+
<div className="type-label text-default">{tab.text}</div>
42+
{adminDescriptions[itemId] && (
43+
<div className="type-body-sm text-subtle">{adminDescriptions[itemId]}</div>
44+
)}
45+
</div>
46+
<i className="fa fa-chevron-right shrink-0 text-muted" aria-hidden="true" />
47+
</div>
48+
</Link>
49+
);
50+
})}
51+
</div>
52+
)}
53+
</Container>
54+
);
55+
}

0 commit comments

Comments
 (0)