Added ui & auth data

This commit is contained in:
Théo 2023-03-18 19:55:28 +01:00
parent bd8418b86a
commit bb172cae65
17 changed files with 294 additions and 104 deletions

View file

@ -1,4 +1,5 @@
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 - Peer-at Code',
@ -6,5 +7,6 @@ export const metadata = {
}; };
export default async function Page() { export default async function Page() {
return <Leaderboard />; const token = cookies().get('token')?.value;
return <Leaderboard token={token!} />;
} }

View file

@ -1,10 +1,12 @@
import Card from '@/ui/Card'; 'use client';
export const metadata = { import { useMe } from '@/lib/hooks/use-players';
title: 'Dashboard - Peer-at Code' import Card from '@/ui/Card';
}; import cookies from 'js-cookie';
export default function Page() { export default function Page() {
const token = cookies.get('token');
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">
@ -14,9 +16,24 @@ export default function Page() {
<p className="text-muted">Ceci est la page d&apos;accueil du dashboard</p> <p className="text-muted">Ceci est la page d&apos;accueil du dashboard</p>
</header> </header>
<main className="flex-col justify-between space-x-0 space-y-4 md:flex md:flex-row md:space-x-6 md:space-y-0"> <main className="flex-col justify-between space-x-0 space-y-4 md:flex md:flex-row md:space-x-6 md:space-y-0">
<Card icon="pie-chart-line" title="46" data="Puzzles" /> <Card
<Card icon="award-line" title="3" data="Badges" /> isLoading={isLoading}
<Card icon="bar-chart-line" title="10 ème" data="Classement" /> icon="pie-chart-line"
title="Puzzles"
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}
/>
</main> </main>
</section> </section>
</div> </div>

View file

@ -1,3 +1,4 @@
import { cookies } from 'next/headers';
import { getPuzzle } from '@/lib/puzzles'; import { getPuzzle } from '@/lib/puzzles';
import Puzzle from '@/ui/Puzzle'; import Puzzle from '@/ui/Puzzle';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
@ -5,16 +6,26 @@ import { notFound } from 'next/navigation';
export async function generateMetadata({ params }: { params: { id: number } }): Promise<Metadata> { export async function generateMetadata({ params }: { params: { id: number } }): Promise<Metadata> {
const { id } = params; const { id } = params;
const token = cookies().get('token')?.value;
const puzzle = await getPuzzle(id); if (!token) {
notFound();
}
const puzzle = await getPuzzle({ token, id });
return { title: `${puzzle.name} - Peer-at Code` }; return { title: `${puzzle.name} - Peer-at Code` };
} }
export default async function Page({ params }: { params: { id: number } }) { export default async function Page({ params }: { params: { id: number } }) {
const { id } = params; const { id } = params;
const token = cookies().get('token')?.value;
const puzzle = await getPuzzle(id); if (!token) {
notFound();
}
const puzzle = await getPuzzle({ token, id });
if (!puzzle) { if (!puzzle) {
notFound(); notFound();

View file

@ -1,3 +1,5 @@
import { cookies } from 'next/headers';
import Puzzles from '@/ui/Puzzles'; import Puzzles from '@/ui/Puzzles';
export const metadata = { export const metadata = {
@ -5,9 +7,12 @@ export const metadata = {
}; };
export default async function Page() { export default async function Page() {
const cookieStore = cookies();
const token = cookieStore.get('token')?.value;
return ( return (
<div className="flex flex-col space-y-6"> <div className="flex flex-col space-y-6">
<Puzzles /> <Puzzles token={token!} />
</div> </div>
); );
} }

View file

@ -3,7 +3,6 @@ import Console from '@/ui/Console';
import Image from 'next/image'; import Image from 'next/image';
export default function Page() { export default function Page() {
// TODO: Fix this (image)
return ( return (
<div> <div>
<div className="flex h-screen w-full"> <div className="flex h-screen w-full">

View file

@ -6,4 +6,16 @@
.console { .console {
@apply relative top-0.5 inline-block; @apply relative top-0.5 inline-block;
} }
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
textarea:-webkit-autofill,
textarea:-webkit-autofill:hover,
textarea:-webkit-autofill:focus,
select:-webkit-autofill,
select:-webkit-autofill:hover,
select:-webkit-autofill:focus {
-webkit-box-shadow: 0 0 0px 1000px hsl(258deg 15% 17%) inset;
transition: background-color 5000s ease-in-out 0s;
}
} }

View file

@ -1,5 +1,46 @@
import BoringAvatar from 'boring-avatars'; import BoringAvatar from 'boring-avatars';
import Image from 'next/image';
export default function Avatar({ name, size = 28 }: { name: string; size?: number }) { import { cn } from '@/lib/utils';
export function Avatar({ name, size = 36 }: { name: string; size?: number }) {
return <BoringAvatar name={name} variant="beam" size={size} />; return <BoringAvatar name={name} variant="beam" size={size} />;
} }
export function Base64Avatar({
name,
src,
className
}: {
name: string;
src: string;
className?: string;
}) {
return (
<Image
src={`data:image;base64,${src}`}
className={cn('rounded-full object-cover', className)}
width="0"
height="0"
alt={name}
/>
);
}
export default function AvatarComponent({
name,
src,
size = 36,
className
}: {
name: string;
src: string;
size?: number;
className?: string;
}) {
return src ? (
<Base64Avatar name={name} src={src} className={className} />
) : (
<Avatar name={name} size={size} />
);
}

View file

@ -4,6 +4,12 @@ import { cn } from '@/lib/utils';
export type Difficulty = 'easy' | 'medium' | 'hard'; export type Difficulty = 'easy' | 'medium' | 'hard';
export const DIFFICULTY = {
1: 'easy',
2: 'medium',
3: 'hard'
}
export default function Badge({ export default function Badge({
title, title,
path, path,

View file

@ -10,11 +10,12 @@ const Button = forwardRef<
<button <button
ref={ref} ref={ref}
className={cn( className={cn(
'inline-flex items-center justify-center rounded-md border-0 px-5 py-2.5 text-center text-sm font-medium outline-none transition-colors focus:outline-none focus:ring-0 disabled:opacity-50', 'inline-flex items-center justify-center rounded-md border-0 px-5 py-2.5 text-center text-sm font-medium outline-none transition-colors focus:outline-none focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50',
{ {
'bg-highlight-primary hover:bg-highlight-primary/60': kind === 'default', 'bg-highlight-primary hover:bg-highlight-primary/60': kind === 'default',
'bg-error hover:bg-error/60': kind === 'danger', 'bg-error hover:bg-error/60': kind === 'danger',
'bg-gradient-to-tl from-brand to-brand-accent hover:bg-opacity-80': kind === 'brand' 'bg-gradient-to-tl from-brand to-brand-accent transition-opacity hover:opacity-90':
kind === 'brand'
}, },
className className
)} )}

View file

@ -1,12 +1,33 @@
import Icon from './Icon'; import Icon from './Icon';
export default function Card({ icon, title, data }: { icon: string; title: string; data: string }) { export default function Card({
isLoading,
icon,
title,
data
}: {
isLoading: boolean;
icon: string;
title: string;
data: any;
}) {
if (isLoading)
return (
<div className="flex w-full items-center space-x-4 rounded-lg border-2 border-highlight-primary bg-primary-700 p-4 shadow-md">
<Icon name={icon} className="text-2xl text-muted" />
<div className="flex flex-col space-y-4">
<span className="h-4 w-32 animate-pulse rounded bg-highlight-primary" />
<span className="h-4 w-24 animate-pulse rounded bg-highlight-primary" />
</div>
</div>
);
return ( return (
<div className="flex w-full items-center space-x-4 rounded-lg border-2 border-highlight-primary bg-primary-700 p-4 shadow-md"> <div className="flex w-full items-center space-x-4 rounded-lg border-2 border-highlight-primary bg-primary-700 p-4 shadow-md">
<Icon name={icon} className="text-2xl text-muted" /> <Icon name={icon} className="text-2xl text-muted" />
<div className="flex flex-col"> <div className="flex flex-col">
<h3 className="text-xl font-semibold">{title}</h3> <h3 className="text-xl font-semibold">{data}</h3>
<p className="text-muted">{data}</p> <p className="text-muted">{title}</p>
</div> </div>
</div> </div>
); );

View file

@ -1,5 +1,6 @@
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<
@ -14,7 +15,8 @@ const Input = forwardRef<
<Label label={label} description={description} required={props.required} className={className}> <Label label={label} description={description} required={props.required} className={className}>
<input <input
ref={ref} ref={ref}
className="w-full rounded-md border border-primary-600 bg-highlight-primary px-5 py-2.5 text-sm font-medium focus:border-brand focus:bg-primary-800 focus:outline-none disabled:opacity-50" className="w-full rounded-md border-primary-600 bg-highlight-primary px-5 py-2.5 text-sm font-medium outline-0 focus:border-brand focus:bg-primary-800 focus:outline-none focus:ring-1 focus:ring-brand disabled:opacity-50"
autoComplete="off"
{...props} {...props}
/> />
</Label> </Label>

View file

@ -2,78 +2,102 @@
import { useLeaderboard } from '@/lib/hooks/use-leaderboard'; import { useLeaderboard } from '@/lib/hooks/use-leaderboard';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import Avatar from './Avatar'; import { useMemo, useState } from 'react';
import AvatarComponent from './Avatar';
import Select from './Select'; import Select from './Select';
// TODO: Generate this later
const scoreColors = ['text-yellow-400', 'text-gray-400', 'text-orange-400']; const scoreColors = ['text-yellow-400', 'text-gray-400', 'text-orange-400'];
// TODO: Generate this later export default function Leaderboard({ token }: { token: string }) {
export const options = [ const { data, isLoading } = useLeaderboard({ token });
{ value: '1i1', title: '1I1' },
{ value: '1i2', title: '1I2' }, const [filter, setFilter] = useState('');
{ value: '1i3', title: '1I3' },
{ value: '1i4', title: '1I4' }, let options;
{ value: '1i5', title: '1I5' },
{ value: '1i6', title: '1I6' }, if (data) {
{ value: '1i7', title: '1I7' }, options = data
{ value: '1i8', title: '1I8' } .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 }));
options.unshift({ value: '', title: 'Tous' });
options.push({ value: 'no-group', title: 'Sans groupe' });
}
const filteredData = useMemo(() => {
if (filter) {
if (filter === 'no-group') {
return data?.filter((score) => score.group === '');
}
return data?.filter((score) => score.group === filter);
}
return data;
}, [data, filter]);
export default function Leaderboard() {
const { data, isLoading } = useLeaderboard();
return ( return (
<div className="flex h-full w-full flex-col space-y-4"> <section className="flex h-full w-full flex-col space-y-4">
<div className="w-full"> <header className="sticky flex items-center justify-between">
<section className="flex flex-col space-y-4"> <div>
<header className="flex items-center justify-between"> <h3 className="text-xl font-semibold">Tableau des scores</h3>
<div> <p className="hidden text-muted sm:block">Suivez la progression des élèves en direct</p>
<h3 className="text-xl font-semibold">Tableau des scores</h3> </div>
<p className="hidden text-muted sm:block"> {(filteredData && (
Suivez la progression des élèves en direct <Select
</p> className="w-32"
</div> options={options || []}
<Select className="w-28" options={options} /> value={filter}
</header> onChange={(event) => setFilter(event.target.value)}
<main className="flex flex-col justify-between space-x-0 space-y-4 pb-4"> />
{(!isLoading && )) || (
data?.map((score, key) => ( <span
<div key={key} className="flex flex-col space-y-2"> className="inline-block h-12 w-32 animate-pulse rounded-lg bg-primary-600"
<div className="flex justify-between space-x-2"> style={{
<div className="flex items-center space-x-4"> animationDuration: '1s'
<span className={cn('font-semibold', scoreColors[key])}>{key + 1}</span> }}
<div className="flex items-center space-x-2"> />
<Avatar name={score.pseudo} /> )}
<span className="text-lg">{score.pseudo}</span> </header>
<span className="text-sm text-muted">{score.group}</span> <main className="flex flex-col justify-between space-x-0 space-y-4 pb-4">
</div> <ul className="flex flex-col space-y-2">
</div> {(!isLoading &&
<div className="flex items-center space-x-4"> filteredData?.map((score, key) => (
<div className="flex flex-col"> <li key={key} className="flex justify-between space-x-2">
<span className="text-sm font-semibold">Puzzles</span> <div className="flex items-center space-x-4">
<span className="text-lg text-muted">{score.completions}</span> <span className={cn('font-semibold', scoreColors[key])}>{key + 1}</span>
</div> <div className="flex items-center space-x-2">
<div className="flex flex-col"> <AvatarComponent name={score.pseudo} src={score.avatar} className="h-9 w-9" />
<span className="text-sm font-semibold">Score</span> <div className="flex flex-col gap-x-2 sm:flex-row sm:items-center">
<span className="text-lg text-muted">{score.score}</span> <span className="text-lg">{score.pseudo}</span>
</div> <span className="text-sm text-muted">{score.group}</span>
</div> </div>
</div> </div>
</div> </div>
))) || <div className="flex items-center space-x-4">
[...Array(20).keys()].map((i) => ( <div className="flex flex-col">
<span <span className="text-sm font-semibold">Puzzles</span>
key={i} <span className="text-lg text-muted">{score.completions}</span>
className="inline-block h-12 animate-pulse rounded-lg bg-primary-600" </div>
style={{ <div className="flex flex-col">
animationDelay: `${i * 0.05}s`, <span className="text-sm font-semibold">Score</span>
animationDuration: '1s' <span className="text-lg text-muted">{score.score}</span>
}} </div>
/> </div>
))} </li>
</main> ))) ||
</section> [...Array(20).keys()].map((i) => (
</div> <span
</div> key={i}
className="inline-block h-12 animate-pulse rounded-lg bg-primary-600"
style={{
animationDelay: `${i * 0.05}s`,
animationDuration: '1s'
}}
/>
))}
</ul>
</main>
</section>
); );
} }

View file

@ -73,12 +73,14 @@ export default function Puzzle({ puzzle }: { puzzle: PuzzleType }) {
label="Réponse" label="Réponse"
type="text" type="text"
placeholder="12" placeholder="12"
required
{...register('answer')} {...register('answer')}
/> />
<Input <Input
className="h-16 w-full sm:w-1/3" className="h-16 w-full sm:w-1/3"
label="Code" label="Code"
type="file" type="file"
required
accept=".py,.js,.ts,.java,.rust,.c" accept=".py,.js,.ts,.java,.rust,.c"
{...register('code_file')} {...register('code_file')}
/> />

View file

@ -4,8 +4,9 @@ import { usePuzzles } from '@/lib/hooks/use-puzzles';
import AppLink from './AppLink'; import AppLink from './AppLink';
import Icon from './Icon'; import Icon from './Icon';
export default function Puzzles() { export default function Puzzles({ token }: { token: string }) {
const { data, isLoading } = usePuzzles(); const { data, isLoading } = usePuzzles({ token });
console.log(data);
return ( return (
<> <>
{(!isLoading && {(!isLoading &&
@ -48,7 +49,7 @@ export default function Puzzles() {
/> />
</div> </div>
<ul className="flex flex-col space-y-4"> <ul className="flex flex-col space-y-4">
{[...Array(7).keys()].map((j) => ( {[...Array(6).keys()].map((j) => (
<span <span
key={j} key={j}
className="inline-block h-14 animate-pulse rounded-lg bg-primary-600" className="inline-block h-14 animate-pulse rounded-lg bg-primary-600"

View file

@ -10,7 +10,8 @@ const Select = forwardRef<
error?: React.ReactNode; error?: React.ReactNode;
description?: string; description?: string;
options: { value: string; title: string }[]; options: { value: string; title: string }[];
} & Partial<ReturnType<UseFormRegister<any>>> }
//& Partial<ReturnType<UseFormRegister<any>>></HTMLSelectElement>
>(({ options, className, label, description, error, ...props }, ref) => ( >(({ options, className, label, description, error, ...props }, ref) => (
<> <>
<Label label={label} description={description} required={props.required} className={className}> <Label label={label} description={description} required={props.required} className={className}>

View file

@ -2,7 +2,7 @@
import cookies from 'js-cookie'; import cookies from 'js-cookie';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import { useEffect } from 'react'; import { useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import AppLink from './AppLink'; import AppLink from './AppLink';
import Button from './Button'; import Button from './Button';
@ -37,13 +37,14 @@ export default function UserAuthForm() {
avatar: '' avatar: ''
} }
}); });
const [isLoading, setIsLoading] = useState(false);
const router = useRouter(); const router = useRouter();
const pathname = usePathname()!; const pathname = usePathname()!;
const isSignIn = pathname.includes('sign-in'); const isSignIn = pathname.includes('sign-in');
const token = cookies.get('token');
async function onSubmit(data: FormData) { async function onSubmit(data: FormData) {
setIsLoading(true);
const res = await fetch( const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/${isSignIn ? 'login' : 'register'}`, `${process.env.NEXT_PUBLIC_API_URL}/${isSignIn ? 'login' : 'register'}`,
{ {
@ -59,18 +60,21 @@ export default function UserAuthForm() {
type: 'manual', type: 'manual',
message: "Nom d'utilisateur indisponible" message: "Nom d'utilisateur indisponible"
}); });
setIsLoading(false);
} }
if (!email_valid) { if (!email_valid) {
setError('email', { setError('email', {
type: 'manual', type: 'manual',
message: 'Email déjà utilisé' message: 'Email déjà utilisé'
}); });
setIsLoading(false);
} }
} }
if (res.ok) { if (res.ok) {
const token = res.headers.get('Authorization')?.split(' ')[1]; const token = res.headers.get('Authorization')?.split(' ')[1];
if (token) cookies.set('token', token); if (token) cookies.set('token', token);
router.refresh();
} else { } else {
setError('passwd', { setError('passwd', {
type: 'manual', type: 'manual',
@ -79,10 +83,6 @@ export default function UserAuthForm() {
} }
} }
useEffect(() => {
if (token) router.push('/dashboard');
}, [token]);
return ( return (
<form <form
className="flex w-52 flex-col justify-center space-y-4 sm:w-72" className="flex w-52 flex-col justify-center space-y-4 sm:w-72"
@ -100,14 +100,14 @@ export default function UserAuthForm() {
/> />
<Input <Input
label="Nom" label="Nom"
type="lastname" type="text"
placeholder="Doe" placeholder="Doe"
error={errors.lastname?.message} error={errors.lastname?.message}
{...register('lastname')} {...register('lastname')}
/> />
<Input <Input
label="Prénom" label="Prénom"
type="firstname" type="text"
placeholder="John" placeholder="John"
error={errors.firstname?.message} error={errors.firstname?.message}
{...register('firstname')} {...register('firstname')}
@ -130,7 +130,7 @@ export default function UserAuthForm() {
error={errors.passwd?.message} error={errors.passwd?.message}
{...register('passwd')} {...register('passwd')}
/> />
<Button type="submit" kind="brand"> <Button type="submit" kind="brand" disabled={isLoading}>
{isSignIn ? 'Se connecter' : "S'inscrire"} {isSignIn ? 'Se connecter' : "S'inscrire"}
</Button> </Button>
<div className="flex flex-col text-center"> <div className="flex flex-col text-center">

View file

@ -1,11 +1,34 @@
'use client'; 'use client';
import { useMe } from '@/lib/hooks/use-players';
import { titleCase } from '@/lib/utils'; import { titleCase } from '@/lib/utils';
import { useSelectedLayoutSegment } from 'next/navigation'; import cookies from 'js-cookie';
import { useRouter, useSelectedLayoutSegment } from 'next/navigation';
import { useEffect, useState } from 'react';
import AvatarComponent from '../Avatar';
import Icon from '../Icon'; import Icon from '../Icon';
import Popover from '../Popover';
export default function Usernav({ isOpen, toggle }: { isOpen: boolean; toggle: () => void }) { export default function Usernav({ isOpen, toggle }: { isOpen: boolean; toggle: () => void }) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const router = useRouter();
const segment = useSelectedLayoutSegment(); const segment = useSelectedLayoutSegment();
const token = cookies.get('token');
const { data: me, isLoading } = useMe({ token: token! });
useEffect(() => {
if (isOpen) {
setIsMenuOpen(false);
}
}, [isOpen]);
async function handleLogout() {
cookies.remove('token');
router.refresh();
}
return ( 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 py-4 px-8">
<div className="flex flex-row items-center space-x-2 sm:space-x-0"> <div className="flex flex-row items-center space-x-2 sm:space-x-0">
@ -21,12 +44,34 @@ export default function Usernav({ isOpen, toggle }: { isOpen: boolean; toggle: (
)} )}
</div> </div>
<div className="flex flex-row items-center space-x-4"> <div className="flex flex-row items-center space-x-4">
<button className="flex items-center text-2xl text-error"> {!isLoading && me ? (
<Icon name="flag-line" /> <Popover
</button> open={isMenuOpen}
<button className="flex items-center justify-center rounded-full border border-primary-400 bg-tertiary px-4 py-2"> onOpenChange={setIsMenuOpen}
T trigger={
</button> <button className="mx-auto flex items-center gap-2">
<AvatarComponent name={me.pseudo} src={me.avatar} className="h-9 w-9" />
<span>{me?.pseudo}</span>
</button>
}
>
<nav className="flex w-32 flex-col gap-2">
<button
className="flex items-center gap-1 p-2 text-error hover:bg-error/10"
onClick={() => handleLogout()}
>
Se déconnecter
</button>
</nav>
</Popover>
) : (
<div className="animate-pulse">
<div className="flex items-center gap-2">
<div className="h-9 w-9 rounded-full bg-highlight-primary" />
<div className="h-4 w-14 rounded-full bg-highlight-primary" />
</div>
</div>
)}
</div> </div>
</div> </div>
); );