Added basic data fetch & ui update

This commit is contained in:
Théo 2023-02-12 18:39:34 +01:00
parent 18d1d82659
commit cb899d416c
22 changed files with 579 additions and 103 deletions

View file

@ -1,16 +1,11 @@
import { type ReactNode } from 'react';
import Sidenav from '@/ui/dashboard/Sidenav';
import Wrapper from '@/ui/dashboard/Wrapper';
export default function Layout({ children }: { children: ReactNode }) {
return (
<div className="flex h-screen flex-col">
<div className="flex flex-1 flex-col overflow-hidden sm:flex-row">
<Sidenav />
<div className="mx-4 flex flex-1 transform flex-col pt-4 pb-8 duration-300 ease-in-out sm:mx-auto sm:py-8 md:max-w-6xl">
{children}
</div>
</div>
<Wrapper>{children}</Wrapper>
</div>
);
}

View file

@ -0,0 +1,16 @@
import Puzzle from '@/ui/Puzzle';
export default async function Page({ params }: { params: { id: string } }) {
const { id } = params;
return (
// <SWRFallback fallback={{ [`puzzles/${id}`]: puzzle }}>
<Puzzle id={id} />
// </SWRFallback>
);
}
// export async function generateStaticParams() {
// const { puzzles } = await getPuzzles();
// return puzzles.map(({ id }) => ({ params: { id } }));
// }

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1,12 +1,34 @@
import '@/styles/globals.css';
import 'remixicon/fonts/remixicon.css';
import { Fira_Code } from '@next/font/google';
import localFont from '@next/font/local';
import { cn } from '@/lib/utils';
import { type ReactNode } from 'react';
const sans = localFont({
variable: '--font-sans',
src: './fonts/Karrik-Regular.woff2',
weight: 'variable'
});
const code = Fira_Code({
variable: '--font-code',
subsets: ['latin'],
weight: 'variable'
});
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="fr" dir="ltr" className={cn('scroll-smooth bg-light-dark [color-scheme:dark]')}>
<html
lang="fr"
dir="ltr"
className={cn(
'scroll-smooth bg-gradient-to-b from-primary-800 to-primary-900 [color-scheme:dark]',
sans.variable,
code.variable
)}
>
<head />
<body className="relative min-h-screen">
<main>{children}</main>

14
lib/hooks/use-puzzles.ts Normal file
View file

@ -0,0 +1,14 @@
import useSWR from 'swr';
import { getChapters, getPuzzle, getPuzzles } from '../puzzles';
export function useChapters() {
return useSWR('chapters', () => getChapters());
}
export function usePuzzles() {
return useSWR('puzzles', () => getPuzzles());
}
export function usePuzzle(id: string) {
return useSWR(`puzzles/${id}`, () => getPuzzle(id));
}

74
lib/puzzles.ts Normal file
View file

@ -0,0 +1,74 @@
export const getChapters = async (): Promise<Chapter[]> => {
const req = await fetch(`http://170.75.166.204/chapters`);
const chapters = await req.json();
if (!req.ok) {
throw new Error('Failed to fetch puzzles');
}
if (!chapters) {
return [];
}
return chapters as Chapter[];
};
export const getPuzzlesByChapter = async (chapitre: string): Promise<Puzzle[]> => {
const req = await fetch(`http://170.75.166.204/chapter/${chapitre}`);
const { puzzles } = await req.json();
if (!req.ok) {
throw new Error('Failed to fetch puzzles');
}
if (!puzzles) {
return [];
}
return puzzles as Puzzle[];
};
export const getPuzzles = async (): Promise<{ chapters: Chapter[]; puzzles: Puzzle[] }> => {
const chapters = await getChapters();
let puzzles: Puzzle[] = [];
for (const chapter of chapters) {
const puzzlesByChapter = await getPuzzlesByChapter(chapter.id);
puzzles = [...puzzles, ...puzzlesByChapter];
}
return {
chapters: chapters as Chapter[],
puzzles: puzzles as Puzzle[]
};
};
export const getPuzzle = async (id: string): Promise<Puzzle> => {
const req = await fetch(`http://170.75.166.204/puzzle/${id}`);
const puzzle = await req.json();
if (!req.ok) {
throw new Error('Failed to fetch puzzle');
}
if (!puzzle) {
return {} as Puzzle;
}
return puzzle as Puzzle;
};
export type Puzzle = {
chapter: string;
name: string;
id: string;
content: string;
};
export type Chapter = {
name: string;
id: string;
};

View file

