React auth provider

This commit is contained in:
Théo 2023-03-28 09:27:48 +02:00
parent 02956c9ef1
commit fef736f547
21 changed files with 200 additions and 122 deletions

View file

@ -1,2 +1,2 @@
NEXT_PUBLIC_API_URL=API
NEXT_PUBLIC_SITE_URL=SITE NEXT_PUBLIC_SITE_URL=SITE
NEXT_PUBLIC_API_URL=API

View file

@ -1,11 +1,11 @@
import Badge from '@/ui/Badge'; 'use client';
export const metadata = { import { UserContext } from '@/context/user';
title: 'Mes badges - Peer-at Code' import Badge from '@/ui/Badge';
}; import { useContext } from 'react';
export default function Page() { export default function Page() {
// TODO: Fetch badges from API and display them const { data: me } = useContext(UserContext);
return ( return (
<div className="flex h-full w-full flex-col space-y-4"> <div className="flex h-full w-full flex-col space-y-4">
<div className="w-full"> <div className="w-full">
@ -18,15 +18,19 @@ export default function Page() {
</header> </header>
<main className="flex flex-col justify-between space-x-0 space-y-4"> <main className="flex flex-col justify-between space-x-0 space-y-4">
<div className="flex space-x-2"> <div className="flex space-x-2">
<Badge title="Je suis un teste" path="/assets/badges/java.png" alt="je suis un alt" /> {me?.badges ? (
<Badge title="Je suis un teste" path="/assets/badges/java.png" alt="je suis un alt" /> me?.badges.map((badge, i) => (
<Badge <Badge
title="Peer-at What ?" key={i}
path="/assets/badges/java.png" name={badge.name}
type="hard" src={badge.logo || '/assets/badges/java.png'}
alt="je suis un alt" alt={badge.name}
earned level={badge.level}
/> />
))
) : (
<p className="text-muted">Aucun badge</p>
)}
</div> </div>
</main> </main>
</section> </section>

View file

@ -1,11 +1,16 @@
import { type ReactNode } from 'react'; import { type ReactNode } from 'react';
import { UserProvider } from '@/context/user';
import Wrapper from '@/ui/dashboard/Wrapper'; import Wrapper from '@/ui/dashboard/Wrapper';
import { cookies } from 'next/headers';
export default function Layout({ children }: { children: ReactNode }) { export default async function Layout({ children }: { children: ReactNode }) {
const token = cookies().get('token')!.value;
return ( return (
<div className="flex h-screen w-full flex-col"> <div className="flex h-screen w-full flex-col">
<UserProvider token={token}>
<Wrapper>{children}</Wrapper> <Wrapper>{children}</Wrapper>
</UserProvider>
</div> </div>
); );
} }

View file

@ -1,12 +1,11 @@
'use client'; 'use client';
import { useMe } from '@/lib/hooks/use-players'; import { UserContext } from '@/context/user';
import Card from '@/ui/Card'; import Card from '@/ui/Card';
import cookies from 'js-cookie'; import { useContext } from 'react';
export default function Page() { export default function Page() {
const token = cookies.get('token'); const { data: me, isLoading } = useContext(UserContext);
const { data: me, isLoading } = useMe({ token: token! });
return ( return (
<div className="flex h-full w-full flex-col space-y-4"> <div className="flex h-full w-full flex-col space-y-4">
<div className="w-full"> <div className="w-full">
@ -19,21 +18,16 @@ export default function Page() {
<Card <Card
isLoading={isLoading} isLoading={isLoading}
icon="pie-chart-line" icon="pie-chart-line"
title="Puzzles" title="Puzzles résolus"
data={me?.completions} data={me?.completions}
/> />
<Card <Card
isLoading={isLoading} isLoading={isLoading}
icon="award-line" icon="award-line"
title="Badges" title="Badges obtenus"
data={me?.badges || 'Aucun'} data={me?.badges?.length || 'Aucun'}
/>
<Card
isLoading={isLoading}
icon="bar-chart-line"
title="Score (classement plus tard)"
data={me?.score}
/> />
<Card isLoading={isLoading} icon="bar-chart-line" title="Rang actuel" data={me?.rank} />
</main> </main>
</section> </section>
</div> </div>

20
context/user.tsx Normal file
View file

@ -0,0 +1,20 @@
'use client';
import { useMe } from '@/lib/hooks/use-players';
import type { Player } from '@/lib/players';
import { createContext, type ReactNode } from 'react';
export const UserContext = createContext<{
data: Player | null | undefined;
isLoading: boolean;
error: Error | null;
}>({
data: null,
isLoading: true,
error: null
});
export const UserProvider = ({ token, children }: { token: string; children: ReactNode }) => {
const { data, isLoading, error } = useMe({ token });
return <UserContext.Provider value={{ data, isLoading, error }}>{children}</UserContext.Provider>;
};

View file

@ -2,9 +2,7 @@ import useSWR from 'swr';
import { getPlayer } from '../players'; import { getPlayer } from '../players';
export function useMe({ token }: { token: string }) { export function useMe({ token }: { token: string }) {
return useSWR('me', () => getPlayer({ token }), { return useSWR('me', () => getPlayer({ token }));
revalidateOnReconnect: false
});
} }
export function usePlayer({ token, username }: { token: string; username: string }) { export function usePlayer({ token, username }: { token: string; username: string }) {

View file

@ -1,4 +1,5 @@
import fetcher from './fetcher'; import fetcher from './fetcher';
import type { Group } from './players';
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 { data, status } = await fetcher.get(`/leaderboard`, {
@ -25,6 +26,7 @@ export type Score = {
tries: number; tries: number;
completions: number; completions: number;
pseudo: string; pseudo: string;
group: string; groups: Group[];
avatar: string; avatar: string;
rank: number;
}; };

View file

@ -48,6 +48,6 @@ export const navItems: NavItem[] = [
name: 'Paramètres', name: 'Paramètres',
slug: 'settings', slug: 'settings',
icon: 'equalizer-line', icon: 'equalizer-line',
disabled: false disabled: true
} }
]; ];

View file

@ -28,14 +28,26 @@ export const getPlayer = async ({
export type Player = { export type Player = {
email: string; email: string;
pseudo: string;
firstnames: string; firstnames: string;
lastname: string; lastname: string;
description: string; description: string;
avatar: string; avatar: string;
group: string; groups: Group[];
score: number; score: number;
tries: number; tries: number;
completions: number; completions: number;
pseudo: string; rank: number;
badges: any[]; badges: Badge[] | null;
};
export type Badge = {
name: string;
level: number;
logo?: string;
};
export type Group = {
name: string;
chapter?: number;
}; };

View file

@ -7,7 +7,7 @@ export const getChapters = async ({ token }: { token: string }): Promise<Chapter
} }
}); });
let chapters = data; const chapters = data;
if (status !== 200) { if (status !== 200) {
throw new Error('Failed to fetch puzzles'); throw new Error('Failed to fetch puzzles');
@ -17,8 +17,6 @@ export const getChapters = async ({ token }: { token: string }): Promise<Chapter
return []; return [];
} }
chapters = chapters.filter((chapter: Chapter) => chapter.id !== 0);
return chapters as Chapter[]; return chapters as Chapter[];
}; };
@ -48,24 +46,17 @@ export const getChapter = async ({
return chapter as Chapter; return chapter as Chapter;
}; };
export const getPuzzles = async ({ export const getPuzzles = async ({ token }: { token: string }): Promise<Chapter[]> => {
token
}: {
token: string;
}): Promise<{ chapters: Chapter[]; puzzles: Puzzle[] }> => {
const chapters = await getChapters({ token }); const chapters = await getChapters({ token });
const puzzles: Puzzle[] = [];
for (const chapter of chapters) { for (let i = 0; i < chapters.length; i++) {
const puzzlesByChapter = await getChapter({ token, id: chapter.id }); const chapter = chapters[i];
if (!puzzlesByChapter?.puzzles) continue; const chapterData = await getChapter({ token, id: chapter.id });
puzzles.push(...puzzlesByChapter!.puzzles); if (!chapterData) continue;
chapters[i].puzzles = chapterData.puzzles;
} }
return { return chapters as Chapter[];
chapters: chapters as Chapter[],
puzzles: puzzles as Puzzle[]
};
}; };
export const getPuzzle = async ({ token, id }: { token: string; id: number }): Promise<Puzzle> => { export const getPuzzle = async ({ token, id }: { token: string; id: number }): Promise<Puzzle> => {
@ -92,10 +83,17 @@ export type Puzzle = {
id: number; id: number;
name: string; name: string;
content: string; content: string;
tags: Tag[] | null;
}; };
export type Chapter = { export type Chapter = {
name: string;
id: number; id: number;
name: string;
puzzles: Puzzle[]; puzzles: Puzzle[];
startDay?: string;
endDay?: string;
};
export type Tag = {
name: string;
}; };

View file

@ -9,10 +9,6 @@ import { getURL } from './lib/utils';
export async function middleware(req: NextRequest) { export async function middleware(req: NextRequest) {
const res = NextResponse.next(); const res = NextResponse.next();
// on donne accès à l'API depuis n'importe quelle origine
res.headers.set('Access-Control-Allow-Origin', '*');
res.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
const token = req.cookies.get('token')?.value; const token = req.cookies.get('token')?.value;
if (req.nextUrl.pathname.includes('dashboard') && !token) if (req.nextUrl.pathname.includes('dashboard') && !token)

View file

@ -1,6 +1,5 @@
{ {
"name": "peer-at-code", "name": "peer-at-code",
"version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View file

@ -1,3 +1,4 @@
// eslint-disable-next-line @typescript-eslint/no-var-requires
const defaultTheme = require('tailwindcss/defaultTheme'); const defaultTheme = require('tailwindcss/defaultTheme');
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */

View file

@ -2,42 +2,37 @@ import Image from 'next/image';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export type Difficulty = 'easy' | 'medium' | 'hard'; export const DIFFICULTY_COLOR = {
1: 'green',
export const DIFFICULTY = { 2: 'yellow',
1: 'easy', 3: 'red'
2: 'medium', };
3: 'hard'
}
export default function Badge({ export default function Badge({
title, name,
path, src,
alt, alt,
type = 'easy', level
earned = false
}: { }: {
title: string; name: string;
path: string; src: string;
alt: string; alt: string;
type?: Difficulty; level: number;
earned?: boolean;
}) { }) {
return ( return (
<div className="flex w-24 flex-col space-y-2 text-center"> <div className="flex w-24 flex-col space-y-2 text-center">
<Image <Image
src={path} src={`data:image;base64,${src}`}
alt={alt} alt={alt}
className={cn(`rounded-full border-2 lg:border-4`, { className={cn(`rounded-full border-2 lg:border-4`, {
'border-green-600': type === 'easy', 'border-green-600': level === 1,
'border-yellow-600': type === 'medium', 'border-yellow-600': level === 2,
'border-red-600': type === 'hard', 'border-red-600': level === 3
'border-gray-600 opacity-40': !earned
})} })}
width={500} width={500}
height={500} height={500}
/> />
<span className="text-sm font-semibold">{earned ? title : '****'}</span> <span className="text-sm font-semibold">{name}</span>
</div> </div>
); );
} }

View file

@ -1,6 +1,5 @@
import { forwardRef } from 'react'; import { forwardRef } from 'react';
import ErrorMessage from './ErrorMessage'; import ErrorMessage from './ErrorMessage';
import Icon from './Icon';
import Label from './Label'; import Label from './Label';
const Input = forwardRef< const Input = forwardRef<

View file

@ -6,22 +6,23 @@ import { useMemo, useState } from 'react';
import AvatarComponent from './Avatar'; import AvatarComponent from './Avatar';
import Select from './Select'; import Select from './Select';
const scoreColors = ['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({ token }: { token: string }) {
const { data, isLoading } = useLeaderboard({ token }); const { data, isLoading } = useLeaderboard({ token });
const [filter, setFilter] = useState(''); const [filter, setFilter] = useState('');
let options; let options = [] as { value: string; title: string }[];
if (data) { if (data) {
options = data options = data
.filter((score, index, self) => { .filter((score) => score.groups && score.groups.length > 0)
return index === self.findIndex((t) => t.group === score.group) && score.group !== ''; .map((score) => score.groups.map((group) => ({ value: group.name, title: group.name })))
}) .flat()
.sort((a, b) => (a.group > b.group ? 1 : -1)) .filter((group, index, self) => self.findIndex((g) => g.value === group.value) === index)
.map((score) => ({ value: score.group, title: score.group })); .sort((a, b) => a.title.localeCompare(b.title));
options.unshift({ value: '', title: 'Tous' }); options.unshift({ value: '', title: 'Tous' });
options.push({ value: 'no-group', title: 'Sans groupe' }); options.push({ value: 'no-group', title: 'Sans groupe' });
} }
@ -29,9 +30,9 @@ export default function Leaderboard({ token }: { token: string }) {
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
if (filter) { if (filter) {
if (filter === 'no-group') { if (filter === 'no-group') {
return data?.filter((score) => score.group === ''); return data?.filter((score) => !score.groups || score.groups.length === 0);
} }
return data?.filter((score) => score.group === filter); return data?.filter((score) => score.groups?.find((group) => group.name === filter));
} }
return data; return data;
}, [data, filter]); }, [data, filter]);
@ -65,12 +66,16 @@ export default function Leaderboard({ token }: { token: string }) {
filteredData?.map((score, key) => ( filteredData?.map((score, key) => (
<li key={key} className="flex justify-between space-x-2"> <li key={key} className="flex justify-between space-x-2">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<span className={cn('font-semibold', scoreColors[key])}>{key + 1}</span> <span className={cn('font-semibold', SCORE_COLORS[score.rank - 1])}>
{score.rank}
</span>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<AvatarComponent name={score.pseudo} src={score.avatar} className="h-9 w-9" /> <AvatarComponent name={score.pseudo} src={score.avatar} className="h-9 w-9" />
<div className="flex flex-col gap-x-2 sm:flex-row sm:items-center"> <div className="flex flex-col gap-x-2 sm:flex-row sm:items-center">
<span className="text-lg">{score.pseudo}</span> <span className="text-lg">{score.pseudo}</span>
<span className="text-sm text-muted">{score.group}</span> <span className="text-sm text-muted">
{score.groups?.map((g) => g.name).join(', ')}
</span>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,30 +1,82 @@
'use client'; 'use client';
import { usePuzzles } from '@/lib/hooks/use-puzzles'; import { usePuzzles } from '@/lib/hooks/use-puzzles';
import { type Chapter } from '@/lib/puzzles';
import { cn } from '@/lib/utils';
import AppLink from './AppLink'; import AppLink from './AppLink';
import Icon from './Icon'; import Icon from './Icon';
export default function Puzzles({ token }: { token: string }) { export default function Puzzles({ token }: { token: string }) {
const { data, isLoading } = usePuzzles({ token }); const { data, isLoading } = usePuzzles({ token });
console.log(data);
// 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
function isChapterLocked(chapter: Chapter) {
return (
chapter.startDay &&
chapter.endDay &&
new Date() > new Date(chapter.startDay) &&
new Date() < new Date(chapter.endDay)
);
}
return ( return (
<> <>
{(!isLoading && {(!isLoading &&
data?.chapters?.map((chapter) => ( data?.map((chapter) => (
<div key={chapter.id} className="flex flex-col space-y-4"> <div key={chapter.id} className="flex flex-col space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-xl font-semibold sm:text-2xl"> <h3 className="text-xl font-semibold sm:text-2xl">
Chapitre {chapter.id} - {chapter.name} Chapitre {chapter.id} - {chapter.name}{' '}
</h3> </h3>
<div className="h-1 w-1/4 rounded-lg bg-gray-200"> <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="h-1 w-1/2 rounded-lg bg-gradient-to-tl from-brand to-brand-accent" />
</div> </div>
</div> </div>
<ul className="flex flex-col space-y-4"> <ul
{data?.puzzles.map((puzzle) => ( 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}`}> <AppLink key={puzzle.id} href={`/dashboard/puzzles/${puzzle.id}`}>
<li className="group flex items-center justify-between rounded-md bg-primary-700 p-4 font-code hover:bg-primary-600"> <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> <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 <Icon
className="-translate-x-2 transform-gpu duration-300 group-hover:translate-x-0" className="-translate-x-2 transform-gpu duration-300 group-hover:translate-x-0"
name="arrow-right-line" name="arrow-right-line"

View file

@ -1,5 +1,4 @@
import { forwardRef } from 'react'; import { forwardRef } from 'react';
import type { UseFormRegister } from 'react-hook-form';
import ErrorMessage from './ErrorMessage'; import ErrorMessage from './ErrorMessage';
import Label from './Label'; import Label from './Label';
@ -17,7 +16,7 @@ const Select = forwardRef<
<Label label={label} description={description} required={props.required} className={className}> <Label label={label} description={description} required={props.required} className={className}>
<select <select
className={ className={
'w-full cursor-pointer overflow-hidden rounded-lg border-2 border-highlight-primary bg-transparent px-5 py-2.5 text-sm font-medium text-secondary opacity-80 outline-none transition-opacity hover:opacity-100 disabled:opacity-50' 'w-full cursor-pointer overflow-hidden rounded-lg border-2 border-highlight-primary bg-transparent px-5 py-2.5 text-sm font-medium text-secondary opacity-80 outline-none outline-0 transition-opacity hover:opacity-100 focus:outline-none focus:ring-1 focus:ring-brand disabled:opacity-50'
} }
{...props} {...props}
ref={ref} ref={ref}

View file

@ -2,6 +2,7 @@
import { NavItem, navItems } from '@/lib/nav-items'; import { NavItem, navItems } from '@/lib/nav-items';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import Image from 'next/image';
import { useSelectedLayoutSegment } from 'next/navigation'; import { useSelectedLayoutSegment } from 'next/navigation';
import AppLink from '../AppLink'; import AppLink from '../AppLink';
import Icon from '../Icon'; import Icon from '../Icon';
@ -18,12 +19,12 @@ export default function Sidenav({ isOpen, toggle }: { isOpen: boolean; toggle: (
)} )}
> >
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="flex p-6"> <div className="flex w-full justify-center p-[9px]">
<AppLink className="truncate" href="/"> <AppLink href="/">
<h1>Peer-at Code</h1> <Image src="/assets/brand/peerat.png" alt="Peer-at" width={50} height={50} />
</AppLink> </AppLink>
</div> </div>
<div className=" px-4 "> <div className="px-4">
<hr className="border-highlight-primary" /> <hr className="border-highlight-primary" />
</div> </div>
<div className="px-4 pt-4"> <div className="px-4 pt-4">
@ -76,7 +77,7 @@ function NavItem({
className={cn('flex justify-center rounded-md px-3 py-3 text-sm lg:justify-start', { className={cn('flex justify-center rounded-md px-3 py-3 text-sm lg:justify-start', {
'text-muted hover:text-secondary': !isActive, 'text-muted hover:text-secondary': !isActive,
'bg-highlight-primary text-secondary': isActive, 'bg-highlight-primary text-secondary': isActive,
'text-gray-600 hover:text-gray-600': item.disabled, 'cursor-not-allowed text-gray-600 hover:text-gray-600': item.disabled,
'justify-center lg:justify-start': isOpen, 'justify-center lg:justify-start': isOpen,
'justify-start sm:justify-center': !isOpen 'justify-start sm:justify-center': !isOpen
})} })}

View file

@ -1,10 +1,10 @@
'use client'; 'use client';
import { useMe } from '@/lib/hooks/use-players'; import { UserContext } from '@/context/user';
import { titleCase } from '@/lib/utils'; import { titleCase } from '@/lib/utils';
import cookies from 'js-cookie'; import cookies from 'js-cookie';
import { useRouter, useSelectedLayoutSegment } from 'next/navigation'; import { useRouter, useSelectedLayoutSegment } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useContext, useEffect, useState } from 'react';
import AvatarComponent from '../Avatar'; import AvatarComponent from '../Avatar';
import Icon from '../Icon'; import Icon from '../Icon';
import Popover from '../Popover'; import Popover from '../Popover';
@ -14,9 +14,7 @@ export default function Usernav({ isOpen, toggle }: { isOpen: boolean; toggle: (
const router = useRouter(); const router = useRouter();
const segment = useSelectedLayoutSegment(); const segment = useSelectedLayoutSegment();
const token = cookies.get('token'); const { data: me, isLoading } = useContext(UserContext);
const { data: me, isLoading } = useMe({ token: token! });
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {