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_API_URL=API

View file

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

View file

@ -1,11 +1,16 @@
import { type ReactNode } from 'react';
import { UserProvider } from '@/context/user';
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 (
<div className="flex h-screen w-full flex-col">
<UserProvider token={token}>
<Wrapper>{children}</Wrapper>
</UserProvider>
</div>
);
}

View file

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

View file

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

View file

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

View file

@ -28,14 +28,26 @@ export const getPlayer = async ({
export type Player = {
email: string;
pseudo: string;
firstnames: string;
lastname: string;
description: string;
avatar: string;
group: string;
groups: Group[];
score: number;
tries: number;
completions: number;
pseudo: string;
badges: any[];
rank: number;
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) {
throw new Error('Failed to fetch puzzles');
@ -17,8 +17,6 @@ export const getChapters = async ({ token }: { token: string }): Promise<Chapter
return [];
}
chapters = chapters.filter((chapter: Chapter) => chapter.id !== 0);
return chapters as Chapter[];
};
@ -48,24 +46,17 @@ export const getChapter = async ({
return chapter as Chapter;
};
export const getPuzzles = async ({
token
}: {
token: string;
}): Promise<{ chapters: Chapter[]; puzzles: Puzzle[] }> => {
export const getPuzzles = async ({ token }: { token: string }): Promise<Chapter[]> => {
const chapters = await getChapters({ token });
const puzzles: Puzzle[] = [];
for (const chapter of chapters) {
const puzzlesByChapter = await getChapter({ token, id: chapter.id });
if (!puzzlesByChapter?.puzzles) continue;
puzzles.push(...puzzlesByChapter!.puzzles);
for (let i = 0; i < chapters.length; i++) {
const chapter = chapters[i];
const chapterData = await getChapter({ token, id: chapter.id });
if (!chapterData) continue;
chapters[i].puzzles = chapterData.puzzles;
}
return {
chapters: chapters as Chapter[],
puzzles: puzzles as Puzzle[]
};
return chapters as Chapter[];
};
export const getPuzzle = async ({ token, id }: { token: string; id: number }): Promise<Puzzle> => {
@ -92,10 +83,17 @@ export type Puzzle = {
id: number;
name: string;
content: string;
tags: Tag[] | null;
};
export type Chapter = {
name: string;
id: number;
name: string;
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) {
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;
if (req.nextUrl.pathname.includes('dashboard') && !token)

View file

@ -1,6 +1,5 @@
{
"name": "peer-at-code",
"version": "0.1.0",
"private": true,
"scripts": {
"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');
/** @type {import('tailwindcss').Config} */

View file

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

View file

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

View file

@ -6,22 +6,23 @@ import { useMemo, useState } from 'react';
import AvatarComponent from './Avatar';
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 }) {
const { data, isLoading } = useLeaderboard({ token });
const [filter, setFilter] = useState('');
let options;
let options = [] as { value: string; title: string }[];
if (data) {
options = data
.filter((score, index, self) => {
return index === self.findIndex((t) => t.group === score.group) && score.group !== '';
})
.sort((a, b) => (a.group > b.group ? 1 : -1))
.map((score) => ({ value: score.group, title: score.group }));
.filter((score) => score.groups && score.groups.length > 0)
.map((score) => score.groups.map((group) => ({ value: group.name, title: group.name })))
.flat()
.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' });
options.push({ value: 'no-group', title: 'Sans groupe' });
}
@ -29,9 +30,9 @@ export default function Leaderboard({ token }: { token: string }) {
const filteredData = useMemo(() => {
if (filter) {
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;
}, [data, filter]);
@ -65,12 +66,16 @@ export default function Leaderboard({ token }: { token: string }) {
filteredData?.map((score, key) => (
<li key={key} className="flex justify-between space-x-2">
<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">
<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">
<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>

View file

@ -1,30 +1,82 @@
'use client';
import { usePuzzles } from '@/lib/hooks/use-puzzles';
import { type Chapter } from '@/lib/puzzles';
import { cn } from '@/lib/utils';
import AppLink from './AppLink';
import Icon from './Icon';
export default function Puzzles({ token }: { token: string }) {
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 (
<>
{(!isLoading &&
data?.chapters?.map((chapter) => (
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}
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>
</div>
<ul className="flex flex-col space-y-4">
{data?.puzzles.map((puzzle) => (
<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="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>
{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"

View file

@ -1,5 +1,4 @@
import { forwardRef } from 'react';
import type { UseFormRegister } from 'react-hook-form';
import ErrorMessage from './ErrorMessage';
import Label from './Label';
@ -17,7 +16,7 @@ const Select = forwardRef<
<Label label={label} description={description} required={props.required} className={className}>
<select
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}
ref={ref}

View file

@ -2,6 +2,7 @@
import { NavItem, navItems } from '@/lib/nav-items';
import { cn } from '@/lib/utils';
import Image from 'next/image';
import { useSelectedLayoutSegment } from 'next/navigation';
import AppLink from '../AppLink';
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 p-6">
<AppLink className="truncate" href="/">
<h1>Peer-at Code</h1>
<div className="flex w-full justify-center p-[9px]">
<AppLink href="/">
<Image src="/assets/brand/peerat.png" alt="Peer-at" width={50} height={50} />
</AppLink>
</div>
<div className=" px-4 ">
<div className="px-4">
<hr className="border-highlight-primary" />
</div>
<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', {
'text-muted hover: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-start sm:justify-center': !isOpen
})}

View file

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