Add missing stuff & changing leaderboard

This commit is contained in:
Théo 2023-04-21 10:27:48 +02:00
parent f41e8d6123
commit 9825f7b3de
14 changed files with 225 additions and 165 deletions

View file

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

View file

@ -1,4 +1,5 @@
import { getPuzzle } from '@/lib/puzzles'; import { getPuzzle } from '@/lib/puzzles';
import { getURL } from '@/lib/utils';
import Puzzle from '@/ui/Puzzle'; import Puzzle from '@/ui/Puzzle';
import SWRFallback from '@/ui/SWRFallback'; import SWRFallback from '@/ui/SWRFallback';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
@ -15,7 +16,22 @@ export async function generateMetadata({ params }: { params: { id: number } }):
const puzzle = await getPuzzle({ token, id }); const puzzle = await getPuzzle({ token, id });
return { title: `${puzzle.name} - Peer-at Code` }; if (!puzzle) {
notFound();
}
return {
title: puzzle.name,
openGraph: {
title: puzzle.name,
type: 'website',
url: getURL(`/dashboard/puzzles/${puzzle.id}`)
// IMAGES WITH OG IMAGE
},
twitter: {
title: puzzle.name
}
};
} }
export default async function Page({ params: { id } }: { params: { id: number } }) { export default async function Page({ params: { id } }: { params: { id: number } }) {
@ -25,11 +41,7 @@ export default async function Page({ params: { id } }: { params: { id: number }
notFound(); notFound();
} }
const puzzle = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/puzzle/${id}`, { const puzzle = await getPuzzle({ token, id });
headers: {
Authorization: `Bearer ${token}`
}
}).then((res) => res.json());
if (!puzzle) { if (!puzzle) {
notFound(); notFound();

View file

@ -1,20 +1,29 @@
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import Puzzles from '@/ui/Puzzles'; import Puzzles from '@/ui/Puzzles';
import SWRFallback from '@/ui/SWRFallback';
import { getPuzzles } from '@/lib/puzzles';
import { notFound } from 'next/navigation';
export const metadata = { export const metadata = {
title: 'Puzzles - Peer-at Code' title: 'Puzzles'
}; };
export default async function Page() { export default async function Page() {
const cookieStore = cookies(); const cookieStore = cookies();
const token = cookieStore.get('token')?.value; const token = cookieStore.get('token')!.value;
const puzzles = await getPuzzles({ token });
if (!puzzles) {
notFound();
}
return ( return (
<div className="flex flex-col space-y-6"> <div className="flex flex-col space-y-6">
{/* <SWRFallback fallback={{ ['puzzles']: chapters }}> */} <SWRFallback fallback={{ ['puzzles']: puzzles }}>
<Puzzles token={token!} /> <Puzzles token={token!} />
{/* </SWRFallback> */} </SWRFallback>
</div> </div>
); );
} }

View file

@ -11,7 +11,7 @@ export default function Page() {
<span> <span>
<Console text="Peer-at Code" className="text-6xl" /> <Console text="Peer-at Code" className="text-6xl" />
</span> </span>
<AppLink href="/dashboard/puzzles">Commencer</AppLink> <AppLink href="/sign-in">Commencer l&apos;aventure</AppLink>
</div> </div>
</div> </div>
<div className="item-center flex h-screen w-full px-2"> <div className="item-center flex h-screen w-full px-2">

View file

@ -1,23 +1,21 @@
import fetcher from './fetcher';
export const getGroups = async ({ token }: { token: string }): Promise<Group[]> => { export const getGroups = async ({ token }: { token: string }): Promise<Group[]> => {
const { data, status } = await fetcher.get(`/groups`, { const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/groups`, {
headers: { headers: {
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
} }
}); });
const groups = data; const groups = (await res.json()) as Group[];
if (status !== 200) { if (!res.ok) {
throw new Error('Failed to fetch groups'); throw new Error('Failed to fetch groups');
} }
if (!groups) { if (!groups) {
return [] as Group[]; return [];
} }
return groups as Group[]; return groups;
}; };
export type Group = { export type Group = {

View file

@ -1,24 +1,23 @@
import fetcher from './fetcher';
import { type Group } from './groups'; import { type Group } from './groups';
export const getScores = async ({ token }: { token: string }): Promise<Score[]> => { export const getScores = async ({ token }: { token: string }): Promise<Score[]> => {
const { data, status } = await fetcher.get(`/leaderboard`, { const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/leaderboard`, {
headers: { headers: {
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
} }
}); });
const scores = data; const scores = (await res.json()) as Score[];
if (status !== 200) { if (!res.ok) {
throw new Error('Failed to fetch scores'); throw new Error('Failed to fetch scores');
} }
if (!scores) { if (!scores) {
return [] as Score[]; return [];
} }
return scores as Score[]; return scores;
}; };
export const getScoresEvent = async ({ export const getScoresEvent = async ({
@ -28,16 +27,16 @@ export const getScoresEvent = async ({
token: string; token: string;
id: number; id: number;
}): Promise<ScoreEvent> => { }): Promise<ScoreEvent> => {
const { data, status } = await fetcher.get(`/leaderboard/${id}`, { const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/leaderboard/${id}`, {
headers: { headers: {
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
} }
}); });
const scores = data; const scores = (await res.json()) as ScoreEvent;
if (status !== 200) { if (!res.ok) {
throw new Error('Failed to fetch scores'); throw new Error('Failed to fetch event scores');
} }
if (!scores) { if (!scores) {

View file

@ -1,4 +1,3 @@
import fetcher from './fetcher';
import { type Group } from './groups'; import { type Group } from './groups';
export const getPlayer = async ({ export const getPlayer = async ({
@ -8,15 +7,15 @@ export const getPlayer = async ({
token: string; token: string;
username?: string; username?: string;
}): Promise<Player | null> => { }): Promise<Player | null> => {
const { data, status } = await fetcher.get(`/player/${username}`, { const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/player/${username}`, {
headers: { headers: {
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
} }
}); });
const player = data; const player = (await res.json()) as Player;
if (status !== 200) { if (!res.ok) {
throw new Error('Failed to fetch player'); throw new Error('Failed to fetch player');
} }
@ -24,7 +23,7 @@ export const getPlayer = async ({
return null; return null;
} }
return player as Player; return player;
}; };
export type Player = { export type Player = {

View file

@ -1,15 +1,13 @@
import fetcher from './fetcher';
export const getChapters = async ({ token }: { token: string }): Promise<Chapter[]> => { export const getChapters = async ({ token }: { token: string }): Promise<Chapter[]> => {
const { data, status } = await fetcher.get(`/chapters`, { const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/chapters`, {
headers: { headers: {
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
} }
}); });
const chapters = data; const chapters = (await res.json()) as Chapter[];
if (status !== 200) { if (!res.ok) {
throw new Error('Failed to fetch puzzles'); throw new Error('Failed to fetch puzzles');
} }
@ -17,7 +15,7 @@ export const getChapters = async ({ token }: { token: string }): Promise<Chapter
return []; return [];
} }
return chapters as Chapter[]; return chapters;
}; };
export const getChapter = async ({ export const getChapter = async ({
@ -27,15 +25,15 @@ export const getChapter = async ({
token: string; token: string;
id: number; id: number;
}): Promise<Chapter | null> => { }): Promise<Chapter | null> => {
const { data, status } = await fetcher.get(`/chapter/${id}`, { const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/chapter/${id}`, {
headers: { headers: {
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
} }
}); });
const chapter = data; const chapter = (await res.json()) as Chapter;
if (status !== 200) { if (!res.ok) {
throw new Error('Failed to fetch puzzles'); throw new Error('Failed to fetch puzzles');
} }
@ -43,40 +41,50 @@ export const getChapter = async ({
return null; return null;
} }
return chapter as Chapter; return chapter;
}; };
export const getPuzzles = async ({ token }: { token: string }): Promise<Chapter[]> => { export const getPuzzles = async ({ token }: { token: string }): Promise<Chapter[]> => {
const chapters = await getChapters({ token }); const chapters = await getChapters({ token });
for (let i = 0; i < chapters.length; i++) { if (!chapters) {
const chapter = chapters[i]; return [];
const chapterData = await getChapter({ token, id: chapter.id });
if (!chapterData) continue;
chapters[i].puzzles = chapterData.puzzles;
} }
return chapters as Chapter[]; for (let i = 0; i < chapters.length; i++) {
let chapter = chapters[i];
chapter = (await getChapter({ token, id: chapter.id })) as Chapter;
if (!chapter) continue;
chapters[i].puzzles = chapter.puzzles;
}
return chapters;
}; };
export const getPuzzle = async ({ token, id }: { token: string; id: number }): Promise<Puzzle> => { export const getPuzzle = async ({
const { data, status } = await fetcher.get(`/puzzle/${id}`, { token,
id
}: {
token: string;
id: number;
}): Promise<Puzzle | null> => {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/puzzle/${id}`, {
headers: { headers: {
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
} }
}); });
const puzzle = data; const puzzle = (await res.json()) as Puzzle;
if (status !== 200) { if (!res.ok) {
throw new Error('Failed to fetch puzzle'); throw new Error('Failed to fetch puzzle');
} }
if (!puzzle) { if (!puzzle) {
return {} as Puzzle; return null;
} }
return puzzle as Puzzle; return puzzle;
}; };
export type Puzzle = { export type Puzzle = {

View file

@ -7,7 +7,7 @@ export default function Console({ text, className }: { text: string; className?:
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [typingIndex, setTypingIndex] = useState(0); const [typingIndex, setTypingIndex] = useState(0);
const typingDelay = 400; // The delay between each character being typed, in milliseconds const typingDelay = 200; // The delay between each character being typed, in milliseconds
useEffect(() => { useEffect(() => {
const intervalId = setInterval(() => { const intervalId = setInterval(() => {

View file

@ -1,19 +1,15 @@
'use client'; 'use client';
import { useLeaderboard } from '@/lib/hooks/use-leaderboard';
import { cn } from '@/lib/utils';
import { useMemo, useState } from 'react';
import AvatarComponent from './Avatar';
import Select from './Select';
import { type ScoreEvent } from '@/lib/leaderboard';
import useSWRSubscription, { type SWRSubscription } from 'swr/subscription'; import useSWRSubscription, { type SWRSubscription } from 'swr/subscription';
import { useGroups } from '@/lib/hooks/use-groups';
import { type ScoreEvent } from '@/lib/leaderboard';
import { cn } from '@/lib/utils';
import Podium from '@/ui/events/podium/Podium';
const SCORE_COLORS = ['text-yellow-400', 'text-gray-400', 'text-orange-400']; const SCORE_COLORS = ['text-yellow-400', 'text-gray-400', 'text-orange-400'];
export default function Leaderboard({ token }: { token: string }) { export default function Leaderboard() {
// const { data, isLoading } = useLeaderboard({ token }); // TODO CHANGER CECI
const CHAPITRE_EVENT = 3; const CHAPITRE_EVENT = 3;
const subscription: SWRSubscription<string, ScoreEvent, Error> = (key, { next }) => { const subscription: SWRSubscription<string, ScoreEvent, Error> = (key, { next }) => {
@ -31,34 +27,17 @@ export default function Leaderboard({ token }: { token: string }) {
}; };
const { data } = useSWRSubscription( const { data } = useSWRSubscription(
`wss://${process.env.NEXT_PUBLIC_API_URL?.split('//')[1]}/rleaderboard${CHAPITRE_EVENT}`, `wss://${process.env.NEXT_PUBLIC_API_URL?.split('//')[1]}/rleaderboard/${CHAPITRE_EVENT}`,
subscription subscription
); );
const [filter, setFilter] = useState(''); const scores = [data?.groups]
.flat()
let options = [] as { value: string; title: string }[]; .sort((a, b) => a!.rank - b!.rank)
.map((group, place) => ({
const { data: groups } = useGroups({ token }); ...group,
place
console.log(groups); }));
if (groups) {
options = groups
.filter((group) => group.chapter === null)
.map((group) => ({ value: group.name, title: group.name }))
.filter((group, index, self) => self.findIndex((g) => g.value === group.value) === index)
.sort((a, b) => a.title.localeCompare(b.title));
options.unshift({ value: '', title: 'Tous' });
}
const filteredData = useMemo(() => {
if (filter) {
return data?.groups.filter((group) => group.name === filter);
}
return data;
}, [data, filter]);
return ( return (
<section className="flex h-full w-full flex-col space-y-4"> <section className="flex h-full w-full flex-col space-y-4">
@ -67,23 +46,9 @@ export default function Leaderboard({ token }: { token: string }) {
<h1 className="text-xl font-semibold">Tableau des scores</h1> <h1 className="text-xl font-semibold">Tableau des scores</h1>
<p className="text-muted">Suivez la progression des élèves en direct</p> <p className="text-muted">Suivez la progression des élèves en direct</p>
</div> </div>
{/* {(filteredData && (
<Select
className="w-32"
options={options || []}
value={filter}
onChange={(event) => setFilter(event.target.value)}
/>
)) || (
<span
className="inline-block h-12 w-32 animate-pulse rounded-lg bg-primary-600"
style={{
animationDuration: '1s'
}}
/>
)} */}
</header> </header>
<main className="flex flex-col justify-between space-x-0 space-y-4 pb-4"> <main className="flex flex-col justify-between space-x-0 space-y-4 pb-4">
{data && <Podium score={scores} />}
<ul className="flex flex-col space-y-2"> <ul className="flex flex-col space-y-2">
{data?.groups.map((group, key) => ( {data?.groups.map((group, key) => (
<li key={key} className="flex justify-between space-x-2"> <li key={key} className="flex justify-between space-x-2">

View file

@ -75,7 +75,10 @@ export default function Puzzle({ token, id }: { token: string; id: number }) {
return ( return (
<div className="flex h-full w-full flex-col justify-between space-y-4"> <div className="flex h-full w-full flex-col justify-between space-y-4">
<h1 className="text-2xl font-bold sm:text-3xl md:text-4xl">{puzzle.name}</h1> <h1 className="text-2xl font-bold sm:text-3xl md:text-4xl">
{puzzle.name}{' '}
<span className="text-xl text-highlight-secondary">({puzzle.scoreMax} points)</span>
</h1>
<div className="flex h-screen w-full overflow-y-auto"> <div className="flex h-screen w-full overflow-y-auto">
<ToHTML className="font-code text-xs sm:text-base" data={puzzle.content} /> <ToHTML className="font-code text-xs sm:text-base" data={puzzle.content} />
</div> </div>
@ -120,8 +123,7 @@ export default function Puzzle({ token, id }: { token: string; id: number }) {
Tentatives : <span className="text-brand-accent">{puzzle.tries}</span> Tentatives : <span className="text-brand-accent">{puzzle.tries}</span>
</p> </p>
<p> <p>
Score : <span className="text-brand-accent">{puzzle.score}</span> (Score maximum:{' '} Score : <span className="text-brand-accent">{puzzle.score}</span>
{puzzle.scoreMax})
</p> </p>
</div> </div>
<AppLink href="/dashboard/puzzles"> <AppLink href="/dashboard/puzzles">

View file

@ -1,25 +1,29 @@
'use client'; 'use client';
import { UserContext } from '@/context/user'; import { useRouter } from 'next/navigation';
import { useGroups } from '@/lib/hooks/use-groups'; import { useContext, useMemo, useState } from 'react';
import { usePuzzles } from '@/lib/hooks/use-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 { useForm } from 'react-hook-form';
import { useSWRConfig } from 'swr';
import AppLink from './AppLink'; import AppLink from './AppLink';
import Button from './Button'; import Button from './Button';
import Dialog from './Dialog'; import Dialog from './Dialog';
import Icon from './Icon'; import Icon from './Icon';
import Input from './Input'; import Input from './Input';
import Select from './Select'; import Select from './Select';
import { useRouter } from 'next/navigation';
import { useSWRConfig } from 'swr'; import { UserContext } from '@/context/user';
import { useGroups } from '@/lib/hooks/use-groups';
import { usePuzzles } from '@/lib/hooks/use-puzzles';
import type { Chapter, Puzzle } from '@/lib/puzzles';
import { cn } from '@/lib/utils';
export default function Puzzles({ token }: { token: string }) { export default function Puzzles({ token }: { token: string }) {
const { data: me } = useContext(UserContext); const { data: me } = useContext(UserContext);
const { data, isLoading } = usePuzzles({ token }); const { data, isLoading } = usePuzzles({ token });
const [isOpen, setIsOpen] = useState<boolean[]>([]); const [isOpen, setIsOpen] = useState<boolean[]>([]);
const [filter, setFilter] = useState<string>('');
const [filterChapter, setFilterChapter] = useState<number>();
function handleClick(index: number) { function handleClick(index: number) {
setIsOpen((prevState) => { setIsOpen((prevState) => {
@ -37,12 +41,23 @@ export default function Puzzles({ token }: { token: string }) {
); );
} }
const filteredData = useMemo(() => {
if (filter && filterChapter) {
console.log(filter);
return data
?.find((chapter) => chapter.id === filterChapter)
?.puzzles.filter((puzzle) => puzzle!.tags!.some((tag) => tag.name === filter))
.map((puzzle) => puzzle);
}
return data?.find((chapter) => chapter.id === filterChapter)?.puzzles;
}, [data, filter, filterChapter]);
return ( return (
<> <>
{(!isLoading && {(!isLoading &&
data?.map((chapter) => ( data?.map((chapter) => (
<div key={chapter.id} className="flex flex-col space-y-4"> <div key={chapter.id} className="flex flex-col">
<div className="flex flex-col justify-between lg:flex-row lg:items-center"> <div className="flex flex-col justify-between md:flex-row md:items-center">
<div className="flex items-center gap-x-2"> <div className="flex items-center gap-x-2">
<h1 className="text-xl font-semibold">{chapter.name}</h1> <h1 className="text-xl font-semibold">{chapter.name}</h1>
{!isInEventGroup(chapter) && ( {!isInEventGroup(chapter) && (
@ -63,35 +78,48 @@ export default function Puzzles({ token }: { token: string }) {
</Dialog> </Dialog>
)} )}
</div> </div>
{chapter.startDate && chapter.endDate ? ( <div className="flex flex-col">
<div className="flex items-center gap-x-2"> {chapter.startDate && chapter.endDate ? (
<Icon name="calendar-line" className="text-sm text-muted" /> <div className="flex items-center gap-x-2">
<span className="text-sm text-muted"> <Icon name="calendar-line" className="text-sm text-muted" />
{new Date(chapter.startDate).toLocaleDateString('fr-FR', { <span className="text-sm text-muted">
day: 'numeric', {new Date(chapter.startDate).toLocaleDateString('fr-FR', {
month: 'long', day: 'numeric',
year: 'numeric', month: 'long',
hour: 'numeric' year: 'numeric',
})}{' '} hour: 'numeric'
-{' '} })}{' '}
{new Date(chapter.endDate).toLocaleDateString('fr-FR', { -{' '}
day: 'numeric', {new Date(chapter.endDate).toLocaleDateString('fr-FR', {
month: 'long', day: 'numeric',
year: 'numeric', month: 'long',
hour: 'numeric' year: 'numeric',
})} hour: 'numeric'
</span> })}
</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 className="mt-1 flex justify-end">
{isInEventGroup(chapter) && (
<FilterChapter
chapters={data}
chapter={chapter}
filter={filter}
setFilter={setFilter}
setFilterChapter={setFilterChapter}
/>
)}
</div> </div>
) : ( </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> </div>
{isInEventGroup(chapter) && ( {isInEventGroup(chapter) && (
<ul className="flex flex-col space-y-4"> <ul className="mt-4 flex flex-col space-y-4">
{chapter.puzzles && {filteredData &&
chapter.puzzles filteredData
.sort((p1, p2) => { .sort((p1, p2) => {
const p1Tags = p1.tags || []; const p1Tags = p1.tags || [];
const p2Tags = p2.tags || []; const p2Tags = p2.tags || [];
@ -181,17 +209,19 @@ function PuzzleProp({ puzzle, chapter }: { puzzle: Puzzle; chapter: Chapter }) {
key={puzzle.id} key={puzzle.id}
href={`/dashboard/puzzles/${puzzle.id}`} href={`/dashboard/puzzles/${puzzle.id}`}
> >
<div className="flex w-10/12 flex-col gap-2 md:w-full md:flex-row"> <div className="flex w-10/12 flex-col gap-2 lg:w-full lg:flex-row">
<span className="text-base font-semibold">{puzzle.name}</span> <span className="text-base font-semibold">
{puzzle.name}{' '}
<span className="text-sm text-highlight-secondary">
({puzzle.score ? `${puzzle.score}` : '?'}/{puzzle.scoreMax} points)
</span>
</span>
{puzzle.tags?.length && ( {puzzle.tags?.length && (
<div className="flex gap-x-2 text-sm text-muted"> <div className="flex gap-x-2 text-sm text-muted">
{puzzle.tags {puzzle.tags
.filter((tag) => !['easy', 'medium', 'hard'].includes(tag.name)) .filter((tag) => !['easy', 'medium', 'hard'].includes(tag.name))
.map((tag, i) => ( .map((tag, i) => (
<span <span key={i} className="inline-block rounded-md bg-primary-900 px-2 py-1">
key={i}
className={cn('inline-block rounded-md bg-primary-900 px-2 py-1')}
>
{tag.name} {tag.name}
</span> </span>
))} ))}
@ -206,7 +236,9 @@ function PuzzleProp({ puzzle, chapter }: { puzzle: Puzzle; chapter: Chapter }) {
) : ( ) : (
<div className="flex h-full w-full items-center justify-between p-4 opacity-50"> <div className="flex h-full w-full items-center justify-between p-4 opacity-50">
<div className="flex gap-x-2"> <div className="flex gap-x-2">
<span className="text-base font-semibold">{puzzle.name}</span> <span className="text-base font-semibold">
{puzzle.name} ({puzzle.scoreMax})
</span>
{puzzle.tags?.length && ( {puzzle.tags?.length && (
<div className="flex gap-x-2 text-sm text-muted"> <div className="flex gap-x-2 text-sm text-muted">
{puzzle.tags {puzzle.tags
@ -228,6 +260,44 @@ function PuzzleProp({ puzzle, chapter }: { puzzle: Puzzle; chapter: Chapter }) {
); );
} }
function FilterChapter({
chapters,
chapter,
filter,
setFilter,
setFilterChapter
}: {
chapters: Chapter[];
chapter: Chapter;
filter: string;
setFilter: (filter: string) => void;
setFilterChapter: (chapter: number) => void;
}) {
let options = [] as { title: string; value: string }[];
options = chapters
.find((c) => c.id === chapter.id)
?.puzzles?.map((p) => p.tags)
.flat()
.filter((tag, index, self) => self.findIndex((t) => t!.name === tag!.name) === index)
.map((t) => {
return { title: t!.name, value: t!.name };
}) as { title: string; value: string }[];
options?.unshift({ title: 'Tout les puzzles', value: '' });
setFilterChapter(chapter.id);
return (
<Select
className="w-44"
options={options || []}
value={filter}
onChange={(event) => setFilter(event.target.value)}
/>
);
}
type GroupData = { type GroupData = {
name?: string; name?: string;
chapter?: number; chapter?: number;

View file

@ -2,27 +2,27 @@
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import Mardown from 'react-markdown'; import Mardown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkBreaks from 'remark-breaks'; import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
export default function ToHTML({ data, className }: { data: string; className?: string }) { export default function ToHTML({ data, className }: { data: string; className?: string }) {
return ( return (
<div className={cn(className)}> <div className={cn(className)}>
<Mardown <Mardown
components={{ components={{
a: ({ ...props }) => ( a: ({ node, ...props }) => (
<a <a
{...props} {...props}
className="text-brand-accent hover:text-brand hover:underline" className="inline text-brand-accent hover:text-brand hover:underline"
// MAKE thIS SHIT DOWNLOAD // MAKE thIS SHIT DOWNLOADABLE
target="_blank" target="_blank"
rel="noopener" rel="noopener"
/> />
), ),
code: ({ ...props }) => ( code: ({ node, ...props }) => (
<code <code
{...props} {...props}
className="cursor-pointer select-none text-transparent hover:text-highlight-secondary" className="cursor-default select-none text-transparent transition-colors delay-150 hover:text-highlight-secondary"
/> />
) )
}} }}

View file

@ -25,7 +25,7 @@ export default function PodiumStep({
visible: () => ({ visible: () => ({
opacity: 1, opacity: 1,
transition: { transition: {
delay: podium.length - group.place + 1, delay: podium.length - group.place + 0.5,
duration: 0.75 duration: 0.75
} }
}), }),
@ -44,7 +44,7 @@ export default function PodiumStep({
height: 200 * ((podium.length - group.place) / podium.length), height: 200 * ((podium.length - group.place) / podium.length),
opacity: 2, opacity: 2,
transition: { transition: {
delay: podium.length - group.place, delay: podium.length - group.place - 0.5,
duration: 1.25, duration: 1.25,
ease: 'backInOut' ease: 'backInOut'
} }