Compare commits
4 commits
066e7905a3
...
ad66016115
Author | SHA1 | Date | |
---|---|---|---|
|
ad66016115 | ||
|
e22c7dafa1 | ||
|
237a0e3af4 | ||
|
f06a762325 |
56 changed files with 2818 additions and 1706 deletions
|
@ -1,4 +1,4 @@
|
||||||
import UserAuthForm from '@/ui/UserAuthForm';
|
import UserAuthForm from '@/components/ui/UserAuthForm';
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import UserAuthForm from '@/ui/UserAuthForm';
|
import UserAuthForm from '@/components/ui/UserAuthForm';
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
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} />;
|
|
||||||
}
|
|
|
@ -4,7 +4,7 @@ import { useContext } from 'react';
|
||||||
|
|
||||||
import { UserContext } from '@/context/user';
|
import { UserContext } from '@/context/user';
|
||||||
|
|
||||||
import Badge from '@/ui/Badge';
|
import Badge from '@/components/ui/Badge';
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const { data: me } = useContext(UserContext);
|
const { data: me } = useContext(UserContext);
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
|
|
||||||
import { UserProvider } from '@/context/user';
|
import { UserProvider } from '@/context/user';
|
||||||
import Wrapper from '@/ui/dashboard/Wrapper';
|
import Wrapper from '@/components/ui/dashboard/Wrapper';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
export default async function Layout({ children }: { children: ReactNode }) {
|
export default async function Layout({ children }: { children: ReactNode }) {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import Leaderboard from '@/ui/Leaderboard';
|
import Leaderboard from '@/components/ui/Leaderboard';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
|
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
|
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
import { UserContext } from '@/context/user';
|
import { UserContext } from '@/context/user';
|
||||||
import Card from '@/ui/Card';
|
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const { data: me, isLoading } = useContext(UserContext);
|
const { data: me, isLoading } = useContext(UserContext);
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { getPuzzle } from '@/lib/puzzles';
|
import { getPuzzle } from '@/lib/puzzles';
|
||||||
import Puzzle from '@/ui/Puzzle';
|
import Puzzle from '@/components/ui/Puzzle';
|
||||||
import SWRFallback from '@/ui/SWRFallback';
|
import SWRFallback from '@/components/ui/SWRFallback';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
import Puzzles from '@/ui/Puzzles';
|
import Puzzles from '@/components/ui/Puzzles';
|
||||||
import SWRFallback from '@/ui/SWRFallback';
|
import SWRFallback from '@/components/ui/SWRFallback';
|
||||||
import { getPuzzles } from '@/lib/puzzles';
|
import { getPuzzles } from '@/lib/puzzles';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
|
|
@ -1,14 +1,26 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import cookies from 'js-cookie';
|
import cookies from 'js-cookie';
|
||||||
import { useContext } from 'react';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useContext, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useSWRConfig } from 'swr';
|
import { useSWRConfig } from 'swr';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from '@/components/ui/Form';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
import { UserContext } from '@/context/user';
|
import { UserContext } from '@/context/user';
|
||||||
import Button from '@/ui/Button';
|
import { type Player } from '@/lib/players';
|
||||||
import Select from '@/ui/Select';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useRouter } from 'next/navigation';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
type SettingsData = {
|
type SettingsData = {
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -60,17 +72,133 @@ export default function Page() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="flex h-full w-full flex-col space-y-4">
|
<section className="flex w-full flex-col space-y-4">
|
||||||
{me && me?.groups?.length > 0 ? (
|
<header>
|
||||||
<form className="flex w-1/4 flex-col space-y-2" onSubmit={handleSubmit(onSubmit)}>
|
<h1 className="text-xl font-semibold">Mon profil</h1>
|
||||||
<Select label="Quitter un groupe" {...register('name')} options={groups} />
|
<p className="text-highlight-secondary">
|
||||||
<Button kind="brand" type="submit">
|
C'est ici que vous pouvez modifier votre profil.
|
||||||
Quitter
|
</p>
|
||||||
</Button>
|
</header>
|
||||||
</form>
|
<main className="flex-col space-y-4">
|
||||||
) : (
|
{me && <ProfileForm user={me!} />}
|
||||||
<p>Vous n' êtes dans aucun groupe</p>
|
{/* {me && me?.groups?.length > 0 ? (
|
||||||
)}
|
<form className="flex w-1/4 flex-col space-y-2" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Select label="Quitter un groupe" {...register('name')} options={groups} />
|
||||||
|
<Button type="submit">Quitter</Button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<p>Vous n' êtes dans aucun groupe</p>
|
||||||
|
)} */}
|
||||||
|
</main>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ProfileFormSchema = z.object({
|
||||||
|
email: z.string().email({ message: "L'email doit être valide" }),
|
||||||
|
pseudo: z.string(),
|
||||||
|
firstname: z.string(),
|
||||||
|
lastname: z.string(),
|
||||||
|
description: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
function ProfileForm({ user }: { user: Player }) {
|
||||||
|
const form = useForm<z.infer<typeof ProfileFormSchema>>({
|
||||||
|
resolver: zodResolver(ProfileFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
email: user.email,
|
||||||
|
pseudo: user.pseudo,
|
||||||
|
firstname: user.firstname,
|
||||||
|
lastname: user.lastname,
|
||||||
|
description: user.description
|
||||||
|
},
|
||||||
|
mode: 'onChange'
|
||||||
|
});
|
||||||
|
|
||||||
|
const [submit, setSubmit] = useState(false);
|
||||||
|
|
||||||
|
// TODO: Route pas encore faite
|
||||||
|
async function onSubmit(data: z.infer<typeof ProfileFormSchema>) {
|
||||||
|
console.log(data);
|
||||||
|
setSubmit(!submit);
|
||||||
|
setTimeout(() => {
|
||||||
|
setSubmit(false);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel htmlFor="email">Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="pseudo"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel htmlFor="pseudo">Pseudo</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="firstname"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel htmlFor="firstname">Prénom</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="lastname"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel htmlFor="lastname">Nom</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel htmlFor="description">Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button disabled={submit} variant="brand">
|
||||||
|
{submit && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Modifier
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import '@/styles/globals.css';
|
import '@/styles/global.css';
|
||||||
import 'remixicon/fonts/remixicon.css';
|
|
||||||
|
|
||||||
import { type Metadata } from 'next';
|
import { type Metadata } from 'next';
|
||||||
import { Fira_Code } from 'next/font/google';
|
import { Fira_Code } from 'next/font/google';
|
||||||
|
@ -8,10 +7,13 @@ import { type ReactNode } from 'react';
|
||||||
|
|
||||||
import { cn, getURL } from '@/lib/utils';
|
import { cn, getURL } from '@/lib/utils';
|
||||||
|
|
||||||
|
import { ThemeProvider } from '@/components/ThemeProvider';
|
||||||
|
|
||||||
const sans = localFont({
|
const sans = localFont({
|
||||||
variable: '--font-sans',
|
variable: '--font-sans',
|
||||||
src: './fonts/Karrik.woff2',
|
src: './fonts/Karrik.woff2',
|
||||||
weight: 'variable'
|
weight: 'variable',
|
||||||
|
display: 'swap'
|
||||||
});
|
});
|
||||||
|
|
||||||
const code = Fira_Code({
|
const code = Fira_Code({
|
||||||
|
@ -28,26 +30,6 @@ export const metadata: Metadata = {
|
||||||
},
|
},
|
||||||
description: "Apprendre la programmation et la cybersécurité en s'amusant.",
|
description: "Apprendre la programmation et la cybersécurité en s'amusant.",
|
||||||
// manifest: getURL('/favicon/site.webmanifest'),
|
// manifest: getURL('/favicon/site.webmanifest'),
|
||||||
openGraph: {
|
|
||||||
title: {
|
|
||||||
default: 'Peer-at Code',
|
|
||||||
template: `%s - Peer-at Code`
|
|
||||||
},
|
|
||||||
description: "Apprendre la programmation et la cybersécurité en s'amusant.",
|
|
||||||
url: getURL(),
|
|
||||||
siteName: 'Peer-at Code',
|
|
||||||
// images: getURL('/assets/social.jpg'),
|
|
||||||
type: 'website'
|
|
||||||
},
|
|
||||||
twitter: {
|
|
||||||
card: 'summary_large_image',
|
|
||||||
title: {
|
|
||||||
default: 'Peer-at Code',
|
|
||||||
template: `%s - Peer-at Code`
|
|
||||||
},
|
|
||||||
description: "Apprendre la programmation et la cybersécurité en s'amusant."
|
|
||||||
// images: getURL('/assets/social.jpg'),
|
|
||||||
},
|
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: getURL()
|
canonical: getURL()
|
||||||
},
|
},
|
||||||
|
@ -74,14 +56,16 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
lang="fr"
|
lang="fr"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
className={cn(
|
className={cn(
|
||||||
'scroll-smooth bg-gradient-to-b from-primary-800 to-primary-900 [color-scheme:dark]',
|
'scroll-smooth bg-gradient-to-b from-primary-800 to-primary-900',
|
||||||
sans.variable,
|
sans.variable,
|
||||||
code.variable
|
code.variable
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<head />
|
<head />
|
||||||
<body className="relative min-h-screen">
|
<body className="relative min-h-screen">
|
||||||
<main>{children}</main>
|
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
|
||||||
|
<main>{children}</main>
|
||||||
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import AppLink from '@/ui/AppLink';
|
import AppLink from '@/components/ui/AppLink';
|
||||||
import Console from '@/ui/Console';
|
import Console from '@/components/ui/Console';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
|
|
15
components.json
Normal file
15
components.json
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "styles/global.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils"
|
||||||
|
}
|
||||||
|
}
|
10
components/ThemeProvider.tsx
Normal file
10
components/ThemeProvider.tsx
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||||
|
import type { ThemeProviderProps } from 'next-themes/dist/types';
|
||||||
|
|
||||||
|
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
|
}
|
50
components/ui/Button.tsx
Normal file
50
components/ui/Button.tsx
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
|
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
brand: 'bg-gradient-to-tl from-brand to-brand-accent transition-opacity hover:opacity-90'
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-10 px-4 py-2',
|
||||||
|
sm: 'h-9 rounded-md px-3',
|
||||||
|
lg: 'h-11 rounded-md px-8',
|
||||||
|
icon: 'h-10 w-10'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
return (
|
||||||
|
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
|
@ -1,7 +1,7 @@
|
||||||
import { getURL } from '@/lib/utils';
|
import { getURL } from '@/lib/utils';
|
||||||
|
|
||||||
import AppLink from './AppLink';
|
import AppLink from '@/components/ui/AppLink';
|
||||||
import Icon from './Icon';
|
import { Icon } from '@/components/ui/Icon';
|
||||||
|
|
||||||
export default function Card({
|
export default function Card({
|
||||||
isLoading,
|
isLoading,
|
||||||
|
@ -19,7 +19,7 @@ export default function Card({
|
||||||
if (isLoading)
|
if (isLoading)
|
||||||
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="loader-5" className="h-4 w-4" />
|
||||||
<div className="flex flex-col space-y-4">
|
<div className="flex flex-col space-y-4">
|
||||||
<span className="h-[18px] w-32 animate-pulse rounded bg-highlight-primary" />
|
<span className="h-[18px] w-32 animate-pulse rounded bg-highlight-primary" />
|
||||||
<span className="h-[18px] w-24 animate-pulse rounded bg-highlight-primary" />
|
<span className="h-[18px] w-24 animate-pulse rounded bg-highlight-primary" />
|
|
@ -1,7 +1,7 @@
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import Icon from './Icon';
|
import { Icon, Icons } from '@/components/ui/Icon';
|
||||||
// import Tooltip from './Tooltip';
|
// import Tooltip from './Tooltip';
|
||||||
|
|
||||||
type DialogProps = {
|
type DialogProps = {
|
||||||
|
@ -42,7 +42,7 @@ export default function Dialog({
|
||||||
<div className="flex w-full justify-between pb-4">
|
<div className="flex w-full justify-between pb-4">
|
||||||
{title}
|
{title}
|
||||||
<DialogPrimitive.Trigger>
|
<DialogPrimitive.Trigger>
|
||||||
<Icon name="close-fill" className="hover:text-highlight-secondary" />
|
<Icon name='close-line' className="hover:text-highlight-secondary" />
|
||||||
</DialogPrimitive.Trigger>
|
</DialogPrimitive.Trigger>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
161
components/ui/Form.tsx
Normal file
161
components/ui/Form.tsx
Normal file
|
@ -0,0 +1,161 @@
|
||||||
|
import type * as LabelPrimitive from '@radix-ui/react-label';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import * as React from 'react';
|
||||||
|
import type { ControllerProps, FieldPath, FieldValues } from 'react-hook-form';
|
||||||
|
import { Controller, FormProvider, useFormContext } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { Label } from '@/components/ui/Label';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Form = FormProvider;
|
||||||
|
|
||||||
|
type FormFieldContextValue<
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||||
|
> = {
|
||||||
|
name: TName;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
|
||||||
|
|
||||||
|
const FormField = <
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
|
return (
|
||||||
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
|
<Controller {...props} />
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useFormField = () => {
|
||||||
|
const fieldContext = React.useContext(FormFieldContext);
|
||||||
|
const itemContext = React.useContext(FormItemContext);
|
||||||
|
const { getFieldState, formState } = useFormContext();
|
||||||
|
|
||||||
|
const fieldState = getFieldState(fieldContext.name, formState);
|
||||||
|
|
||||||
|
if (!fieldContext) {
|
||||||
|
throw new Error('useFormField should be used within <FormField>');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = itemContext;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: fieldContext.name,
|
||||||
|
formItemId: `${id}-form-item`,
|
||||||
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
|
formMessageId: `${id}-form-item-message`,
|
||||||
|
...fieldState
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormItemContextValue = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
|
||||||
|
|
||||||
|
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
const id = React.useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div ref={ref} className={cn('space-y-2', className)} {...props} />
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
FormItem.displayName = 'FormItem';
|
||||||
|
|
||||||
|
const FormLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { error, formItemId } = useFormField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(error && 'text-destructive', className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FormLabel.displayName = 'FormLabel';
|
||||||
|
|
||||||
|
const FormControl = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Slot>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof Slot>
|
||||||
|
>(({ ...props }, ref) => {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
ref={ref}
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FormControl.displayName = 'FormControl';
|
||||||
|
|
||||||
|
const FormDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { formDescriptionId } = useFormField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn('text-sm text-muted', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FormDescription.displayName = 'FormDescription';
|
||||||
|
|
||||||
|
const FormMessage = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, children, ...props }, ref) => {
|
||||||
|
const { error, formMessageId } = useFormField();
|
||||||
|
const body = error ? String(error?.message) : children;
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn('text-sm font-medium text-destructive', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FormMessage.displayName = 'FormMessage';
|
||||||
|
|
||||||
|
export {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
useFormField
|
||||||
|
};
|
16
components/ui/Icon.tsx
Normal file
16
components/ui/Icon.tsx
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function Icon({ name, className }: { name: string; className?: string }) {
|
||||||
|
return <i className={cn(`ri-${name}`, className)} role="img" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Icons = {
|
||||||
|
gitHub: () => (
|
||||||
|
<svg viewBox="0 0 438.549 438.549">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M409.132 114.573c-19.608-33.596-46.205-60.194-79.798-79.8-33.598-19.607-70.277-29.408-110.063-29.408-39.781 0-76.472 9.804-110.063 29.408-33.596 19.605-60.192 46.204-79.8 79.8C9.803 148.168 0 184.854 0 224.63c0 47.78 13.94 90.745 41.827 128.906 27.884 38.164 63.906 64.572 108.063 79.227 5.14.954 8.945.283 11.419-1.996 2.475-2.282 3.711-5.14 3.711-8.562 0-.571-.049-5.708-.144-15.417a2549.81 2549.81 0 01-.144-25.406l-6.567 1.136c-4.187.767-9.469 1.092-15.846 1-6.374-.089-12.991-.757-19.842-1.999-6.854-1.231-13.229-4.086-19.13-8.559-5.898-4.473-10.085-10.328-12.56-17.556l-2.855-6.57c-1.903-4.374-4.899-9.233-8.992-14.559-4.093-5.331-8.232-8.945-12.419-10.848l-1.999-1.431c-1.332-.951-2.568-2.098-3.711-3.429-1.142-1.331-1.997-2.663-2.568-3.997-.572-1.335-.098-2.43 1.427-3.289 1.525-.859 4.281-1.276 8.28-1.276l5.708.853c3.807.763 8.516 3.042 14.133 6.851 5.614 3.806 10.229 8.754 13.846 14.842 4.38 7.806 9.657 13.754 15.846 17.847 6.184 4.093 12.419 6.136 18.699 6.136 6.28 0 11.704-.476 16.274-1.423 4.565-.952 8.848-2.383 12.847-4.285 1.713-12.758 6.377-22.559 13.988-29.41-10.848-1.14-20.601-2.857-29.264-5.14-8.658-2.286-17.605-5.996-26.835-11.14-9.235-5.137-16.896-11.516-22.985-19.126-6.09-7.614-11.088-17.61-14.987-29.979-3.901-12.374-5.852-26.648-5.852-42.826 0-23.035 7.52-42.637 22.557-58.817-7.044-17.318-6.379-36.732 1.997-58.24 5.52-1.715 13.706-.428 24.554 3.853 10.85 4.283 18.794 7.952 23.84 10.994 5.046 3.041 9.089 5.618 12.135 7.708 17.705-4.947 35.976-7.421 54.818-7.421s37.117 2.474 54.823 7.421l10.849-6.849c7.419-4.57 16.18-8.758 26.262-12.565 10.088-3.805 17.802-4.853 23.134-3.138 8.562 21.509 9.325 40.922 2.279 58.24 15.036 16.18 22.559 35.787 22.559 58.817 0 16.178-1.958 30.497-5.853 42.966-3.9 12.471-8.941 22.457-15.125 29.979-6.191 7.521-13.901 13.85-23.131 18.986-9.232 5.14-18.182 8.85-26.84 11.136-8.662 2.286-18.415 4.004-29.263 5.146 9.894 8.562 14.842 22.077 14.842 40.539v60.237c0 3.422 1.19 6.279 3.572 8.562 2.379 2.279 6.136 2.95 11.276 1.995 44.163-14.653 80.185-41.062 108.068-79.226 27.88-38.161 41.825-81.126 41.825-128.906-.01-39.771-9.818-76.454-29.414-110.049z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
};
|
25
components/ui/Input.tsx
Normal file
25
components/ui/Input.tsx
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-full rounded-md border border-highlight-primary bg-highlight-primary px-3 py-2 text-sm ring-offset-highlight-primary file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
export { Input };
|
26
components/ui/Label.tsx
Normal file
26
components/ui/Label.tsx
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
|
@ -2,12 +2,10 @@
|
||||||
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
|
||||||
|
import Podium from '@/components/ui/events/podium/Podium';
|
||||||
import { useLeaderboardEvent } from '@/lib/hooks/use-leaderboard';
|
import { useLeaderboardEvent } from '@/lib/hooks/use-leaderboard';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import Podium from '@/ui/events/podium/Podium';
|
|
||||||
import { Timer } from './Timer';
|
import { Timer } from './Timer';
|
||||||
import { type ScoreEvent } from '@/lib/leaderboard';
|
|
||||||
import useSWRSubscription, { type SWRSubscription } from 'swr/subscription';
|
|
||||||
|
|
||||||
const SCORE_COLORS = ['text-yellow-400', 'text-gray-400', 'text-orange-400'];
|
const SCORE_COLORS = ['text-yellow-400', 'text-gray-400', 'text-orange-400'];
|
||||||
|
|
||||||
|
@ -74,7 +72,7 @@ export default function Leaderboard({ token }: { token: string }) {
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-semibold text-highlight-secondary',
|
'text-highlight-secondary font-semibold',
|
||||||
SCORE_COLORS[group.rank - 1]
|
SCORE_COLORS[group.rank - 1]
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
@ -83,7 +81,7 @@ export default function Leaderboard({ token }: { token: string }) {
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<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">{group.name}</span>
|
<span className="text-lg">{group.name}</span>
|
||||||
<span className="text-sm text-highlight-secondary">
|
<span className="text-highlight-secondary text-sm">
|
||||||
{group.players && group.players.length > 1
|
{group.players && group.players.length > 1
|
||||||
? group.players
|
? group.players
|
||||||
.map((player) => player.pseudo || 'Anonyme')
|
.map((player) => player.pseudo || 'Anonyme')
|
||||||
|
@ -101,11 +99,11 @@ export default function Leaderboard({ token }: { token: string }) {
|
||||||
</div> */}
|
</div> */}
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-sm font-semibold">Essai{tries > 1 ? 's' : ''}</span>
|
<span className="text-sm font-semibold">Essai{tries > 1 ? 's' : ''}</span>
|
||||||
<span className="text-lg text-highlight-secondary">{tries}</span>
|
<span className="text-highlight-secondary text-lg">{tries}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-sm font-semibold">Score</span>
|
<span className="text-sm font-semibold">Score</span>
|
||||||
<span className="text-lg text-highlight-secondary">
|
<span className="text-highlight-secondary text-lg">
|
||||||
{group.players.reduce((a, b) => a + b.score, 0)}
|
{group.players.reduce((a, b) => a + b.score, 0)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -116,7 +114,7 @@ export default function Leaderboard({ token }: { token: string }) {
|
||||||
[...Array(20).keys()].map((i) => (
|
[...Array(20).keys()].map((i) => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={i}
|
||||||
className="inline-block h-12 animate-pulse rounded-lg bg-primary-600"
|
className="bg-primary-600 inline-block h-12 animate-pulse rounded-lg"
|
||||||
style={{
|
style={{
|
||||||
animationDelay: `${i * 0.05}s`,
|
animationDelay: `${i * 0.05}s`,
|
||||||
animationDuration: '1s'
|
animationDuration: '1s'
|
|
@ -1,18 +1,30 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import cookies from 'js-cookie';
|
import cookies from 'js-cookie';
|
||||||
import { notFound, useRouter } from 'next/navigation';
|
import { notFound, useRouter } from 'next/navigation';
|
||||||
import { useContext, useState } from 'react';
|
import { useContext, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useSWRConfig } from 'swr';
|
import { useSWRConfig } from 'swr';
|
||||||
|
import * as z from 'zod';
|
||||||
|
|
||||||
import { usePuzzle } from '@/lib/hooks/use-puzzles';
|
import { usePuzzle } from '@/lib/hooks/use-puzzles';
|
||||||
|
import { getURL } from '@/lib/utils';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from '@/components/ui/Form';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import ToHTML from '@/components/ui/ToHTML';
|
||||||
|
|
||||||
import { UserContext } from '@/context/user';
|
import { UserContext } from '@/context/user';
|
||||||
import { getURL } from '@/lib/utils';
|
import { useToast } from '@/lib/hooks/use-toast';
|
||||||
import Button from './Button';
|
|
||||||
import Input from './Input';
|
|
||||||
import ToHTML from './ToHTML';
|
|
||||||
|
|
||||||
type PuzzleData = {
|
type PuzzleData = {
|
||||||
answer: string;
|
answer: string;
|
||||||
|
@ -98,48 +110,7 @@ export default function Puzzle({ token, id }: { token: string; id: number }) {
|
||||||
<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>
|
||||||
{!puzzle.score ? (
|
{!puzzle.score ? (
|
||||||
<form
|
<InputForm />
|
||||||
className="flex w-full flex-col justify-between sm:flex-row"
|
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
|
||||||
encType="multipart/form-data"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center justify-center space-x-0 sm:flex-row sm:space-x-6">
|
|
||||||
<Input
|
|
||||||
className="w-full"
|
|
||||||
label="Réponse"
|
|
||||||
type="text"
|
|
||||||
placeholder="CAPTAIN LOOK !"
|
|
||||||
required
|
|
||||||
{...register('answer')}
|
|
||||||
/>
|
|
||||||
{granted && (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
{granted.message && (
|
|
||||||
<p className="text-sm text-highlight-secondary">{granted.message}</p>
|
|
||||||
)}
|
|
||||||
{granted.tries && (
|
|
||||||
<p className="text-sm text-highlight-secondary">
|
|
||||||
Tentative(s) actuelle(s) : {granted.tries}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{granted.score && (
|
|
||||||
<p className="highlight-secondary text-sm">Score : {granted.score}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* <Input
|
|
||||||
className="h-16 w-full sm:w-1/3"
|
|
||||||
label="Code"
|
|
||||||
type="file"
|
|
||||||
required
|
|
||||||
accept=".py,.js,.ts,.java,.rs,.c"
|
|
||||||
{...register('code_file')}
|
|
||||||
/> */}
|
|
||||||
</div>
|
|
||||||
<Button kind="brand" className="mt-6" type="submit">
|
|
||||||
Envoyer
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="items-center gap-x-2">
|
<div className="items-center gap-x-2">
|
||||||
|
@ -151,11 +122,7 @@ export default function Puzzle({ token, id }: { token: string; id: number }) {
|
||||||
Score : <span className="text-brand-accent">{puzzle.score}</span>
|
Score : <span className="text-brand-accent">{puzzle.score}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button type="button" onClick={() => router.push(getURL(`/dashboard/puzzles`))}>
|
||||||
kind="brand"
|
|
||||||
type="button"
|
|
||||||
onClick={() => router.push(getURL(`/dashboard/puzzles`))}
|
|
||||||
>
|
|
||||||
Retour aux puzzles
|
Retour aux puzzles
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -163,3 +130,63 @@ export default function Puzzle({ token, id }: { token: string; id: number }) {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const InputFormSchema = z.object({
|
||||||
|
answer: z.string().nonempty().trim(),
|
||||||
|
code_file: z.any().nullable()
|
||||||
|
});
|
||||||
|
|
||||||
|
function InputForm() {
|
||||||
|
const form = useForm<z.infer<typeof InputFormSchema>>({
|
||||||
|
resolver: zodResolver(InputFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
answer: '',
|
||||||
|
code_file: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSubmit(data: z.infer<typeof InputFormSchema>) {
|
||||||
|
console.log(data);
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="flex w-full flex-col items-end justify-between gap-4 sm:flex-row"
|
||||||
|
encType="multipart/form-data"
|
||||||
|
>
|
||||||
|
<div className="flex w-full flex-col gap-2 sm:flex-row sm:gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="answer"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel htmlFor="answer">Réponse</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="CAPTAIN, LOOK !" autoComplete="off" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="code_file"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel htmlFor="code_file">Fichier</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} type="file" />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button className="w-full sm:w-44" variant="brand">
|
||||||
|
Envoyer
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
|
@ -5,12 +5,12 @@ import { type ChangeEvent, useContext, useEffect, useMemo, useState } from 'reac
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useSWRConfig } from 'swr';
|
import { useSWRConfig } from 'swr';
|
||||||
|
|
||||||
import AppLink from './AppLink';
|
import AppLink from '@/components/ui/AppLink';
|
||||||
import Button from './Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import Dialog from './Dialog';
|
import Dialog from '@/components/ui/Dialog';
|
||||||
import Icon from './Icon';
|
import { Icon, Icons } from '@/components/ui/Icon';
|
||||||
import Input from './Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import Select from './Select';
|
import Select from '@/components/ui/Select';
|
||||||
|
|
||||||
import { UserContext } from '@/context/user';
|
import { UserContext } from '@/context/user';
|
||||||
import { useGroups } from '@/lib/hooks/use-groups';
|
import { useGroups } from '@/lib/hooks/use-groups';
|
||||||
|
@ -111,7 +111,7 @@ export default function Puzzles({ token }: { token: string }) {
|
||||||
onOpenChange={() => handleClick(chapter.id)}
|
onOpenChange={() => handleClick(chapter.id)}
|
||||||
trigger={
|
trigger={
|
||||||
<button className="flex items-center gap-x-2 text-sm font-semibold text-muted hover:text-brand">
|
<button className="flex items-center gap-x-2 text-sm font-semibold text-muted hover:text-brand">
|
||||||
<Icon name="group-line" />
|
{/* <Icon name="group-line" /> */}
|
||||||
Rejoindre un groupe
|
Rejoindre un groupe
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
@ -124,7 +124,7 @@ export default function Puzzles({ token }: { token: string }) {
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{chapter.startDate && chapter.endDate ? (
|
{chapter.startDate && chapter.endDate ? (
|
||||||
<div className="flex items-center justify-start gap-x-2 md:justify-end">
|
<div className="flex items-center justify-start gap-x-2 md:justify-end">
|
||||||
<Icon name="calendar-line" className="text-sm text-muted" />
|
{/* <Icon name="calendar-line" className="text-sm text-muted" /> */}
|
||||||
<span className="text-sm text-muted">
|
<span className="text-sm text-muted">
|
||||||
{new Date(chapter.startDate).toLocaleDateString('fr-FR', {
|
{new Date(chapter.startDate).toLocaleDateString('fr-FR', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
@ -291,10 +291,10 @@ function PuzzleProp({ puzzle, chapter }: { puzzle: Puzzle; chapter: Chapter }) {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Icon
|
{/* <Icon
|
||||||
className="-translate-x-2 transform-gpu text-highlight-secondary duration-300 group-hover:translate-x-0"
|
className="-translate-x-2 transform-gpu text-highlight-secondary duration-300 group-hover:translate-x-0"
|
||||||
name="arrow-right-line"
|
name="arrow-right-line"
|
||||||
/>
|
/> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -493,7 +493,7 @@ function GroupForm({ chapter, token }: { chapter: Chapter; token: string }) {
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<Input
|
<Input
|
||||||
className="w-full"
|
className="w-full"
|
||||||
label="Nom du groupe"
|
// label="Nom du groupe"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Terre en vue mon capitaine !"
|
placeholder="Terre en vue mon capitaine !"
|
||||||
required
|
required
|
||||||
|
@ -524,9 +524,7 @@ function GroupForm({ chapter, token }: { chapter: Chapter; token: string }) {
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Button kind="brand" type="submit">
|
<Button type="submit">{isJoining ? 'Rejoindre' : 'Créer'}</Button>
|
||||||
{isJoining ? 'Rejoindre' : 'Créer'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
|
@ -1,6 +1,6 @@
|
||||||
import { forwardRef } from 'react';
|
import { forwardRef } from 'react';
|
||||||
import ErrorMessage from './ErrorMessage';
|
import ErrorMessage from './ErrorMessage';
|
||||||
import Label from './Label';
|
import { Label } from '@/components/ui/Label';
|
||||||
|
|
||||||
const Select = forwardRef<
|
const Select = forwardRef<
|
||||||
HTMLSelectElement,
|
HTMLSelectElement,
|
||||||
|
@ -13,7 +13,8 @@ const Select = forwardRef<
|
||||||
//& Partial<ReturnType<UseFormRegister<any>>></HTMLSelectElement>
|
//& 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 className={className}>
|
||||||
|
{label}
|
||||||
<select
|
<select
|
||||||
className={
|
className={
|
||||||
'w-full cursor-pointer overflow-hidden rounded-lg border-2 border-highlight-primary bg-highlight-primary 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 disabled:opacity-50'
|
'w-full cursor-pointer overflow-hidden rounded-lg border-2 border-highlight-primary bg-highlight-primary 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 disabled:opacity-50'
|
186
components/ui/UserAuthForm.tsx
Normal file
186
components/ui/UserAuthForm.tsx
Normal file
|
@ -0,0 +1,186 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import cookies from 'js-cookie';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import * as z from 'zod';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from '@/components/ui/Form';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
|
||||||
|
const AuthFormSchema = z.object({
|
||||||
|
pseudo: z.string().min(3, 'Votre pseudo doit faire au moins 3 caractères.'),
|
||||||
|
email: z.string().optional(),
|
||||||
|
passwd: z.string().min(8, 'Votre mot de passe doit faire au moins 8 caractères.'),
|
||||||
|
firstname: z.string().optional(),
|
||||||
|
lastname: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function UserAuthForm() {
|
||||||
|
const form = useForm<z.infer<typeof AuthFormSchema>>({
|
||||||
|
resolver: zodResolver(AuthFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
pseudo: '',
|
||||||
|
email: '',
|
||||||
|
passwd: '',
|
||||||
|
firstname: '',
|
||||||
|
lastname: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname()!;
|
||||||
|
|
||||||
|
const isSignIn = pathname.includes('sign-in');
|
||||||
|
|
||||||
|
async function onSubmit(data: z.infer<typeof AuthFormSchema>) {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}/${isSignIn ? 'login' : 'register'}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
form.setError('passwd', {
|
||||||
|
type: 'manual',
|
||||||
|
message: "Une erreur s'est produite."
|
||||||
|
});
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSignIn) {
|
||||||
|
if (res.status === 400) {
|
||||||
|
const { username_valid, email_valid } = await res.json();
|
||||||
|
if (!username_valid) {
|
||||||
|
form.setError('pseudo', {
|
||||||
|
message: "Nom d'utilisateur indisponible"
|
||||||
|
});
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!email_valid) {
|
||||||
|
form.setError('email', {
|
||||||
|
message: 'Adresse e-mail indisponible'
|
||||||
|
});
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 200) {
|
||||||
|
const token = res.headers.get('Authorization')?.split(' ')[1];
|
||||||
|
if (token) {
|
||||||
|
cookies.set('token', token, {
|
||||||
|
sameSite: 'strict',
|
||||||
|
secure: process.env.NODE_ENV === 'production'
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
form.setError('passwd', {
|
||||||
|
message: "Nom d'utilisateur ou mot de passe incorrect"
|
||||||
|
});
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
{!isSignIn && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel htmlFor="email">Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="email" placeholder="philipzcwbarlow@peerat.dev" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="firstname"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel htmlFor="firstname">Prénom</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Philip" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="lastname"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel htmlFor="lastname">Nom</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Barlow" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="pseudo"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel htmlFor="pseudo">Nom d'utilisateur</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Barlow" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="passwd"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel htmlFor="passwd">Mot de passe</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder="********" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button disabled={isLoading} variant="brand">
|
||||||
|
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{isSignIn ? 'Se connecter' : "S'inscrire"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,11 +1,11 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import AppLink from '@/components/ui/AppLink';
|
||||||
|
import { Icon } from '@/components/ui/Icon';
|
||||||
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 Image from 'next/image';
|
||||||
import { useSelectedLayoutSegment } from 'next/navigation';
|
import { useSelectedLayoutSegment } from 'next/navigation';
|
||||||
import AppLink from '../AppLink';
|
|
||||||
import Icon from '../Icon';
|
|
||||||
|
|
||||||
export default function Sidenav({ isOpen, toggle }: { isOpen: boolean; toggle: () => void }) {
|
export default function Sidenav({ isOpen, toggle }: { isOpen: boolean; toggle: () => void }) {
|
||||||
return (
|
return (
|
||||||
|
@ -64,7 +64,7 @@ export default function Sidenav({ isOpen, toggle }: { isOpen: boolean; toggle: (
|
||||||
item={{
|
item={{
|
||||||
name: 'Git',
|
name: 'Git',
|
||||||
slug: 'https://git.peerat.dev/Peer-at-Code',
|
slug: 'https://git.peerat.dev/Peer-at-Code',
|
||||||
icon: 'git-repository-line',
|
icon: 'github-fill',
|
||||||
disabled: false
|
disabled: false
|
||||||
}}
|
}}
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
|
@ -99,8 +99,8 @@ function NavItem({
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex justify-center rounded-md px-3 py-3 text-sm transition-colors duration-150 lg:justify-start',
|
'flex justify-center rounded-md px-3 py-3 text-sm transition-colors duration-150 lg:justify-start',
|
||||||
{
|
{
|
||||||
'text-muted hover:text-secondary': !isActive,
|
'text-muted hover:bg-highlight-primary/80 hover:text-foreground': !isActive,
|
||||||
'bg-highlight-primary text-secondary hover:text-white': isActive,
|
'bg-highlight-primary text-foreground': isActive,
|
||||||
'cursor-not-allowed 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
|
||||||
|
@ -109,7 +109,7 @@ function NavItem({
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Icon className="text-2xl" name={item.icon} />
|
<Icon name={item.icon} />
|
||||||
<span
|
<span
|
||||||
className={cn('hidden lg:block', {
|
className={cn('hidden lg:block', {
|
||||||
'block sm:hidden': isOpen,
|
'block sm:hidden': isOpen,
|
|
@ -5,8 +5,8 @@ import { titleCase } from '@/lib/utils';
|
||||||
import { useRouter, useSelectedLayoutSegment } from 'next/navigation';
|
import { useRouter, useSelectedLayoutSegment } from 'next/navigation';
|
||||||
import { useContext, useEffect, useState } from 'react';
|
import { useContext, useEffect, useState } from 'react';
|
||||||
import AvatarComponent from '../Avatar';
|
import AvatarComponent from '../Avatar';
|
||||||
import Icon from '../Icon';
|
import { Icon, Icons } from '@/components/ui/Icon';
|
||||||
import Popover from '../Popover';
|
import Popover from '@/components/ui/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 [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
@ -22,7 +22,7 @@ export default function Usernav({ isOpen, toggle }: { isOpen: boolean; toggle: (
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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="z-50 flex w-full flex-row items-center justify-between border-b border-solid border-highlight-primary px-8 py-4">
|
||||||
<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">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<button onClick={toggle} className="block sm:hidden">
|
<button onClick={toggle} className="block sm:hidden">
|
||||||
|
@ -49,7 +49,7 @@ export default function Usernav({ isOpen, toggle }: { isOpen: boolean; toggle: (
|
||||||
>
|
>
|
||||||
<nav className="flex w-32 flex-col gap-2">
|
<nav className="flex w-32 flex-col gap-2">
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-1 p-2 text-error hover:bg-error/10"
|
className="flex items-center gap-1 p-2 text-destructive hover:bg-destructive/10"
|
||||||
onClick={() => router.push('/logout')}
|
onClick={() => router.push('/logout')}
|
||||||
>
|
>
|
||||||
Se déconnecter
|
Se déconnecter
|
187
lib/hooks/use-toast.ts
Normal file
187
lib/hooks/use-toast.ts
Normal file
|
@ -0,0 +1,187 @@
|
||||||
|
// Inspired by react-hot-toast library
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import type { ToastActionElement, ToastProps } from '@/components/ui/Toast';
|
||||||
|
|
||||||
|
const TOAST_LIMIT = 1;
|
||||||
|
const TOAST_REMOVE_DELAY = 1000000;
|
||||||
|
|
||||||
|
type ToasterToast = ToastProps & {
|
||||||
|
id: string;
|
||||||
|
title?: React.ReactNode;
|
||||||
|
description?: React.ReactNode;
|
||||||
|
action?: ToastActionElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionTypes = {
|
||||||
|
ADD_TOAST: 'ADD_TOAST',
|
||||||
|
UPDATE_TOAST: 'UPDATE_TOAST',
|
||||||
|
DISMISS_TOAST: 'DISMISS_TOAST',
|
||||||
|
REMOVE_TOAST: 'REMOVE_TOAST'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
function genId() {
|
||||||
|
count = (count + 1) % Number.MAX_VALUE;
|
||||||
|
return count.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionType = typeof actionTypes;
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| {
|
||||||
|
type: ActionType['ADD_TOAST'];
|
||||||
|
toast: ToasterToast;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType['UPDATE_TOAST'];
|
||||||
|
toast: Partial<ToasterToast>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType['DISMISS_TOAST'];
|
||||||
|
toastId?: ToasterToast['id'];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType['REMOVE_TOAST'];
|
||||||
|
toastId?: ToasterToast['id'];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
toasts: ToasterToast[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
const addToRemoveQueue = (toastId: string) => {
|
||||||
|
if (toastTimeouts.has(toastId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
toastTimeouts.delete(toastId);
|
||||||
|
dispatch({
|
||||||
|
type: 'REMOVE_TOAST',
|
||||||
|
toastId: toastId
|
||||||
|
});
|
||||||
|
}, TOAST_REMOVE_DELAY);
|
||||||
|
|
||||||
|
toastTimeouts.set(toastId, timeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reducer = (state: State, action: Action): State => {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'ADD_TOAST':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT)
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'UPDATE_TOAST':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t))
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'DISMISS_TOAST': {
|
||||||
|
const { toastId } = action;
|
||||||
|
|
||||||
|
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||||
|
// but I'll keep it here for simplicity
|
||||||
|
if (toastId) {
|
||||||
|
addToRemoveQueue(toastId);
|
||||||
|
} else {
|
||||||
|
state.toasts.forEach((toast) => {
|
||||||
|
addToRemoveQueue(toast.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === toastId || toastId === undefined
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
open: false
|
||||||
|
}
|
||||||
|
: t
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'REMOVE_TOAST':
|
||||||
|
if (action.toastId === undefined) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== action.toastId)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const listeners: Array<(state: State) => void> = [];
|
||||||
|
|
||||||
|
let memoryState: State = { toasts: [] };
|
||||||
|
|
||||||
|
function dispatch(action: Action) {
|
||||||
|
memoryState = reducer(memoryState, action);
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
listener(memoryState);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type Toast = Omit<ToasterToast, 'id'>;
|
||||||
|
|
||||||
|
function toast({ ...props }: Toast) {
|
||||||
|
const id = genId();
|
||||||
|
|
||||||
|
const update = (props: ToasterToast) =>
|
||||||
|
dispatch({
|
||||||
|
type: 'UPDATE_TOAST',
|
||||||
|
toast: { ...props, id }
|
||||||
|
});
|
||||||
|
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: 'ADD_TOAST',
|
||||||
|
toast: {
|
||||||
|
...props,
|
||||||
|
id,
|
||||||
|
open: true,
|
||||||
|
onOpenChange: (open: any) => {
|
||||||
|
if (!open) dismiss();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
dismiss,
|
||||||
|
update
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useToast() {
|
||||||
|
const [state, setState] = React.useState<State>(memoryState);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
listeners.push(setState);
|
||||||
|
return () => {
|
||||||
|
const index = listeners.indexOf(setState);
|
||||||
|
if (index > -1) {
|
||||||
|
listeners.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toast,
|
||||||
|
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useToast, toast };
|
|
@ -1,3 +1,6 @@
|
||||||
|
import { Icons, type Icon } from '@/components/ui/Icon';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A navigation item.
|
* A navigation item.
|
||||||
*
|
*
|
||||||
|
|
|
@ -29,7 +29,7 @@ export const getPlayer = async ({
|
||||||
export type Player = {
|
export type Player = {
|
||||||
email: string;
|
email: string;
|
||||||
pseudo: string;
|
pseudo: string;
|
||||||
firstnames: string;
|
firstname: string;
|
||||||
lastname: string;
|
lastname: string;
|
||||||
description: string;
|
description: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
|
|
|
@ -2,9 +2,6 @@
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
swcMinify: true,
|
swcMinify: true,
|
||||||
experimental: {
|
|
||||||
scrollRestoration: true
|
|
||||||
},
|
|
||||||
redirects: async () => {
|
redirects: async () => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|
19
package.json
19
package.json
|
@ -20,25 +20,34 @@
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/Peer-at-Code/peer-at-code#readme",
|
"homepage": "https://github.com/Peer-at-Code/peer-at-code#readme",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^3.1.0",
|
||||||
"@radix-ui/react-dialog": "^1.0.3",
|
"@radix-ui/react-dialog": "^1.0.3",
|
||||||
|
"@radix-ui/react-label": "^2.0.2",
|
||||||
"@radix-ui/react-popover": "^1.0.5",
|
"@radix-ui/react-popover": "^1.0.5",
|
||||||
|
"@radix-ui/react-select": "^1.2.2",
|
||||||
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
|
"@radix-ui/react-toast": "^1.1.4",
|
||||||
"boring-avatars": "^1.7.0",
|
"boring-avatars": "^1.7.0",
|
||||||
|
"class-variance-authority": "^0.6.0",
|
||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
"edge-csrf": "^1.0.3",
|
"edge-csrf": "^1.0.3",
|
||||||
"framer-motion": "^10.12.4",
|
"framer-motion": "^10.12.4",
|
||||||
"js-cookie": "^3.0.1",
|
"js-cookie": "^3.0.1",
|
||||||
"next": "13.4.0",
|
"lucide-react": "^0.252.0",
|
||||||
|
"next": "13.4.8",
|
||||||
|
"next-themes": "^0.2.1",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-hook-form": "^7.43.1",
|
"react-hook-form": "^7.44.2",
|
||||||
"react-markdown": "^8.0.5",
|
"react-markdown": "^8.0.5",
|
||||||
"remark-breaks": "^3.0.2",
|
"remark-breaks": "^3.0.2",
|
||||||
"remark-gfm": "^3.0.1",
|
"remark-gfm": "^3.0.1",
|
||||||
"remixicon": "^2.5.0",
|
"remixicon": "^3.3.0",
|
||||||
"sharp": "^0.32.1",
|
"sharp": "^0.32.1",
|
||||||
"swr": "^2.0.3",
|
"swr": "^2.0.3",
|
||||||
"tailwind-merge": "^1.9.0",
|
"tailwind-merge": "^1.12.0",
|
||||||
"zod": "^3.20.2"
|
"tailwindcss-animate": "^1.0.5",
|
||||||
|
"zod": "^3.21.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5.3",
|
"@tailwindcss/forms": "^0.5.3",
|
||||||
|
|
2918
pnpm-lock.yaml
generated
2918
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
93
styles/global.css
Normal file
93
styles/global.css
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--muted: 210 40% 96.1%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
|
||||||
|
--primary: 222.2 47.4% 11.2%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--secondary: 210 40% 96.1%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--accent: 210 40% 96.1%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--ring: 215 20.2% 65.1%;
|
||||||
|
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
|
||||||
|
--primary: 210 40% 98%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 0 85.7% 97.3%;
|
||||||
|
|
||||||
|
--ring: 217.2 32.6% 17.5%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.console {
|
||||||
|
@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 {
|
||||||
|
-webkit-box-shadow: 0 0 0px 1000px hsl(258deg 15% 17%) inset;
|
||||||
|
transition: background-color 5000s ease-in-out 0s;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,18 +0,0 @@
|
||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
@layer components {
|
|
||||||
.console {
|
|
||||||
@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 {
|
|
||||||
-webkit-box-shadow: 0 0 0px 1000px hsl(258deg 15% 17%) inset;
|
|
||||||
transition: background-color 5000s ease-in-out 0s;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -3,19 +3,60 @@ const defaultTheme = require('tailwindcss/defaultTheme');
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
mode: 'jit',
|
darkMode: ['class'],
|
||||||
content: ['./app/**/*.{js,ts,jsx,tsx}', './ui/**/*.{js,ts,jsx,tsx}'],
|
content: [
|
||||||
future: {
|
'./pages/**/*.{ts,tsx}',
|
||||||
hoverOnlyWhenSupported: true
|
'./components/**/*.{ts,tsx}',
|
||||||
},
|
'./app/**/*.{ts,tsx}',
|
||||||
|
'./src/**/*.{ts,tsx}'
|
||||||
|
],
|
||||||
theme: {
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: '2rem',
|
||||||
|
screens: {
|
||||||
|
'2xl': '1400px'
|
||||||
|
}
|
||||||
|
},
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['var(--font-sans)', ...defaultTheme.fontFamily.sans],
|
sans: ['var(--font-sans)', ...defaultTheme.fontFamily.sans],
|
||||||
code: ['var(--font-code)', ...defaultTheme.fontFamily.serif]
|
code: ['var(--font-code)', ...defaultTheme.fontFamily.serif]
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
...require('tailwindcss/colors'),
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))'
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
foreground: 'hsl(var(--secondary-foreground))'
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
|
foreground: 'hsl(var(--destructive-foreground))'
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
foreground: 'hsl(var(--muted-foreground))'
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
|
foreground: 'hsl(var(--accent-foreground))'
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
|
foreground: 'hsl(var(--popover-foreground))'
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: 'hsl(var(--card))',
|
||||||
|
foreground: 'hsl(var(--card-foreground))'
|
||||||
|
},
|
||||||
primary: {
|
primary: {
|
||||||
900: 'hsl(258deg 15% 7%)',
|
900: 'hsl(258deg 15% 7%)',
|
||||||
800: 'hsl(258deg 15% 11%)',
|
800: 'hsl(258deg 15% 11%)',
|
||||||
|
@ -83,8 +124,27 @@ module.exports = {
|
||||||
tertiary: 'hsl(258deg 8% 65%)',
|
tertiary: 'hsl(258deg 8% 65%)',
|
||||||
secondaryAccent: '#e2e8f0',
|
secondaryAccent: '#e2e8f0',
|
||||||
muted: 'hsl(258deg 7% 46%)'
|
muted: 'hsl(258deg 7% 46%)'
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 4px)'
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
'accordion-down': {
|
||||||
|
from: { height: 0 },
|
||||||
|
to: { height: 'var(--radix-accordion-content-height)' }
|
||||||
|
},
|
||||||
|
'accordion-up': {
|
||||||
|
from: { height: 'var(--radix-accordion-content-height)' },
|
||||||
|
to: { height: 0 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||||
|
'accordion-up': 'accordion-up 0.2s ease-out'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: [require('@tailwindcss/forms')]
|
plugins: [require('tailwindcss-animate'), require('tailwindcss'), require('autoprefixer')]
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,28 +0,0 @@
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { forwardRef } from 'react';
|
|
||||||
|
|
||||||
const Button = forwardRef<
|
|
||||||
HTMLButtonElement,
|
|
||||||
React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
||||||
kind?: 'default' | 'danger' | 'brand';
|
|
||||||
}
|
|
||||||
>(({ kind = 'default', className, ...props }, ref) => (
|
|
||||||
<button
|
|
||||||
ref={ref}
|
|
||||||
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:cursor-not-allowed disabled:opacity-50',
|
|
||||||
{
|
|
||||||
'bg-highlight-primary hover:bg-highlight-primary/60': kind === 'default',
|
|
||||||
'bg-error hover:bg-error/60': kind === 'danger',
|
|
||||||
'bg-gradient-to-tl from-brand to-brand-accent transition-opacity hover:opacity-90':
|
|
||||||
kind === 'brand'
|
|
||||||
},
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
|
|
||||||
Button.displayName = 'Button';
|
|
||||||
|
|
||||||
export default Button;
|
|
|
@ -1,5 +0,0 @@
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
export default function Icon({ name, className }: { name: string; className?: string }) {
|
|
||||||
return <i className={cn(`ri-${name}`, className)} role="img" />;
|
|
||||||
}
|
|
28
ui/Input.tsx
28
ui/Input.tsx
|
@ -1,28 +0,0 @@
|
||||||
import { forwardRef } from 'react';
|
|
||||||
import ErrorMessage from './ErrorMessage';
|
|
||||||
import Label from './Label';
|
|
||||||
|
|
||||||
const Input = forwardRef<
|
|
||||||
HTMLInputElement,
|
|
||||||
React.InputHTMLAttributes<HTMLInputElement> & {
|
|
||||||
label: React.ReactNode;
|
|
||||||
error?: React.ReactNode;
|
|
||||||
description?: React.ReactNode;
|
|
||||||
}
|
|
||||||
>(({ className, label, description, error, ...props }, ref) => (
|
|
||||||
<>
|
|
||||||
<Label label={label} description={description} required={props.required} className={className}>
|
|
||||||
<input
|
|
||||||
ref={ref}
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
</Label>
|
|
||||||
{error && <ErrorMessage>{error}</ErrorMessage>}
|
|
||||||
</>
|
|
||||||
));
|
|
||||||
|
|
||||||
Input.displayName = 'Input';
|
|
||||||
|
|
||||||
export default Input;
|
|
26
ui/Label.tsx
26
ui/Label.tsx
|
@ -1,26 +0,0 @@
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
export default function Label({
|
|
||||||
label,
|
|
||||||
description,
|
|
||||||
className,
|
|
||||||
required,
|
|
||||||
children
|
|
||||||
}: {
|
|
||||||
className?: string;
|
|
||||||
label: React.ReactNode;
|
|
||||||
description?: React.ReactNode;
|
|
||||||
required?: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<label className={cn('flex flex-col gap-1 text-left', className)}>
|
|
||||||
<span className="text-sm">
|
|
||||||
{label}
|
|
||||||
{required && <span className="ml-1 text-error">*</span>}
|
|
||||||
</span>
|
|
||||||
{description && <span className="text-xs text-muted">{description}</span>}
|
|
||||||
{children}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,191 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import cookies from 'js-cookie';
|
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import AppLink from './AppLink';
|
|
||||||
import Button from './Button';
|
|
||||||
import Input from './Input';
|
|
||||||
|
|
||||||
type FormData = {
|
|
||||||
pseudo: string;
|
|
||||||
email: string;
|
|
||||||
passwd: string;
|
|
||||||
firstname: string;
|
|
||||||
lastname: string;
|
|
||||||
description: string;
|
|
||||||
sgroup: string;
|
|
||||||
avatar: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function UserAuthForm() {
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors },
|
|
||||||
setError
|
|
||||||
} = useForm<FormData>({
|
|
||||||
defaultValues: {
|
|
||||||
pseudo: '',
|
|
||||||
email: '',
|
|
||||||
passwd: '',
|
|
||||||
firstname: '',
|
|
||||||
lastname: '',
|
|
||||||
description: '',
|
|
||||||
sgroup: '',
|
|
||||||
avatar: ''
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const router = useRouter();
|
|
||||||
const pathname = usePathname()!;
|
|
||||||
const isSignIn = pathname.includes('sign-in');
|
|
||||||
|
|
||||||
async function onSubmit(data: FormData) {
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
const res = await fetch(
|
|
||||||
`${process.env.NEXT_PUBLIC_API_URL}/${isSignIn ? 'login' : 'register'}`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!res) {
|
|
||||||
setError('passwd', {
|
|
||||||
type: 'manual',
|
|
||||||
message: "Une erreur s'est produite."
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isSignIn) {
|
|
||||||
if (res.status === 400) {
|
|
||||||
const { username_valid, email_valid } = await res.json();
|
|
||||||
if (!username_valid) {
|
|
||||||
setError('pseudo', {
|
|
||||||
type: 'manual',
|
|
||||||
message: "Nom d'utilisateur indisponible"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!email_valid) {
|
|
||||||
setError('email', {
|
|
||||||
type: 'manual',
|
|
||||||
message: 'Adresse e-mail indisponible'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.status === 200) {
|
|
||||||
const token = res.headers.get('Authorization')?.split(' ')[1];
|
|
||||||
if (token) {
|
|
||||||
cookies.set('token', token, {
|
|
||||||
sameSite: 'strict',
|
|
||||||
secure: process.env.NODE_ENV === 'production'
|
|
||||||
});
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setError('passwd', {
|
|
||||||
type: 'manual',
|
|
||||||
message: "Nom d'utilisateur ou mot de passe incorrect"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
className="flex w-52 flex-col justify-center space-y-4 sm:w-72"
|
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
|
||||||
method="POST"
|
|
||||||
>
|
|
||||||
{!isSignIn && (
|
|
||||||
<>
|
|
||||||
<Input
|
|
||||||
label="Adresse e-mail"
|
|
||||||
type="email"
|
|
||||||
placeholder="philipzcwbarlow@peerat.dev"
|
|
||||||
required
|
|
||||||
error={errors.email?.message}
|
|
||||||
{...register('email', {
|
|
||||||
pattern: {
|
|
||||||
value: /^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$/,
|
|
||||||
message: 'Adresse e-mail invalide'
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Nom"
|
|
||||||
type="text"
|
|
||||||
placeholder="Barlow"
|
|
||||||
error={errors.lastname?.message}
|
|
||||||
{...register('lastname', {
|
|
||||||
pattern: {
|
|
||||||
value: /^[a-zA-ZÀ-ÿ]{3,20}$/,
|
|
||||||
message: '3 à 20 caractères alphabétiques'
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Prénom"
|
|
||||||
type="text"
|
|
||||||
placeholder="Philipz"
|
|
||||||
error={errors.firstname?.message}
|
|
||||||
{...register('firstname', {
|
|
||||||
pattern: {
|
|
||||||
value: /^[a-zA-ZÀ-ÿ]{3,20}$/,
|
|
||||||
message: '3 à 20 caractères alphabétiques'
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Input
|
|
||||||
label="Nom d'utilisateur"
|
|
||||||
type="text"
|
|
||||||
placeholder="Cypher Wolf"
|
|
||||||
required
|
|
||||||
error={errors.pseudo?.message}
|
|
||||||
{...register('pseudo', {
|
|
||||||
pattern: {
|
|
||||||
value: /^[a-zA-Z0-9À-ÿ\s_-]{3,20}$/,
|
|
||||||
message: '3 à 20 caractères alphanumériques'
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Mot de passe"
|
|
||||||
type="password"
|
|
||||||
placeholder="Terre en vue mon capitaine !"
|
|
||||||
required
|
|
||||||
error={errors.passwd?.message}
|
|
||||||
{...register('passwd')}
|
|
||||||
/>
|
|
||||||
<Button type="submit" kind="brand" disabled={isLoading}>
|
|
||||||
{isSignIn ? 'Se connecter' : "S'inscrire"}
|
|
||||||
</Button>
|
|
||||||
<div className="flex flex-col text-center">
|
|
||||||
{/* {!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">
|
|
||||||
Politique de confidentialité
|
|
||||||
</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'}>
|
|
||||||
{isSignIn ? "S'inscrire maintenant" : 'Se connecter'}
|
|
||||||
</AppLink>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
Loading…
Add table
Reference in a new issue