refactor: created a folder for ui

This commit is contained in:
Théo 2023-07-05 18:48:34 +02:00
parent f06a762325
commit 237a0e3af4
34 changed files with 588 additions and 368 deletions

View 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
View 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 };

View file

@ -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" />

View file

@ -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
View 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
View 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
View 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
View 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 }

View file

@ -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'

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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'

View 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&apos;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>
);
}

View file

@ -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,

View file

@ -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

View file

@ -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;

View file

@ -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" />;
}

View file

@ -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;

View file

@ -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>
);
}

View file

@ -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>
);
}