Compare commits

...

8 commits

Author SHA1 Message Date
Théo
383f507fd8 Merge branch 'main' of https://git.peerat.dev/Peer-at-Code/peer-at-code-web 2023-04-11 11:24:07 +02:00
Théo
82d7291068 Misc change 2023-04-11 11:23:50 +02:00
Théo
21ebeb8a6d Added new logo 2023-04-11 11:23:33 +02:00
Théo
284c2934d7 Added misc url (Discord) 2023-04-11 11:23:12 +02:00
Théo
8023dc2987 Added ui dialog 2023-04-11 11:22:54 +02:00
Théo
6e335b377b Added link to MDToHTML 2023-04-11 11:22:46 +02:00
Théo
d475a08790 Added group check 2023-04-11 11:22:32 +02:00
Théo
82fbf1ecba Added event leaderboard 2023-04-11 11:22:21 +02:00
33 changed files with 1284 additions and 313 deletions

View file

@ -0,0 +1,19 @@
import EventLeaderboard from '@/ui/events/Leaderboard';
import { cookies } from 'next/headers';
import { notFound } from 'next/navigation';
export const metadata = {
title: 'Tableau des scores - Peer-at Code',
description: 'Suivez la progression des élèves en direct'
};
export default async function Page({ params }: { params: { id: number } }) {
const { id } = params;
if (!id) {
notFound();
}
const token = cookies().get('token')?.value;
return <EventLeaderboard token={token!} id={id} />;
}

View file