@ -1,6 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true, // Recommended for the `pages` directory, default in `app`.
reactStrictMode: true,
swcMinify: true,
experimental: {
appDir: true

View file

@ -26,6 +26,7 @@
"next": "13.1.6",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.43.1",
"remixicon": "^2.5.0",
"swr": "^2.0.3",
"tailwind-merge": "^1.9.0",

11
pnpm-lock.yaml generated
View file

@ -20,6 +20,7 @@ specifiers:
prettier-plugin-tailwindcss: ^0.2.2
react: 18.2.0
react-dom: 18.2.0
react-hook-form: ^7.43.1
remixicon: ^2.5.0
swr: ^2.0.3
tailwind-merge: ^1.9.0
@ -33,6 +34,7 @@ dependencies:
next: 13.1.6_biqbaboplfbrettd7655fr4n2y
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
react-hook-form: 7.43.1_react@18.2.0
remixicon: 2.5.0
swr: 2.0.3_react@18.2.0
tailwind-merge: 1.9.0
@ -2268,6 +2270,15 @@ packages:
scheduler: 0.23.0
dev: false
/react-hook-form/7.43.1_react@18.2.0:
resolution: {integrity: sha512-+s3+s8LLytRMriwwuSqeLStVjRXFGxgjjx2jED7Z+wz1J/88vpxieRQGvJVvzrzVxshZ0BRuocFERb779m2kNg==}
engines: {node: '>=12.22.0'}
peerDependencies:
react: ^16.8.0 || ^17 || ^18
dependencies:
react: 18.2.0
dev: false
/react-is/16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
dev: true

View file

@ -1,3 +1,5 @@
const defaultTheme = require('tailwindcss/defaultTheme');
/** @type {import('tailwindcss').Config} */
module.exports = {
mode: 'jit',
@ -7,9 +9,84 @@ module.exports = {
},
theme: {
extend: {
fontFamily: {
sans: ['var(--font-sans)', ...defaultTheme.fontFamily.sans],
code: ['var(--font-code)', ...defaultTheme.fontFamily.serif]
},
colors: {
'light-dark': '#282828',
dark: '#202020'
...require('tailwindcss/colors'),
primary: {
900: 'hsl(258deg 15% 7%)',
800: 'hsl(258deg 15% 11%)',
700: 'hsl(258deg 15% 15%)',
600: 'hsl(258deg 15% 20%)',
500: 'hsl(258deg 15% 25%)',
400: 'hsl(258deg 14% 35%)',
300: 'hsl(258deg 13% 45%)',
200: 'hsl(258deg 13% 55%)',
100: 'hsl(258deg 10% 65%)',
50: 'hsl(258deg 8% 85%)',
0: 'hsl(258deg 8% 100%)'
},
brand: {
DEFAULT: '#1c56cb',
accent: '#236bfe'
},
success: {
DEFAULT: 'hsl(104deg 39% 59%)',
secondary: '#b5e4ca',
background: '#60a747'
},
info: {
DEFAULT: 'hsl(258deg 78% 77%)',
secondary: '#ccb8f9',
background: '#9878de'
},
warning: {
DEFAULT: 'hsl(39deg 100% 67%)',
secondary: '#ffd88d',
background: '#da9b34'
},
error: {
DEFAULT: 'hsl(7deg 100% 67%)',
secondary: '#ffbc99',
background: '#cd4634'
},
highlight: {
primary: 'hsl(258deg 15% 17%)',
secondary: 'hsl(258deg 10% 46%)'
},
product: {
ignite: 'hsl(8deg 89% 57%)',
pipe: 'hsl(214deg 100% 58%)',
channels: 'hsl(46deg 74% 51%)'
}
},
backgroundColor: {
primary: {
DEFAULT: 'hsl(258deg 15% 7%)',
900: 'hsl(258deg 15% 7%)',
800: 'hsl(258deg 15% 11%)',
700: 'hsl(258deg 15% 15%)',
600: 'hsl(258deg 15% 20%)',
500: 'hsl(258deg 15% 25%)',
400: 'hsl(258deg 14% 35%)',
300: 'hsl(258deg 13% 45%)',
200: 'hsl(258deg 13% 55%)',
100: 'hsl(258deg 10% 65%)',
50: 'hsl(258deg 8% 85%)',
0: 'hsl(258deg 8% 100%)'
},
secondary: 'hsl(258deg 15% 11%)',
tertiary: 'hsl(258deg 15% 17%)',
contrast: '#4f5450'
},
textColor: {
primary: 'hsl(258deg 8% 100%)',
secondary: 'hsl(258deg 8% 84%)',
tertiary: 'hsl(258deg 8% 65%)',
secondaryAccent: '#e2e8f0',
muted: 'hsl(258deg 7% 46%)'
}
}
},

View file

@ -4,16 +4,17 @@ import { forwardRef } from 'react';
const Button = forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement> & {
kind?: 'default' | 'outline' | 'danger';
kind?: 'default' | 'danger' | 'brand';
}
>(({ kind = 'default', className, ...props }, ref) => (
<button
ref={ref}
className={cn(
'inline-flex items-center justify-center rounded-lg border-0 px-5 py-2.5 text-center text-sm font-medium outline-none transition-colors focus:outline-none focus:ring-0 disabled:opacity-50',
'inline-flex items-center justify-center rounded-md border-0 px-5 py-2.5 text-center text-sm font-medium outline-none transition-colors focus:outline-none focus:ring-0 disabled:opacity-50',
{
'bg-[#424242] hover:bg-[#424242]/60': kind === 'default',
'bg-red-600 hover:bg-red-600/60': kind === 'danger'
'bg-highlight-primary hover:bg-highlight-primary/60': kind === 'default',
'bg-error hover:bg-error/60': kind === 'danger',
'bg-brand hover:bg-brand/60': kind === 'brand'
},
className
)}

View file

@ -14,7 +14,7 @@ const Input = forwardRef<
<Label label={label} description={description} required={props.required} className={className}>
<input
ref={ref}
className="w-full rounded-lg border-0 bg-[#424242] px-5 py-2.5 text-sm font-medium outline-none transition-colors focus:outline-none focus:ring-0 disabled:opacity-50"
className="w-full rounded-md border-0 bg-highlight-primary px-5 py-2.5 text-sm font-medium ring-offset-0 focus:ring-brand disabled:opacity-50"
{...props}
/>
</Label>

52
ui/Puzzle.tsx Normal file
View file

@ -0,0 +1,52 @@
'use client';
import { usePuzzle } from '@/lib/hooks/use-puzzles';
import { notFound } from 'next/navigation';
import Button from './Button';
import Input from './Input';
import ToHTML from './ToHTML';
export default function Puzzle({ id }: { id: string }) {
const { data: puzzle, isLoading } = usePuzzle(id);
if (isLoading) {
return <></>;
}
if (!puzzle) {
notFound();
}
return (
<div className="flex h-full w-full flex-col justify-between space-y-4">
<div className="flex flex-col space-y-2">
<h2 className="text-4xl font-bold">{puzzle.name}</h2>
<p className="text-sm text-muted">Chapitre {puzzle.chapter}</p>
</div>
<div className="flex h-screen overflow-y-auto">
<ToHTML className="font-code" html={puzzle.content} />
</div>
<form className="flex w-full flex-col justify-between sm:flex-row">
<div className="flex flex-col space-x-0 sm:flex-row sm:space-x-6">
<Input
className="w-full sm:w-1/3"
label="Réponse"
name="answer"
type="text"
placeholder="12"
/>
<Input
className="h-16 w-full sm:w-1/3"
label="Code"
name="code_file"
type="file"
accept=".py,.js,.ts,.java,.rust,.c"
/>
</div>
<Button kind="brand" className="mt-6" type="submit">
Envoyer
</Button>
</form>
</div>
);
}

42
ui/Puzzles.tsx Normal file
View file

@ -0,0 +1,42 @@
'use client';
import { usePuzzles } from '@/lib/hooks/use-puzzles';
import AppLink from './AppLink';
import Icon from './Icon';
export default function Puzzles() {
const { data, isLoading } = usePuzzles();
return (
<>
{!isLoading &&
data?.chapters?.map((chapter) => (
<div key={chapter.id} className="flex flex-col space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-2xl font-semibold">
Chapitre {chapter.id} - {chapter.name}
</h3>
<div className="h-1 w-1/4 bg-gray-200">
<div className="h-1 w-1/2 bg-brand" />
</div>
</div>
<ul className="flex flex-col space-y-4">
{data?.puzzles.map((puzzle) => (
<AppLink key={puzzle.id} href={`/dashboard/puzzles/${puzzle.id}`}>
<li className="group flex justify-between rounded-md bg-primary-700 p-4 font-code hover:bg-primary-600">
<div className="flex space-x-4">
<span className="">{puzzle.id}</span>
<span className="font-semibold">{puzzle.name}</span>
</div>
<Icon
className="-translate-x-2 transform-gpu duration-300 group-hover:translate-x-0"
name="arrow-right-line"
/>
</li>
</AppLink>
))}
</ul>
</div>
))}
</>
);
}

15
ui/SWRFallback.tsx Normal file
View file

@ -0,0 +1,15 @@
'use client';
import { SWRConfig } from 'swr';
export default function SWRFallback({
children,
fallback
}: {
children: React.ReactNode;
fallback: {
[key: string]: unknown;
};
}) {
return <SWRConfig value={{ fallback, keepPreviousData: true }}>{children}</SWRConfig>;
}

119
ui/UserAuthForm.tsx Normal file
View file

@ -0,0 +1,119 @@
'use client';
import { usePathname, useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import AppLink from './AppLink';
import Button from './Button';
import Input from './Input';
export default function UserAuthForm() {
return (
<>
<AuthForm />
</>
);
}
type FormData = {
email?: string;
username: string;
password: string;
};
function AuthForm() {
const {
register,
handleSubmit,
formState: { errors },
setError
} = useForm<FormData>({
defaultValues: {
email: '',
username: '',
password: ''
}
});
const router = useRouter();
const pathname = usePathname()!;
const isSignIn = pathname.includes('sign-in');
async function onSubmit(data: FormData) {
const res = await fetch(`http://170.75.166.204/${isSignIn ? 'login' : 'register'}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
...data
})
});
if (res.status === 200) {
router.push('/dashboard');
}
}
return (
<form
className="bg-dark flex flex-col space-y-4 rounded-md p-6"
onSubmit={handleSubmit(onSubmit)}
>
{!isSignIn && (
<Input
label="Adresse e-mail"
type="email"
placeholder="Ex: peer-at@exemple.be"
required
error={
errors.email?.message
// &&
// (isSignIn ? (
// <>
// {translations.noAccountAssociated}{' '}
// <AppLink className="underline" href="/sign-up">
// {translations.signUpQuestion}
// </AppLink>
// </>
// ) : (
// errors.email.message
// ))
}
{...register('email')}
/>
)}
<Input
label="Nom d'utilisateur"
type="text"
placeholder='Ex: "PeerAt"'
required
{...register('username', { required: true })}
/>
<Input
label="Mot de passe"
type="password"
placeholder='Ex: "MotDePasse123"'
required
{...register('password', { required: true })}
/>
<Button type="submit" kind="brand">
Se connecter
</Button>
{/* {!isSignIn && (
<p className="items-center text-sm text-gray-400">
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-gray-400">
{isSignIn ? "Vous n'avez pas de compte?" : 'Vous possédez un compte?'}{' '}
<AppLink className="text-white underline" href={isSignIn ? '/sign-up' : '/sign-in'}>
{isSignIn ? "S'inscrire maintenant" : 'Se connecter'}
</AppLink>
</p>
</form>
);
}

View file

@ -2,98 +2,92 @@
import { NavItem, navItems } from '@/lib/nav-items';
import { cn } from '@/lib/utils';
import Image from 'next/image';
import { useSelectedLayoutSegment } from 'next/navigation';
import { useState } from 'react';
import Logo from '../../public/logo.webp';
import AppLink from '../AppLink';
import Icon from '../Icon';
export default function Sidenav() {
const [isOpen, setIsOpen] = useState(false);
function toggleSidenav() {
setIsOpen(!isOpen);
}
export default function Sidenav({ isOpen, toggle }: { isOpen: boolean; toggle: () => void }) {
return (
<aside
className={cn(
'relative flex h-24 flex-row justify-between bg-dark shadow-md transition-all duration-300 ease-in-out sm:h-screen sm:flex-col',
'absolute z-10 h-screen w-28 border-r border-highlight-primary bg-gradient-to-b from-primary-800 to-primary-900 shadow-md transition-all duration-300 ease-in-out sm:relative sm:flex sm:flex-col md:w-60',
{
'sm:w-60': isOpen,
'w-full sm:w-28': !isOpen
'bottom-0 -translate-x-full sm:translate-x-0': !isOpen,
'bottom-0 w-full sm:w-28': isOpen
}
)}
>
<div className="flex flex-row sm:flex-col">
<div className="flex px-4 pt-0 sm:block sm:items-center sm:pt-4">
<div className="flex items-center justify-between">
<AppLink href="/" className="hidden sm:block">
<Image src={Logo} alt="Peer-at Code Logo" className=" h-10 w-10" />
</AppLink>
<button
className="flex items-center justify-center rounded bg-light-dark p-1"
onClick={toggleSidenav}
>
<Icon
name="arrow-left-line"
className={cn('transition duration-300', {
'rotate-0': isOpen,
'rotate-180': !isOpen
})}
/>
</button>
</div>
<div className="flex h-full flex-col">
<div className="flex p-6">
<AppLink className="truncate" href="/">
<h1>Peer-at Code</h1>
</AppLink>
</div>
<div className="hidden px-4 pt-4 sm:block">
<hr className="border-light-dark" />
<div className=" px-4 ">
<hr className="border-highlight-primary" />
</div>
<div className="hidden px-4 pt-4 sm:block">
<div className="px-4 pt-4">
<ul className="space-y-4">
{navItems.map((item) => (
<li key={item.slug}>
<NavItem item={item} isOpen={isOpen} />
<NavItem item={item} isOpen={isOpen} onClick={toggle} />
</li>
))}
</ul>
</div>
</div>
<div className="flex flex-row sm:flex-col">
<div className="px-4 py-4">
<button className="flex w-full items-center space-x-2 truncate rounded bg-light-dark p-3">
<Icon className="text-2xl" name="user-line" />
<span className="truncate">Hacktiviste</span>
</button>
<div className="px-4 pt-4">
<hr className="border-highlight-primary" />
</div>
<div className="px-4 pt-4">
<ul className="space-y-4">
<li>
<NavItem
item={{
name: 'Tutoriels',
slug: '/dashboard/tutorials',
icon: 'question-line',
disabled: false
}}
isOpen={isOpen}
onClick={toggle}
/>
</li>
</ul>
</div>
</div>
</aside>
);
}
function NavItem({ item, isOpen }: { item: NavItem; isOpen: boolean }) {
function NavItem({
item,
isOpen,
onClick
}: {
item: NavItem;
isOpen: boolean;
onClick?: () => void;
}) {
const segment = useSelectedLayoutSegment();
const isActive = segment?.split('/').pop() === item.slug || (item.slug === '' && !segment);
return (
<AppLink
href={item.disabled ? '/dashboard' : `/dashboard/${item.slug}`}
className={cn('flex rounded-md px-3 py-3 text-sm font-medium', {
'bg-light-dark text-gray-400 hover:bg-light-dark/60 hover:text-white': !isActive,
'bg-blue-500 text-white': isActive,
className={cn('flex justify-center rounded-md px-3 py-3 text-sm md:justify-start', {
'text-muted hover:text-white': !isActive,
'bg-highlight-primary text-secondary': isActive,
'text-gray-600 hover:text-gray-600': item.disabled,
'justify-start': isOpen,
'justify-center': !isOpen
'justify-center md:justify-start': isOpen,
'justify-start sm:justify-center': !isOpen
})}
onClick={onClick}
passHref
>
<div
className={cn('flex items-center', {
'space-x-2': isOpen
})}
>
<div className="flex items-center space-x-2">
<Icon className="text-2xl" name={item.icon} />
<span
className={cn({
className={cn('hidden md:block', {
'block sm:hidden': isOpen,
hidden: !isOpen
})}
>

42
ui/dashboard/Usernav.tsx Normal file
View file

@ -0,0 +1,42 @@
'use client';
import { useSelectedLayoutSegment } from 'next/navigation';
import Icon from '../Icon';
export default function Usernav({ isOpen, toggle }: { isOpen: boolean; toggle: () => void }) {
const segment = useSelectedLayoutSegment();
// segment to TitleCase
const titleCase = (str: string) => {
return str
.toLowerCase()
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};
return (
<div className="z-50 flex flex-row items-center justify-between border-b border-solid border-highlight-primary bg-secondary py-4 px-8">
<div className="flex flex-row items-center space-x-2 sm:space-x-0">
<div className="flex items-center">
<button onClick={toggle} className="block sm:hidden">
{isOpen ? <Icon name="close-line" /> : <Icon name="menu-2-line" />}
</button>
</div>
{segment && (
<div className="flex items-center justify-center text-highlight-secondary">
{titleCase(segment)}
</div>
)}
</div>
<div className="flex flex-row items-center space-x-4">
<div className="flex items-center justify-center p-1 text-xl">
<Icon name="flag-line" />
</div>
<div className="flex items-center justify-center rounded-full bg-highlight-primary px-4 py-2">
T
</div>
</div>
</div>
);
}

22
ui/dashboard/Wrapper.tsx Normal file
View file

@ -0,0 +1,22 @@
'use client';
import { useState, type ReactNode } from 'react';
import Sidenav from './Sidenav';
import Usernav from './Usernav';
export default function Wrapper({ children }: { children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const toggle = () => setIsOpen(!isOpen);
return (
<div className="flex flex-1 overflow-hidden">
<Sidenav isOpen={isOpen} toggle={toggle} />
<div className="flex flex-1 flex-col">
<Usernav isOpen={isOpen} toggle={toggle} />
<div className="flex w-full flex-1 transform flex-col overflow-y-scroll p-8 duration-300 ease-in-out">
{children}
</div>
</div>
</div>
);
}