@ -12,7 +12,7 @@ export default function Page() {
<section className="flex flex-col space-y-4">
<header className="flex flex-col">
<h3 className="text-xl font-semibold">Mes badges</h3>
<p className="hidden text-muted sm:block">
<p className="text-muted">
Vos badges sont affichés ici, vous pouvez les partager avec vos amis
</p>
</header>

View file

@ -36,13 +36,17 @@ export default function Page() {
<header>
<h3 className="text-xl font-semibold">Guides</h3>
</header>
<main className="h-full w-full flex-col justify-between space-x-0 space-y-4 rounded-lg border border-highlight-primary bg-primary-700 md:flex md:flex-row md:space-x-6 md:space-y-0"></main>
<main className="h-full w-full flex-col justify-between space-x-0 space-y-4 rounded-lg border border-highlight-primary bg-primary-700 md:flex md:flex-row md:space-x-6 md:space-y-0">
Work in progress
</main>
</section>
<section className="flex h-full w-full flex-col space-y-4">
<header>
<h3 className="text-xl font-semibold">Historiques</h3>
</header>
<main className="h-full w-full flex-col justify-between space-x-0 space-y-4 rounded-lg border border-highlight-primary bg-primary-700 md:flex md:flex-row md:space-x-6 md:space-y-0"></main>
<main className="h-full w-full flex-col justify-between space-x-0 space-y-4 rounded-lg border border-highlight-primary bg-primary-700 md:flex md:flex-row md:space-x-6 md:space-y-0">
Work in progress
</main>
</section>
</div>
{/* TODO fix ça c'est pas responsive */}

View file

@ -25,7 +25,11 @@ export default async function Page({ params }: { params: { id: number } }) {
notFound();
}
const puzzle = await getPuzzle({ token, id });
const puzzle = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/puzzle/${id}`, {
headers: {
Authorization: `Bearer ${token}`
}
}).then((res) => res.json());
if (!puzzle) {
notFound();

27
lib/groups.ts Normal file
View file

@ -0,0 +1,27 @@
import fetcher from './fetcher';
export const getGroups = async ({ token }: { token: string }): Promise<Group[]> => {
const { data, status } = await fetcher.get(`/groups`, {
headers: {
Authorization: `Bearer ${token}`
}
});
const groups = data;
if (status !== 200) {
throw new Error('Failed to fetch groups');
}
if (!groups) {
return [] as Group[];
}
return groups as Group[];
};
export type Group = {
id: number;
name: string;
chapter?: number;
};

6
lib/hooks/use-groups.ts Normal file
View file

@ -0,0 +1,6 @@
import useSWR from 'swr';
import { getGroups } from '../groups';
export function useGroups({ token }: { token: string }) {
return useSWR('groups', () => getGroups({ token }));
}

View file

@ -1,6 +1,10 @@
import useSWR from 'swr';
import { getScores } from '../leaderboard';
import { getScores, getScoresEvent } from '../leaderboard';
export function useLeaderboard({ token }: { token: string }) {
return useSWR('leaderboard', () => getScores({ token }));
}
export function useLeaderboardEvent({ token, id }: { token: string; id: number }) {
return useSWR(`leaderboard/${id}`, () => getScoresEvent({ token, id }));
}

View file

@ -1,5 +1,5 @@
import fetcher from './fetcher';
import type { Group } from './players';
import { type Group } from './groups';
export const getScores = async ({ token }: { token: string }): Promise<Score[]> => {
const { data, status } = await fetcher.get(`/leaderboard`, {
@ -21,6 +21,32 @@ export const getScores = async ({ token }: { token: string }): Promise<Score[]>
return scores as Score[];
};
export const getScoresEvent = async ({
token,
id
}: {
token: string;
id: number;
}): Promise<ScoreEvent> => {
const { data, status } = await fetcher.get(`/leaderboard/${id}`, {
headers: {
Authorization: `Bearer ${token}`
}
});
const scores = data;
if (status !== 200) {
throw new Error('Failed to fetch scores');
}
if (!scores) {
return {} as ScoreEvent;
}
return scores as ScoreEvent;
};
export type Score = {
score: number;
tries: number;
@ -30,3 +56,22 @@ export type Score = {
avatar: string;
rank: number;
};
export type ScoreEvent = {
start_date: string;
end_date: string;
groups: [
{
name: string;
rank: number;
players: [
{
pseudo: string;
tries: number;
completions: number;
score: number;
}
];
}
];
};

View file

@ -1,4 +1,5 @@
import fetcher from './fetcher';
import { type Group } from './groups';
export const getPlayer = async ({
token,
@ -46,8 +47,3 @@ export type Badge = {
level: number;
logo?: string;
};
export type Group = {
name: string;
chapter?: number;
};

View file

@ -90,8 +90,8 @@ export type Chapter = {
id: number;
name: string;
puzzles: Puzzle[];
startDay?: string;
endDay?: string;
startDate?: string;
endDate?: string;
};
export type Tag = {

View file

@ -11,10 +11,34 @@ export async function middleware(req: NextRequest) {
const token = req.cookies.get('token')?.value;
if (req.nextUrl.pathname.includes('dashboard') && !token)
if (token) {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/player/`, {
headers: {
Authorization: `Bearer ${token}`
},
cache: 'no-cache',
next: {
revalidate: 60
}
});
if (response.status !== 200) {
res.cookies.set('token', '', {
path: '/',
expires: new Date(0)
});
NextResponse.redirect(getURL('/sign-in'));
}
}
if (!token && req.nextUrl.pathname.includes('dashboard')) {
return NextResponse.redirect(getURL('/sign-in'));
else if (req.nextUrl.pathname.includes('sign') && token)
}
if (token && req.nextUrl.pathname.includes('sign')) {
return NextResponse.redirect(getURL('/dashboard'));
}
return res;
}

View file

@ -20,16 +20,20 @@
},
"homepage": "https://github.com/Peer-at-Code/peer-at-code#readme",
"dependencies": {
"@radix-ui/react-dialog": "^1.0.3",
"@radix-ui/react-popover": "^1.0.5",
"axios": "^1.3.4",
"boring-avatars": "^1.7.0",
"clsx": "^1.2.1",
"framer-motion": "^10.11.2",
"js-cookie": "^3.0.1",
"next": "13.2.3",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.43.1",
"react-markdown": "^8.0.5",
"remark-breaks": "^3.0.2",
"remark-gfm": "^3.0.1",
"remixicon": "^2.5.0",
"swr": "^2.0.3",
"tailwind-merge": "^1.9.0",

768
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 507 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -7,6 +7,9 @@ const AppLink = forwardRef<HTMLAnchorElement, Parameters<typeof Link>[0]>((props
if (props.target === '_blank') {
return <a ref={ref} {...props} href={props.href.toString()} />;
}
if (props['aria-disabled']) {
return <span ref={ref} {...props} />;
}
return <Link ref={ref} {...props} href={props.href} />;
});

54
ui/Dialog.tsx Normal file
View file

@ -0,0 +1,54 @@
import { cn } from '@/lib/utils';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import type { ReactNode } from 'react';
import Icon from './Icon';
// import Tooltip from './Tooltip';
type DialogProps = {
title?: ReactNode;
tooltip: ReactNode;
trigger: ReactNode;
children: ReactNode;
open?: boolean;
fullscreen?: boolean;
className?: string;
onOpenChange?: (open: boolean) => void;
};
export default function Dialog({
title,
tooltip,
trigger,
children,
open,
fullscreen,
className,
onOpenChange
}: DialogProps) {
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
{/* <Tooltip label={tooltip}> */}
<DialogPrimitive.Trigger asChild>{trigger}</DialogPrimitive.Trigger>
{/* </Tooltip> */}
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-highlight-primary/50" />
<DialogPrimitive.Content
className={cn(
'fixed left-1/2 top-1/2 z-50 flex w-96 -translate-x-1/2 -translate-y-1/2 transform flex-col overflow-hidden rounded-lg bg-primary-800 shadow-lg sm:w-1/3 sm:bg-primary-800/50',
className
)}
>
{title && (
<div className="flex w-full justify-between pb-4">
{title}
<DialogPrimitive.Trigger>
<Icon name="close-fill" className="hover:text-highlight-secondary" />
</DialogPrimitive.Trigger>
</div>
)}
{children}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}

View file

@ -74,7 +74,10 @@ export default function Leaderboard({ token }: { token: string }) {
<div className="flex flex-col gap-x-2 sm:flex-row sm:items-center">
<span className="text-lg">{score.pseudo}</span>
<span className="text-sm text-muted">
{score.groups?.map((g) => g.name).join(', ')}
{score.groups
?.map((g) => g.name)
.sort((a, b) => a.localeCompare(b))
.join(', ')}
</span>
</div>
</div>

View file

@ -1,9 +1,9 @@
'use client';
import type { Puzzle as PuzzleType } from '@/lib/puzzles';
import cookies from 'js-cookie';
import { notFound } from 'next/navigation';
import { useForm } from 'react-hook-form';
import cookies from 'js-cookie';
import Button from './Button';
import Input from './Input';
@ -36,9 +36,14 @@ export default function Puzzle({ puzzle }: { puzzle: PuzzleType }) {
async function onSubmit(data: PuzzleData) {
const formData = new FormData();
// if (data.code_file[0].size > 16 * 1024 * 1024) {
// alert('Fichier trop volumineux');
// return;
// }
formData.append('answer', data.answer);
formData.append('filename', data.code_file[0].name);
formData.append('code_file', data.code_file[0]);
// formData.append('filename', data.code_file[0].name);
// formData.append('code_file', data.code_file[0]);
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/puzzleResponse/${puzzle.id}`, {
method: 'POST',
@ -57,7 +62,7 @@ export default function Puzzle({ puzzle }: { puzzle: PuzzleType }) {
<div className="flex h-full w-full flex-col justify-between space-y-4">
<div className="flex flex-col space-y-2">
<h2 className="text-4xl font-bold">{puzzle.name}</h2>
<p className="text-sm text-muted">Chapitre</p>
{/* <p className="text-sm text-muted">Chapitre</p> */}
</div>
<div className="flex h-screen overflow-y-auto">
<ToHTML className="font-code" data={puzzle.content} />
@ -69,21 +74,21 @@ export default function Puzzle({ puzzle }: { puzzle: PuzzleType }) {
>
<div className="flex flex-col space-x-0 sm:flex-row sm:space-x-6">
<Input
className="w-full sm:w-1/3"
className="w-full"
label="Réponse"
type="text"
placeholder="12"
required
{...register('answer')}
/>
<Input
{/* <Input
className="h-16 w-full sm:w-1/3"
label="Code"
type="file"
required
accept=".py,.js,.ts,.java,.rust,.c"
accept=".py,.js,.ts,.java,.rs,.c"
{...register('code_file')}
/>
/> */}
</div>
<Button kind="brand" className="mt-6" type="submit">
Envoyer

View file

@ -1,23 +1,40 @@
'use client';
import { UserContext } from '@/context/user';
import { useGroups } from '@/lib/hooks/use-groups';
import { usePuzzles } from '@/lib/hooks/use-puzzles';
import { type Chapter } from '@/lib/puzzles';
import type { Chapter, Puzzle } from '@/lib/puzzles';
import { cn } from '@/lib/utils';
import { useContext, useState } from 'react';
import { useForm } from 'react-hook-form';
import AppLink from './AppLink';
import Button from './Button';
import Dialog from './Dialog';
import Icon from './Icon';
import Input from './Input';
import Select from './Select';
export default function Puzzles({ token }: { token: string }) {
const { data: me } = useContext(UserContext);
const { data, isLoading } = usePuzzles({ token });
// SOme chapters have a start date and a end date (for example, the first chapter is only available for 2 weeks), I want to want to lock the chapter if the current date is not between the start and end date
// I want to display a message to the user if the chapter is locked
const [isOpen, setIsOpen] = useState<boolean[]>([]);
function isChapterLocked(chapter: Chapter) {
function handleClick(index: number) {
setIsOpen((prevState) => {
const newState = [...prevState];
newState[index] = !newState[index];
return newState;
});
}
console.log(me);
function isInEventGroup(chapter: Chapter) {
return (
chapter.startDay &&
chapter.endDay &&
new Date() > new Date(chapter.startDay) &&
new Date() < new Date(chapter.endDay)
chapter.startDate &&
chapter.endDate &&
me?.groups?.some((group) => group.chapter && group.chapter === chapter.id)
);
}
@ -26,65 +43,63 @@ export default function Puzzles({ token }: { token: string }) {
{(!isLoading &&
data?.map((chapter) => (
<div key={chapter.id} className="flex flex-col space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-xl font-semibold sm:text-2xl">
Chapitre {chapter.id} - {chapter.name}{' '}
</h3>
<div className="h-1 w-1/4 rounded-lg bg-gray-200">
<div className="h-1 w-1/2 rounded-lg bg-gradient-to-tl from-brand to-brand-accent" />
<div className="flex flex-col justify-between lg:flex-row lg:items-center">
<div className="flex items-center gap-x-2">
<h3 className="text-xl font-semibold sm:text-2xl">
Chapitre {chapter.id} - {chapter.name}{' '}
</h3>
{!isInEventGroup(chapter) && (
<Dialog
key={chapter.id}
title={chapter.name}
tooltip="Select Hogwarts Level"
open={isOpen[chapter.id]}
onOpenChange={() => handleClick(chapter.id)}
trigger={
<button className="flex items-center gap-x-2 text-sm font-semibold text-muted hover:text-brand">
<Icon name="group-line" />
Rejoindre un groupe
</button>
}
className="right-96 p-4"
>
<GroupForm chapter={chapter} token={token} />
</Dialog>
)}
</div>
{chapter.startDate && chapter.endDate ? (
<div className="flex items-center gap-x-2">
<Icon name="calendar-line" className="text-sm text-muted" />
<span className="text-sm text-muted">
{new Date(chapter.startDate).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: 'numeric'
})}{' '}
-{' '}
{new Date(chapter.endDate).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: 'numeric'
})}
</span>
</div>
) : (
<div className="h-1 w-1/4 rounded-lg bg-gray-200">
<div className="h-1 w-1/2 rounded-lg bg-gradient-to-tl from-brand to-brand-accent" />
</div>
)}
</div>
<ul
className={cn('flex flex-col space-y-4', {
// If the chapter is locked i want to add a class to li children to make them unclickable
'pointer-events-none': isChapterLocked(chapter)
})}
>
{chapter.puzzles &&
chapter.puzzles.map((puzzle) => (
<AppLink key={puzzle.id} href={`/dashboard/puzzles/${puzzle.id}`}>
<li
className={cn(
'group flex items-center justify-between rounded-md border-2 bg-primary-700 p-4 font-code hover:bg-primary-600',
{
'border-green-600/30': puzzle.tags
?.map((tag) => tag.name.toLowerCase())
.includes('easy'),
'border-yellow-600/30': puzzle.tags
?.map((tag) => tag.name.toLowerCase())
.includes('medium'),
'border-red-600/30': puzzle.tags
?.map((tag) => tag.name.toLowerCase())
.includes('hard'),
'border-highlight-secondary/30': !puzzle.tags?.length
}
)}
>
<div className="flex gap-x-2">
<span className="text-base font-semibold">{puzzle.name}</span>
{puzzle.tags?.length && (
<div className="flex gap-x-2 text-sm text-muted">
{puzzle.tags
.filter((tag) => !['easy', 'medium', 'hard'].includes(tag.name))
.map((tag, i) => (
<span
key={i}
className={cn('inline-block rounded-md bg-primary-900 px-2 py-1')}
>
{tag.name}
</span>
))}
</div>
)}
</div>
<Icon
className="-translate-x-2 transform-gpu duration-300 group-hover:translate-x-0"
name="arrow-right-line"
/>
</li>
</AppLink>
))}
</ul>
{isInEventGroup(chapter) && (
<ul className="flex flex-col space-y-4">
{chapter.puzzles &&
chapter.puzzles.map((puzzle) => (
<PuzzleProp key={puzzle.id} puzzle={puzzle} chapter={chapter} />
))}
</ul>
)}
</div>
))) || (
<div className="flex flex-col space-y-6">
@ -119,3 +134,191 @@ export default function Puzzles({ token }: { token: string }) {
</>
);
}
function PuzzleProp({ puzzle, chapter }: { puzzle: Puzzle; chapter: Chapter }) {
const isPuzzleAvailable = (chapter: Chapter) => {
const today = new Date();
const startDate = new Date(chapter.startDate!);
const endDate = new Date(chapter.endDate!);
return (
(chapter.startDate && chapter.endDate && today >= startDate && today <= endDate) ||
(!chapter.startDate && !chapter.endDate)
);
};
return (
<li
className={cn(
'group relative flex h-full w-full rounded-md border-2 bg-primary-700 font-code hover:bg-primary-600',
{
'border-green-600/30': puzzle.tags?.map((tag) => tag.name.toLowerCase()).includes('easy'),
'border-yellow-600/30': puzzle.tags
?.map((tag) => tag.name.toLowerCase())
.includes('medium'),
'border-red-600/30': puzzle.tags?.map((tag) => tag.name.toLowerCase()).includes('hard'),
'border-highlight-primary': !puzzle.tags?.length,
'cursor-not-allowed': !isPuzzleAvailable(chapter)
}
)}
>
{isPuzzleAvailable(chapter) ? (
<AppLink
className="flex h-full w-full items-center justify-between p-4"
key={puzzle.id}
href={`/dashboard/puzzles/${puzzle.id}`}
>
<div className="flex gap-x-2">
<span className="text-base font-semibold">{puzzle.name}</span>
{puzzle.tags?.length && (
<div className="flex gap-x-2 text-sm text-muted">
{puzzle.tags
.filter((tag) => !['easy', 'medium', 'hard'].includes(tag.name))
.map((tag, i) => (
<span
key={i}
className={cn('inline-block rounded-md bg-primary-900 px-2 py-1')}
>
{tag.name}
</span>
))}
</div>
)}
</div>
<Icon
className="-translate-x-2 transform-gpu duration-300 group-hover:translate-x-0"
name="arrow-right-line"
/>
</AppLink>
) : (
<div className="flex h-full w-full items-center justify-between p-4 opacity-50">
<div className="flex gap-x-2">
<span className="text-base font-semibold">{puzzle.name}</span>
{puzzle.tags?.length && (
<div className="flex gap-x-2 text-sm text-muted">
{puzzle.tags
.filter((tag) => !['easy', 'medium', 'hard'].includes(tag.name))
.map((tag, i) => (
<span
key={i}
className={cn('inline-block rounded-md bg-primary-900 px-2 py-1')}
>
{tag.name}
</span>
))}
</div>
)}
</div>
</div>
)}
</li>
);
}
type GroupData = {
name?: string;
chapter?: number;
puzzle?: number;
};
function GroupForm({ chapter, token }: { chapter: Chapter; token: string }) {
const [isJoining, setIsJoining] = useState(false);
const { data: groups } = useGroups({ token });
const {
register,
handleSubmit,
formState: { errors },
setError,
reset
} = useForm<GroupData>({
defaultValues: {
name: undefined,
chapter: chapter.id,
puzzle: undefined
}
});
async function onSubmit(data: GroupData) {
await fetch(`${process.env.NEXT_PUBLIC_API_URL}/${isJoining ? 'groupJoin' : 'groupCreate'}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
Authorization: `Bearer ${token}`
}
});
}
return (
<div className="flex flex-col">
<div className="flex justify-between">
<button
onClick={() => {
setIsJoining(false);
reset();
}}
className={cn('rounded-lg p-2 font-semibold', {
'bg-primary-500': !isJoining
})}
>
Créer un groupe
</button>
<button
onClick={() => {
setIsJoining(true);
reset();
}}
className={cn('rounded-lg p-2 font-semibold', {
'bg-primary-500': isJoining
})}
>
Rejoindre un groupe
</button>
</div>
<div className="px-2 py-4">
<hr className="border-primary-600" />
</div>
<form
className="flex w-full flex-col justify-between"
onSubmit={handleSubmit(onSubmit)}
encType="multipart/form-data"
>
<div className="flex w-56 flex-col space-y-4">
{!isJoining ? (
<>
<div className="flex flex-col">
<Input
className="w-full"
label="Nom du groupe"
type="text"
placeholder="Terre en vue mon capitaine !"
required
{...register('name')}
/>
</div>
</>
) : (
<>
<Select
className="w-full"
label="Groupes"
required
{...register('name')}
options={
groups
?.filter((group) => group.chapter === chapter.id)
.map((group) => ({
title: group.name,
value: group.name
})) || []
}
/>
</>
)}
<Button kind="brand" type="submit">
{isJoining ? 'Rejoindre' : 'Créer'}
</Button>
</div>
</form>
</div>
);
}

55
ui/Timer.tsx Normal file
View file

@ -0,0 +1,55 @@
import clsx from 'clsx';
import { useEffect, useReducer } from 'react';
type State = {
hours: number;
minutes: number;
seconds: number;
};
type Action = {
type: string;
payload: Partial<State>;
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'SET_TIME_REMAINING':
return { ...state, ...action.payload };
default:
return state;
}
}
export function Timer({ targetDate, className }: { targetDate: Date; className?: string }) {
const [timeRemaining, dispatch] = useReducer(reducer, {
hours: 0,
minutes: 0,
seconds: 0
});
targetDate = new Date(targetDate);
useEffect(() => {
const intervalId = setInterval(() => {
const timeDifference = targetDate.getTime() - Date.now();
const hours = Math.floor(timeDifference / (1000 * 60 * 60));
const minutes = Math.floor((timeDifference / (1000 * 60)) % 60);
const seconds = Math.floor((timeDifference / 1000) % 60);
dispatch({
type: 'SET_TIME_REMAINING',
payload: { hours, minutes, seconds }
});
}, 1000);
return () => clearInterval(intervalId);
}, [targetDate]);
return (
<span className={clsx('', className)}>
{`${timeRemaining.hours.toString().padStart(2, '0')}:${timeRemaining.minutes
.toString()
.padStart(2, '0')}:${timeRemaining.seconds.toString().padStart(2, '0')}`}
</span>
);
}

View file

@ -2,11 +2,22 @@
import { cn } from '@/lib/utils';
import Mardown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkBreaks from 'remark-breaks';
export default function ToHTML({ data, className }: { data: string; className?: string }) {
return (
<div className={cn('select-none', className)}>
<Mardown>{data}</Mardown>
<Mardown
components={{
a: ({ node, ...props }) => (
<a {...props} className="text-brand" download={node} target="_blank" rel="noreferrer" />
)
}}
remarkPlugins={[remarkGfm, remarkBreaks]}
>
{data}
</Mardown>
</div>
);
}

View file

@ -64,7 +64,7 @@ export default function UserAuthForm() {
if (!email_valid) {
setError('email', {
type: 'manual',
message: 'Email déjà utilisé'
message: 'Adresse e-mail indisponible'
});
}
}
@ -134,7 +134,7 @@ export default function UserAuthForm() {
{isSignIn ? 'Se connecter' : "S'inscrire"}
</Button>
<div className="flex flex-col text-center">
{!isSignIn && (
{/* {!isSignIn && (
<p className="flex flex-col items-center text-sm text-muted">
En cliquant sur continuer, vous acceptez les{' '}
<AppLink className="text-white underline" href="/privacy-policy" target="_blank">
@ -142,7 +142,7 @@ export default function UserAuthForm() {
</AppLink>
.
</p>
)}
)} */}
<p className="flex flex-col items-center text-sm text-muted">
{isSignIn ? "Vous n'avez pas de compte?" : 'Vous possédez un compte?'}{' '}
<AppLink className="text-brand underline" href={isSignIn ? '/sign-up' : '/sign-in'}>

View file

@ -41,6 +41,18 @@ export default function Sidenav({ isOpen, toggle }: { isOpen: boolean; toggle: (
</div>
<div className="px-4 pt-4">
<ul className="space-y-4">
<li>
<NavItem
item={{
name: 'Discord',
slug: 'https://discord.gg/72vuHcwUkE',
icon: 'discord-fill',
disabled: false
}}
isOpen={isOpen}
onClick={toggle}
/>
</li>
<li>
<NavItem
item={{
@ -70,10 +82,15 @@ function NavItem({
onClick?: () => void;
}) {
const segment = useSelectedLayoutSegment();
const isActive = segment?.split('/').pop() === item.slug || (item.slug === '' && !segment);
const pathname = segment?.split('/').pop() || '';
const isHttp = item.slug.includes('http');
const isActive = pathname === item.slug || (item.slug === '' && !segment);
return (
<AppLink
href={item.disabled ? '/dashboard' : `/dashboard/${item.slug}`}
aria-disabled={item.disabled}
href={isHttp ? item.slug : `dashboard/${item.slug}`}
target={isHttp ? '_blank' : undefined}
rel={isHttp ? 'noopener noreferrer' : undefined}
className={cn('flex justify-center rounded-md px-3 py-3 text-sm lg:justify-start', {
'text-muted hover:text-secondary': !isActive,
'bg-highlight-primary text-secondary': isActive,

View file

@ -24,11 +24,11 @@ export default function Usernav({ isOpen, toggle }: { isOpen: boolean; toggle: (
async function handleLogout() {
cookies.remove('token');
router.refresh();
router.replace('/');
}
return (
<div className="z-50 flex w-full flex-row items-center justify-between border-b border-solid border-highlight-primary bg-secondary py-4 px-8">
<div className="z-50 flex w-full flex-row items-center justify-between border-b border-solid border-highlight-primary bg-secondary px-8 py-4">
<div className="flex flex-row items-center space-x-2 sm:space-x-0">
<div className="flex items-center">
<button onClick={toggle} className="block sm:hidden">

67
ui/events/Leaderboard.tsx Normal file
View file

@ -0,0 +1,67 @@
'use client';
import { useLeaderboardEvent } from '@/lib/hooks/use-leaderboard';
import { cn } from '@/lib/utils';
// import { Timer } from '../Timer';
import Podium from './podium/Podium';
import { useMemo } from 'react';
const SCORE_COLORS = ['text-yellow-400', 'text-gray-400', 'text-orange-400'];
export default function EventLeaderboard({ token, id }: { token: string; id: number }) {
const { data, isLoading } = useLeaderboardEvent({ token, id });
const scores = [data?.groups]
.flat()
.sort((a, b) => a!.rank - b!.rank)
.map((group, place) => ({
...group,
place
}));
return (
<section className="flex h-full w-full flex-col space-y-4 p-4">
{!isLoading && data && <Podium score={scores} />}
{/* <Timer targetDate={data && data.end_date} /> */}
<main className="flex flex-col justify-between space-x-0 space-y-4 pb-4">
<ul className="flex flex-col space-y-2">
{!isLoading &&
data?.groups.map((group, key) => (
<li key={key} className="flex justify-between space-x-2">
<div className="flex items-center space-x-4">
<span className={cn('font-semibold', SCORE_COLORS[group.rank - 1])}>
{group.rank}
</span>
<div className="flex items-center space-x-2">
<div className="flex flex-col gap-x-2 sm:flex-row sm:items-center">
<span className="text-lg">{group.name}</span>
<span className="text-sm text-muted">
{group.players
?.map((p) => p.pseudo)
.sort((a, b) => a.localeCompare(b))
.join(', ')}
</span>
</div>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="flex flex-col">
<span className="text-sm font-semibold">Essaies</span>
<span className="text-lg text-muted">
{group.players.reduce((a, b) => a + b.tries, 0)}
</span>
</div>
<div className="flex flex-col">
<span className="text-sm font-semibold">Score</span>
<span className="text-lg text-muted">
{group.players.reduce((a, b) => a + b.score, 0)}
</span>
</div>
</div>
</li>
))}
</ul>
</main>
</section>
);
}

View file

@ -0,0 +1,20 @@
import PodiumStep from './PodiumStep';
export default function Podium({ score }: { score: any }) {
const podium = [2, 0, 1]
.reduce((podiumOrder, position) => [...podiumOrder, score[position]] as any, [])
.filter(Boolean);
console.log(podium);
return (
<div
className="mt-8 grid grid-flow-col-dense place-content-center content-end items-end justify-center justify-items-center gap-2"
style={{ height: 250 }}
>
{podium.map((group: any, index: number) => (
<PodiumStep key={index} podium={podium} group={group} index={index} />
))}
</div>
);
}

View file

@ -0,0 +1,64 @@
import { motion } from 'framer-motion';
export const positions: Record<number, string> = {
0: '1 er',
1: '2 ème',
2: '3 ème'
};
export default function PodiumStep({
podium,
group,
index
}: {
podium: any;
group: any;
index: number;
}) {
return (
<div className="flex flex-col items-center">
<motion.div
custom={index}
initial="hidden"
animate="visible"
variants={{
visible: () => ({
opacity: 1,
transition: {
delay: podium.length - group.place + 1,
duration: 0.75
}
}),
hidden: { opacity: 0 }
}}
className="w-16 items-center justify-center text-center"
>
<span className="font-semibold text-white">{group.name}</span>
</motion.div>
<motion.div
custom={index}
initial="hidden"
animate="visible"
variants={{
visible: () => ({
height: 200 * ((podium.length - group.place) / podium.length),
opacity: 2,
transition: {
delay: podium.length - group.place,
duration: 1.25,
ease: 'backInOut'
}
}),
hidden: { opacity: 0, height: 0 }
}}
className="flex w-16 cursor-pointer place-content-center rounded-t-lg bg-brand shadow-lg hover:bg-opacity-80"
style={{
marginBottom: -1,
filter: `opacity(${0.1 + (podium.length - group.place) / podium.length})`
}}
>
<span className="self-end font-semibold text-white">{positions[group.place]}</span>
</motion.div>
</div>
);
}