Refactor core and features #26

Merged
glazk0 merged 2 commits from dev into main 2024-04-16 00:44:52 +02:00
54 changed files with 1012 additions and 588 deletions

690
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -25,8 +25,13 @@ export const handle: Handle = async ({ event, resolve }) => {
return resolve(event);
}
try {
const user: User = await res.json();
event.locals.user = user;
} catch (error) {
event.locals.user = undefined;
event.cookies.delete('session', { path: '/' });
}
return resolve(event);
};

View file

@ -19,7 +19,7 @@
</script>
<button
class="absolute right-2 top-2 rounded-md p-2 text-white"
class="absolute right-2 top-2 rounded-md p-2"
bind:this={element}
on:click={copy}
{...$$restProps}

View file

@ -1,4 +1,6 @@
// TODO: Add more components here
export { default as Badge } from './badge.svelte';
export { default as Breadcrumb } from './breadcrumb.svelte';
export { default as Chapter } from './chapter.svelte';
export { default as CopyCodeInjector } from './copy-code-injector.svelte';
export { default as Metadata } from './metadata.svelte';
export { default as Puzzle } from './puzzle.svelte';

View file

@ -5,7 +5,7 @@
import Award from 'lucide-svelte/icons/award';
import Code from 'lucide-svelte/icons/code';
import Github from 'lucide-svelte/icons/github';
import GitBranch from 'lucide-svelte/icons/git-branch';
import LifeBuoy from 'lucide-svelte/icons/life-buoy';
import LogOut from 'lucide-svelte/icons/log-out';
import RectangleEllipsis from 'lucide-svelte/icons/rectangle-ellipsis';
@ -49,10 +49,6 @@
<ScrollText class="mr-2 h-4 w-4" />
<span>Logs</span>
</DropdownMenu.Item>
<DropdownMenu.Item href="/admin/chapters">
<Code class="mr-2 h-4 w-4" />
<span>Chapitres</span>
</DropdownMenu.Item>
<DropdownMenu.Item href="/admin/puzzles">
<Code class="mr-2 h-4 w-4" />
<span>Puzzles</span>
@ -61,10 +57,6 @@
</DropdownMenu.Sub>
{/if}
<DropdownMenu.Separator />
<DropdownMenu.Item href="/reset-password">
<RectangleEllipsis class="mr-2 h-4 w-4" />
<span>Réinitialiser le mot de passe</span>
</DropdownMenu.Item>
<DropdownMenu.Item href="/settings">
<Settings class="mr-2 h-4 w-4" />
<span>Paramètres</span>
@ -76,8 +68,8 @@
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item href="/git" target="_blank">
<Github class="mr-2 h-4 w-4" />
<span>GitHub</span>
<GitBranch class="mr-2 h-4 w-4" />
<span>Git</span>
</DropdownMenu.Item>
<DropdownMenu.Item href="/discord" target="_blank">
<LifeBuoy class="mr-2 h-4 w-4" />

View file

@ -0,0 +1,28 @@
import Root from "./textarea.svelte";
type FormTextareaEvent<T extends Event = Event> = T & {
currentTarget: EventTarget & HTMLTextAreaElement;
};
type TextareaEvents = {
blur: FormTextareaEvent<FocusEvent>;
change: FormTextareaEvent<Event>;
click: FormTextareaEvent<MouseEvent>;
focus: FormTextareaEvent<FocusEvent>;
keydown: FormTextareaEvent<KeyboardEvent>;
keypress: FormTextareaEvent<KeyboardEvent>;
keyup: FormTextareaEvent<KeyboardEvent>;
mouseover: FormTextareaEvent<MouseEvent>;
mouseenter: FormTextareaEvent<MouseEvent>;
mouseleave: FormTextareaEvent<MouseEvent>;
paste: FormTextareaEvent<ClipboardEvent>;
input: FormTextareaEvent<InputEvent>;
};
export {
Root,
//
Root as Textarea,
type TextareaEvents,
type FormTextareaEvent,
};

View file

@ -0,0 +1,31 @@
<script lang="ts">
import type { HTMLTextareaAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
type $$Props = HTMLTextareaAttributes;
let className: $$Props["class"] = undefined;
export let value: $$Props["value"] = undefined;
export { className as class };
</script>
<textarea
class={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
bind:value
on:blur
on:change
on:click
on:focus
on:keydown
on:keypress
on:keyup
on:mouseover
on:mouseenter
on:mouseleave
on:paste
on:input
{...$$restProps}
/>

View file

@ -1,9 +1,9 @@
import type { ComponentType } from "svelte";
import type { Icon } from "lucide-svelte";
import { type Icon } from "lucide-svelte";
import BarChart2 from "lucide-svelte/icons/bar-chart-2";
import Code from "lucide-svelte/icons/code";
import Github from "lucide-svelte/icons/github";
import GitBranch from "lucide-svelte/icons/git-branch";
import LayoutDashboard from "lucide-svelte/icons/layout-dashboard";
import LifeBuoy from "lucide-svelte/icons/life-buoy";
@ -44,7 +44,7 @@ export const navigation: NavItemWithChildren[] = [
name: "Git",
href: "/git",
external: true,
icon: Github
icon: GitBranch
},
{
name: "Discord",

View file

@ -1,14 +1,10 @@
export const siteConfig = {
name: 'Peer-at Code',
url: 'https://app.peerat.dev',
description: 'Apprendre la programmation et la cybersécurité en s\'amusant.',
description: "Apprendre la programmation et la cybersécurité en s'amusant.",
imageUrl: '',
keywords: ['peerat', 'code', 'cybersecurite', 'programmation', "apprendre en s'amusant"],
author: 'peerat',
links: {
github: "https://git.peerat.dev",
discord: "https://discord.gg/72vuHcwUkE",
},
themeColor: '#110F15'
};

View file

@ -1,20 +1,15 @@
import type { StateStore } from "./state";
export const connectWebSocket = <T>(path: string, token: string | undefined, store: StateStore<T>) => {
export const connectWebSocket = <T>(path: string, store: StateStore<T>, token: string | undefined = undefined) => {
const ws = new WebSocket(`wss://api.peerat.dev${path}`);
ws.onopen = () => {
console.log('WebSocket connection opened');
if (token) {
ws.send(JSON.stringify({ token }));
}
};
ws.onclose = () => console.log('WebSocket connection closed');
ws.onerror = (event) => console.log('WebSocket error:', event);
ws.onmessage = (event) => {
console.log('WebSocket message:', event.data);
const data: T = JSON.parse(event.data);
store.addRequest(data);
};

6
src/params/id.ts Normal file
View file

@ -0,0 +1,6 @@
import type { ParamMatcher } from '@sveltejs/kit';
export const match: ParamMatcher = (param) => {
const regex = /\d+/
return regex.test(param);
};

21
src/params/link.ts Normal file
View file

@ -0,0 +1,21 @@
import type { ParamMatcher } from '@sveltejs/kit';
export const links = [
'git',
'discord'
];
type Link = (typeof links)[number];
export const linkMap: Record<Link, string> = {
git: "https://git.peerat.dev",
discord: "https://discord.gg/72vuHcwUkE",
}
export const isLink = (param: string): param is Link => {
return links.includes(param as Link);
}
export const match: ParamMatcher = (param: string) => {
return links.includes(param.toLowerCase() as Link)
};

View file

@ -2,6 +2,7 @@
import { navigating } from '$app/stores';
import { Loader, Navbar, Sidenav } from '$lib/components/layout';
import { Toaster } from '$lib/components/ui/sonner';
</script>
{#if $navigating}
@ -17,6 +18,7 @@
class="flex w-full flex-1 transform flex-col overflow-y-auto p-4 duration-300 ease-in-out"
>
<slot />
<Toaster position="top-right" />
</div>
</div>
</div>

View file

@ -11,18 +11,18 @@ export const load = (async ({ fetch, locals: { user } }) => {
const res = await fetch(`${API_URL}/chapters`);
let chapters: Chapter[];
if (!res.ok) {
return {
daily: null
};
chapters = [];
} else {
chapters = await res.json();
}
const chapters: Chapter[] = await res.json();
const lastChapter = chapters.filter((chapter) => chapter.start && chapter.end).pop();
const event = chapters.filter((chapter) => chapter.start && chapter.end).pop();
return {
title: 'Dashboard',
event: lastChapter,
event,
};
}) satisfies PageServerLoad;

View file

@ -5,8 +5,6 @@
import Button from '$lib/components/ui/button/button.svelte';
export let data: PageData;
$: user = data.user;
</script>
<section class="flex w-full flex-col gap-4">
@ -18,9 +16,9 @@
<div
class="flex w-full flex-col justify-between gap-4 space-x-0 md:flex md:flex-row md:space-x-6 md:space-y-0"
>
<Card title="Puzzles résolus" data={user?.completions ?? 0} />
<Card title="Badges obtenus" data={user?.badges?.length ?? 'Aucun'} />
<Card title="Rang actuel" data={user?.rank ?? 'Non classé'} />
<Card title="Puzzles résolus" data={data.user?.completions ?? 0} />
<Card title="Badges obtenus" data={data.user?.badges?.length ?? 'Aucun'} />
<Card title="Rang actuel" data={data.user?.rank ?? 'Non classé'} />
</div>
{#if data.event}
<header>
@ -39,27 +37,6 @@
<Button href="/chapters/{data.event.id}/groups">Participer</Button>
</div>
{/if}
<!-- {#if data.daily && data.daily.puzzle}
<header>
<h1 class="text-lg font-semibold">Puzzle du jour</h1>
<p class="text-muted-foreground">
Essayer de résoudre le puzzle du jour pour gagner des points supplémentaires
</p>
</header>
<div
class="flex items-center justify-between gap-4 rounded border border-primary bg-card px-4 py-2"
>
<div class="flex w-full flex-col justify-between md:flex-row md:items-center md:gap-4">
<span class="text-lg font-semibold">{data.daily.chapter.name}</span>
<span class="text-muted-foreground">
{data.daily.puzzle.name} ({data.daily.puzzle.score ?? '?'}/{data.daily.puzzle.scoreMax})
</span>
</div>
<Button href="/chapters/{data.daily.chapter.id}/puzzle/{data.daily.puzzle.id}">
{data.daily.puzzle.score ? 'Voir' : 'Jouer'}
</Button>
</div>
{/if} -->
<div class="grid grid-cols-1 gap-4">
<div class="flex flex-col gap-4">
<header>
@ -72,8 +49,8 @@
class="h-full max-h-96 overflow-y-scroll rounded border border-border bg-card p-4 shadow-md"
>
<ul class="flex flex-col space-y-2">
{#if user?.completionsList?.length}
{#each user.completionsList as completion, key}
{#if data.user?.completionsList?.length}
{#each data.user.completionsList as completion, key}
<li class="flex justify-between space-x-2">
<div class="flex items-center space-x-4">
<div class="flex items-center space-x-2">

View file

@ -0,0 +1,15 @@
import { redirect } from "@sveltejs/kit";
import type { PageLoad } from "./$types";
import { isLink, linkMap } from "../../../params/link";
export const load: PageLoad = async ({ params: { link } }) => {
link = link.toLowerCase();
if (!isLink(link))
redirect(302, '/')
redirect(302, linkMap[link])
};

View file

@ -2,7 +2,6 @@ import { redirect } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ locals: { user }, cookies }) => {
if (!user) redirect(302, '/login');
if (!user.email.endsWith('@peerat.dev')) redirect(302, '/');

View file

@ -24,7 +24,8 @@
const stateStore = createStateStore<Log>();
onMount(() => {
connectWebSocket('/admin/logs', data.session, stateStore);
connectWebSocket('/admin/logs', stateStore, data.session);
return () => stateStore.reset();
});
const logsStore = derived(stateStore, ($stateStore) =>
@ -97,6 +98,7 @@
<span class="font-normal text-foreground">
{log.createdAt.toLocaleString()}
</span>
</p>
</div>
</div>
{/each}

View file

@ -0,0 +1,20 @@
import { API_URL } from "$env/static/private";
import { redirect } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
import type { Puzzle } from "$lib/types";
export const load: PageServerLoad = async ({ locals: { user }, fetch }) => {
if (!user) redirect(302, '/login');
if (!user.email.endsWith('@peerat.dev')) redirect(302, '/');
const res = await fetch(`${API_URL}/admin/puzzles/`);
if (!res.ok) redirect(302, '/');
const puzzles: Puzzle[] = await res.json();
return {
puzzles
};
};

View file

@ -0,0 +1,40 @@
<script lang="ts">
import type { PageData } from './$types';
import { Input } from '$lib/components/ui/input';
export let data: PageData;
let name: string;
$: filteredPuzzles = data.puzzles.filter((puzzle) => {
const regex = new RegExp(name, 'i');
return regex.test(puzzle.name);
});
</script>
<section class="flex w-full flex-col gap-2">
<header class="flex flex-col justify-between gap-2 lg:flex-row lg:items-center">
<div class="flex flex-col">
<h2 class="text-xl font-semibold">Liste de puzzle</h2>
</div>
</header>
<div class="flex">
<Input bind:value={name} placeholder="Goerfra the puzzle lord" />
</div>
<ul class="flex flex-col gap-2">
{#if filteredPuzzles?.length}
{#each filteredPuzzles as puzzle (puzzle.id)}
<a href="/admin/puzzles/{puzzle.id}">
{puzzle.name}
</a>
{:else}
<li
class="flex h-16 w-full items-center justify-center rounded border border-border bg-card"
>
<p class="text-muted-foreground">Aucun puzzle trouvé</p>
</li>
{/each}
{/if}
</ul>
</section>

View file

@ -0,0 +1,63 @@
import { API_URL } from "$env/static/private";
import { redirect, type Actions } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
import { fail, superValidate } from "sveltekit-superforms";
import { zod } from "sveltekit-superforms/adapters";
import type { Puzzle } from "$lib/types";
import { formSchema } from "./schema";
export const load: PageServerLoad = async ({ locals: { user }, fetch, params: { id } }) => {
if (!user) redirect(302, '/login');
if (!user.email.endsWith('@peerat.dev')) redirect(302, '/');
const res = await fetch(`${API_URL}/admin/puzzle/${id}`);
if (!res.ok) redirect(302, '/admin/puzzles');
const puzzle: Puzzle = await res.json()
const form = await superValidate(puzzle, zod(formSchema));
return {
puzzle,
form,
};
};
export const actions: Actions = {
default: async ({ request, locals: { user }, fetch, params: { id } }) => {
if (!user) return fail(401);
if (!user.email.endsWith('@peerat.dev')) return fail(401);
const form = await superValidate(request, zod(formSchema));
if (!form.valid) {
return fail(400, { form });
}
const res = await fetch(`${API_URL}/admin/puzzle/${id}`, {
method: "PUT",
body: JSON.stringify({
name: form.data.name,
content: form.data.content,
soluce: form.data.soluce,
scoreMax: parseInt(form.data.score_max),
chapter: parseInt(form.data.chapter)
})
});
if (!res.ok) {
return fail(500);
}
return {
form
};
}
};

View file

@ -0,0 +1,80 @@
<script lang="ts">
import type { PageData } from './$types';
import Loader from 'lucide-svelte/icons/loader-circle';
import { zodClient } from 'sveltekit-superforms/adapters';
import { superForm } from 'sveltekit-superforms/client';
import * as Form from '$lib/components/ui/form';
import Input from '$lib/components/ui/input/input.svelte';
import Textarea from '$lib/components/ui/textarea/textarea.svelte';
import { formSchema } from './schema';
import Button from '$lib/components/ui/button/button.svelte';
export let data: PageData;
const form = superForm(data.form, {
validators: zodClient(formSchema),
delayMs: 500,
multipleSubmits: 'prevent'
});
const { form: formData, enhance, delayed } = form;
</script>
<section>
<form class="flex flex-col gap-2" method="POST" use:enhance>
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold">Modifier le puzzle</h2>
<div class="flex gap-2">
{#if $formData.chapter}
<Button href="/chapters/{$formData.chapter}/puzzle/{data.puzzle.id}">
Voir le puzzle
</Button>
{/if}
<Form.Button disabled={$delayed}>
{#if $delayed}
<Loader class="mr-2 h-4 w-4 animate-spin" />
{/if}
Modifier
</Form.Button>
</div>
</div>
<Form.Field {form} name="name">
<Form.Control let:attrs>
<Form.Label>Nom</Form.Label>
<Input {...attrs} bind:value={$formData.name} placeholder="Philip" />
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="content">
<Form.Control let:attrs>
<Form.Label>Contenu</Form.Label>
<Textarea {...attrs} bind:value={$formData.content} placeholder="Barlow" />
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="soluce">
<Form.Control let:attrs>
<Form.Label>Solution</Form.Label>
<Input {...attrs} bind:value={$formData.soluce} placeholder="Cypherwolf" />
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="score_max">
<Form.Control let:attrs>
<Form.Label>Score maximum</Form.Label>
<Input type="number" {...attrs} bind:value={$formData.score_max} placeholder="Cypherwolf" />
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="chapter">
<Form.Control let:attrs>
<Form.Label>Chapitre</Form.Label>
<Input type="number" {...attrs} bind:value={$formData.chapter} placeholder="Cypherwolf" />
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</form>
</section>

View file

@ -0,0 +1,42 @@
import { z } from "zod";
export const formSchema = z.object({
name: z.string().min(1, { message: "Le nom du puzzle est requis" }),
content: z.string().min(1, { message: "Le contenu du puzzle est requis" }),
soluce: z.string().min(1, { message: "La solution du puzzle est requise" }),
score_max: z.string().regex(/^\d+$/, { message: "Le score maximum doit être un nombre" }),
chapter: z.string().regex(/^\d+$/, { message: "Le chapitre doit être un nombre" }),
});
export type FormSchema = typeof formSchema;
const text = `
Bienvenue dans notre vaisseau de cyber-formation le « Rasper-ship », moussaillon.
Nous taccueillons ici pour un entraînement intensif à la programmation.
Quand tu seras devenu un véritable Peer-at Codeur, tu pourras maider à sauver le monde.
Je me présente, je mappelle « Philipz Cypher Wolf Barlow ». Je serai ton capitaine durant tout ton parcours dapprentissage du code.
Premièrement, nous allons nous occuper de ton équipement.
Pour développer, ton arme principale sera un IDE (Integrated Development Editor) mais dans un premier temps je te propose dutiliser un éditeur de texte avancé.
Celui que je te conseille est Open Source est disponible sur tous les types dordinateur, il sappelle Geany (https://www.geany.org/).
Commence par linstaller sur ta machine !
Pour discuter avec dautres Peerats et obtenir de laide des plus expérimentés, rejoins notre serveur Discord (https://discord.gg/eUbSbPceh3).
Nhésite pas y demander une petite démo de lutilisation de Geany :)
Si tu tes orienté vers la cybersécurité, tu peux réaliser les challenges en Python.
Si tu tes orienté vers le développement dapplications, tu peux réaliser les challenges en Java.
Allez moussaillon, « souquez les artimuses » et lancez Geany !
Si tu nas jamais codé, tu peux trouver, dans la cale, des explications sur son fonctionnement.
Commence par créer un fichier Abordage.java ou Abordage.py en fonction du langage que tu veux utiliser.
Voici ce que tu peux écrire dans ce fichier: ![À l'abordage !](https://cdn.peerat.dev/w1-0.png)
Félicitation, tu viens décrire le code source de ton premier programme :)
(Compile) et Exécute ton programme. Copie ci-dessous le résultat affiché dans la console.
Si tu as besoin daide pour cette étape, nhésite pas à solliciter dautres Peerats sur Discord ou dans les couloirs de ton campus.
`

View file

@ -2,14 +2,15 @@
import { page } from '$app/stores';
import Badge from '$lib/components/badge.svelte';
$: user = $page.data.user;
$: badges = user?.badges?.sort((a, b) => a.level - b.level);
$: badges = $page.data.user?.badges?.sort((a, b) => a.level - b.level);
</script>
<section class="flex h-full w-full flex-col gap-4">
<header class="flex flex-col">
<h1 class="text-xl font-semibold">Mes badges</h1>
<p class="text-muted-foreground">Vos badges sont affichés ici, vous pouvez les partager avec vos amis</p>
<p class="text-muted-foreground">
Vos badges sont affichés ici, vous pouvez les partager avec vos amis
</p>
</header>
<main class="flex flex-col justify-between gap-4">
<div class="flex flex-wrap gap-4">

View file

@ -6,22 +6,26 @@ import type { PageServerLoad } from './$types';
import type { Chapter } from '$lib/types';
import { handleRedirect } from '$lib/utils';
export const load = (async ({ url, locals: { user }, fetch }) => {
export const load: PageServerLoad = async ({ url, locals: { user }, fetch }) => {
if (!user) redirect(302, handleRedirect('login', url));
let chapters: Chapter[];
const res = await fetch(`${API_URL}/chapters`);
if (!res.ok) {
return {
chapters: []
};
chapters = [];
} else {
chapters = await res.json();
}
const chapters = (await res.json()) as Chapter[];
if (chapters.length) chapters
.sort((a) => {
return (a.start && a.end) ? -1 : 1;
})
return {
title: 'Chapitres',
chapters
};
}) satisfies PageServerLoad;
}

View file

@ -7,8 +7,6 @@
export let data: PageData;
$: chapters = data.chapters;
const toBeContinued: IChapter = {
id: Math.random() * 999,
name: 'To be continued ...',
@ -28,7 +26,7 @@
</div>
</header>
<ul class="flex flex-col gap-2">
{#each chapters as chapter (chapter.id)}
{#each data.chapters as chapter (chapter.id)}
<Chapter {chapter} />
{/each}
<Chapter chapter={toBeContinued} />

View file

@ -1,13 +1,13 @@
import { API_URL } from '$env/static/private';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import type { Chapter } from '$lib/types';
import { handleRedirect } from '$lib/utils';
import { redirect } from '@sveltejs/kit';
export const load = (async ({ url, locals: { user }, fetch, params: { chapterId } }) => {
export const load: PageServerLoad = async ({ url, locals: { user }, fetch, params: { chapterId } }) => {
if (!user) redirect(302, handleRedirect('login', url));
const res = await fetch(`${API_URL}/chapter/${chapterId}`);
@ -28,4 +28,4 @@ export const load = (async ({ url, locals: { user }, fetch, params: { chapterId
title: chapter.name,
chapter
};
}) satisfies PageServerLoad;
}

View file

@ -1,7 +1,7 @@
<script lang="ts">
import type { PageData } from './$types';
import BarChart2 from 'lucide-svelte/icons/bar-chart-2';
import BarChart from 'lucide-svelte/icons/bar-chart-2';
import Users from 'lucide-svelte/icons/users';
import Puzzle from '$lib/components/puzzle.svelte';
@ -24,13 +24,13 @@
{/if}
</div>
<div class="flex gap-2">
{#if data.chapter.start && data.chapter.end}
<Button href="/chapters/{data.chapter.id}/groups">
<Users class="mr-2 h-4 w-4" />
Voir les groupes
</Button>
{#if data.chapter.start && data.chapter.end}
<Button href="/chapters/{data.chapter.id}/leaderboard">
<BarChart2 class="mr-2 h-4 w-4" />
<BarChart class="mr-2 h-4 w-4" />
Voir le classement
</Button>
{/if}

View file

@ -1,45 +1,40 @@
import { API_URL } from '$env/static/private';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import type { Chapter, Group } from '$lib/types';
import { handleRedirect } from '$lib/utils';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ url, locals: { user }, fetch, params: { chapterId } }) => {
if (!user) redirect(302, handleRedirect('login', url));
let res = await fetch(`${API_URL}/chapter/${chapterId}`);
if (!res.ok) {
redirect(302, '/chapters');
}
if (!res.ok) redirect(302, `/chapters/${chapterId}`);
const chapter = (await res.json()) as Chapter;
const chapter: Chapter = await res.json()
if (!chapter || !chapter.show && !(chapter.start && chapter.end)) {
redirect(302, '/chapters');
}
if (!chapter || !chapter.show && !(chapter.start && chapter.end)) redirect(302, `/chapters/${chapterId}`)
res = await fetch(`${API_URL}/groups/${chapter.id}`);
if (!res.ok) {
redirect(302, `/chapters/${chapterId}`);
}
if (!res.ok) redirect(302, `/chapters/${chapterId}`);
const groups = (await res.json()) as Group[];
const groups: Group[] = await res.json();
return {
title: `${chapter.name} - Groups`,
title: `${chapter.name} - Groupes`,
chapter,
groups
};
};
export const actions: Actions = {
join: async ({ fetch, params: { chapterId }, request }) => {
join: async ({ fetch, params: { chapterId }, request, locals: { user } }) => {
if (!user) {
return fail(401);
}
const data = await request.formData();
@ -79,7 +74,8 @@ export const actions: Actions = {
message: "Une erreur s'est produite"
};
},
leave: async ({ fetch, params: { chapterId }, request }) => {
leave: async ({ fetch, params: { chapterId }, request, locals: { user } }) => {
if (!user) fail(401);
const data = await request.formData();

View file

@ -1,20 +1,18 @@
import { API_URL } from "$env/static/private";
import { fail, redirect, type Actions } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
import { fail, redirect } from "@sveltejs/kit";
import type { Actions, PageServerLoad } from "./$types";
import { superValidate } from "sveltekit-superforms";
import { setError, superValidate } from "sveltekit-superforms";
import { zod } from "sveltekit-superforms/adapters";
import { handleRedirect } from "$lib/utils";
import { groupSchema } from "$lib/validations/group";
export const load: PageServerLoad = async ({ url, params: { chapterId }, locals: { user } }) => {
if (!user) redirect(302, handleRedirect('login', url));
if (user.groups.find(g => g.chapter === parseInt(chapterId))) {
redirect(302, `/chapters/${chapterId}/groups`);
}
if (user.groups.find(g => g.chapter === parseInt(chapterId))) redirect(302, `/chapters/${chapterId}/groups`);
const form = await superValidate(zod(groupSchema));
@ -25,9 +23,10 @@ export const load: PageServerLoad = async ({ url, params: { chapterId }, locals:
};
export const actions: Actions = {
default: async ({ url, locals: { user }, fetch, request, params: { chapterId } }) => {
if (!user) redirect(302, handleRedirect('login', url));
default: async ({ fetch, request, params: { chapterId }, locals: { user } }) => {
if (!user) {
return fail(401);
}
if (!chapterId) redirect(302, '/chapters');
@ -46,16 +45,13 @@ export const actions: Actions = {
});
if (!res.ok) {
if (res.status === 403) {
form.errors.name = ["Vous êtes déjà dans un groupe"];
return setError(form, "name", "Vous êtes déjà dans un groupe");
} else if (res.status === 423) {
form.errors.name = ["Vous ne pouvez plus créer de groupe"];
} else {
form.errors.name = ["Une erreur est survenue, veuillez réessayer plus tard"];
return setError(form, "name", "Vous ne pouvez plus créer de groupe, la date limite est passée");
}
return fail(400, { form });
return setError(form, "name", "Une erreur est survenue, veuillez réessayer plus tard");
}
redirect(302, `/chapters/${chapterId}/groups`);

View file

@ -0,0 +1,32 @@
import { API_URL } from '$env/static/private';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import type { Chapter, LeaderboardEvent } from '$lib/types';
import { handleRedirect } from '$lib/utils';
export const load: PageServerLoad = async ({ url, locals: { user }, fetch, params: { chapterId } }) => {
if (!user) redirect(302, handleRedirect('login', url));
let res = await fetch(`${API_URL}/chapter/${chapterId}`)
if (!res.ok) redirect(302, `/chapters/${chapterId}`);
const chapter: Chapter = await res.json();
if (!(chapter.start && chapter.end)) redirect(302, `/chapters/${chapter.id}`);
res = await fetch(`${API_URL}/leaderboard/${chapter.id}`);
let leaderboard: LeaderboardEvent | undefined = undefined;
if (!res.ok) leaderboard = undefined;
leaderboard = await res.json();
return {
title: `${chapter.name} - Classement`,
leaderboard
};
};

View file

@ -1,5 +1,7 @@
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { derived } from 'svelte/store';
import type { PageData } from './$types';
import Code from 'lucide-svelte/icons/code';
@ -8,9 +10,25 @@
import Button from '$lib/components/ui/button/button.svelte';
import type { LeaderboardEvent } from '$lib/types';
import { createStateStore } from '$lib/stores/state';
import { connectWebSocket } from '$lib/stores/websocket';
export let data: PageData;
const SCORE_COLORS = ['text-yellow-400', 'text-gray-400', 'text-orange-400'];
const stateStore = createStateStore<LeaderboardEvent>();
if (data.leaderboard) stateStore.addRequest(data.leaderboard);
onMount(() => {
connectWebSocket(`/rleaderboard/${$page.params.chapterId}`, stateStore);
return () => stateStore.reset();
});
$: currentLeaderboard = derived(stateStore, ($stateStore) => $stateStore.requests.pop());
</script>
<section class="flex h-full w-full flex-col gap-4">
@ -28,7 +46,7 @@
</header>
<main class="pb-4">
<div class="overflow-x-auto">
<table class="w-full min-w-max table">
<table class="table min-w-full">
<thead
class="border-x border-b border-t border-border bg-card/50 text-sm text-muted-foreground"
>
@ -41,14 +59,20 @@
</tr>
</thead>
<tbody class="border-x border-b border-border bg-card align-middle">
{#if !data.leaderboard.groups.length}
{#if !$currentLeaderboard?.groups.length}
<tr>
<td colspan="5" class="text-center text-muted-foreground">
Aucun groupe n'a encore de score
Aucun groupe n'a encore été créé
</td>
</tr>
{:else if !$currentLeaderboard.groups.filter( (g) => g.players.reduce((a, b) => a + b.score, 0) ).length}
<tr>
<td colspan="5" class="text-center text-muted-foreground">
Aucune équipe n'a encore de score
</td>
</tr>
{:else}
{#each data.leaderboard.groups.filter( (g) => g.players.reduce((a, b) => a + b.score, 0) ) as group (group)}
{#each $currentLeaderboard.groups as group (group.name)}
<tr class={cn(SCORE_COLORS[group.rank - 1])}>
<td>{group.rank}</td>
<td>{group.name}</td>
@ -56,7 +80,7 @@
{#if group.players?.length}
<span>{group.players.map((player) => player?.pseudo).join(', ')} </span>
{:else}
<span class="text-muted">Aucun joueur</span>
<span class="text-muted-foreground">Aucun joueur</span>
{/if}
</td>
<td class="text-right">{group.players.reduce((a, b) => a + b.score, 0)}</td>

View file

@ -4,7 +4,7 @@ import type { PageServerLoad } from './$types';
import { handleRedirect } from '$lib/utils';
export const load = (async ({ url, locals: { user }, params: { chapterId } }) => {
export const load: PageServerLoad = async ({ url, locals: { user }, params: { chapterId } }) => {
if (!user) redirect(302, handleRedirect('login', url));
redirect(302, chapterId ? `/chapters/${chapterId}` : `/chapters`);
}) satisfies PageServerLoad;
redirect(302, isNaN(parseInt(chapterId)) ? `/chapters/${chapterId}` : `/chapters`);
}

View file

@ -1,13 +1,18 @@
import { API_URL } from '$env/static/private';
import { error, redirect, type Actions } from '@sveltejs/kit';
import { compile } from 'mdsvex';
import type { PageServerLoad } from './$types';
import { error, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import type Puzzle from '$lib/components/puzzle.svelte';
import type { Chapter } from '$lib/types';
import { superValidate } from 'sveltekit-superforms';
import { zod } from 'sveltekit-superforms/adapters';
import { compile } from 'mdsvex';
import type { Chapter, Puzzle } from '$lib/types';
import { handleRedirect } from '$lib/utils';
export const load = (async ({ url, locals: { user }, fetch, cookies, params: { chapterId, puzzleId } }) => {
import { formSchema } from './schema';
export const load: PageServerLoad = async ({ url, locals: { user }, fetch, cookies, params: { chapterId, puzzleId } }) => {
if (!user) redirect(302, handleRedirect('login', url));
const session = cookies.get('session');
@ -22,7 +27,7 @@ export const load = (async ({ url, locals: { user }, fetch, cookies, params: { c
redirect(302, `/chapters`);
}
const chapter = (await res.json()) as Chapter;
const chapter: Chapter = await res.json();
if (!chapter || !chapter.show) {
redirect(302, `/chapters`);
@ -55,20 +60,37 @@ export const load = (async ({ url, locals: { user }, fetch, cookies, params: { c
const content = await compile(puzzle.content);
if (content)
puzzle.content = content?.code
.replace(/>{@html `<code class="language-/g, '><code class="language-')
.replace(/<\/code>`}<\/pre>/g, '</code></pre>');
return {
title: `${chapter.name} - ${puzzle.name}`,
puzzle: puzzle as Puzzle,
puzzle,
url: `${API_URL}/puzzleResponse/${puzzleId}`,
session
session,
form: await superValidate(zod(formSchema))
};
}) satisfies PageServerLoad;
}
export const actions = {
default: async ({ params }) => {
redirect(302, `/chapters/${params.chapterId}/puzzle/${params.puzzleId}`);
export const actions: Actions = {
default: async ({ request, fetch, params: { chapterId, puzzleId } }) => {
const formData = await request.formData();
console.log(formData)
const res = await fetch(`${API_URL}/puzzleResponse/${puzzleId}`, {
method: 'POST',
body: formData
});
if (!res.ok) {
console.log(res)
return;
}
} satisfies Actions;
redirect(302, `/chapters/${chapterId}/puzzle/${puzzleId}`);
}
}

View file

@ -1,18 +1,20 @@
<script lang="ts">
import type { PageData } from './$types';
import './prism-night-owl.css';
import type { PageData } from './$types';
import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import Loader from 'lucide-svelte/icons/loader-circle';
import { toast } from 'svelte-sonner';
import CopyCodeInjector from '$lib/components/copy-code-injector.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner';
export let data: PageData;
$: puzzle = data.puzzle;
@ -27,13 +29,48 @@
<span class="text-lg text-muted-foreground">({puzzle.scoreMax} points)</span>
</h2>
<article
class="prose-invert prose-a:text-primary prose-pre:rounded h-screen max-w-none overflow-y-auto break-normal font-fira"
class="prose-invert h-screen max-w-none overflow-y-auto break-normal font-fira prose-a:text-primary prose-pre:rounded"
>
<CopyCodeInjector>
{@html puzzle.content}
</CopyCodeInjector>
</article>
{#if !puzzle.score}
<!-- <form
class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row"
method="POST"
enctype="multipart/form-data"
use:enhance
>
<div class="flex w-full flex-col gap-2 sm:flex-row sm:gap-4">
<Form.Field {form} name="answer">
<Form.Control let:attrs>
<Form.Label>Réponse</Form.Label>
<Input {...attrs} bind:value={$formData.answer} placeholder="CAPTAIN, LOOK!" />
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="code_file">
<Form.Control let:attrs>
<Form.Label>Code source</Form.Label>
<Input
{...attrs}
bind:value={$formData.code_file}
type="file"
placeholder=""
disabled
/>
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</div>
<Button type="submit" class="w-full sm:w-44" disabled={$delayed}>
{#if $delayed}
<Loader class="mr-2 h-4 w-4 animate-spin" />
{/if}
Valider
</Button>
</form> -->
<form
class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row"
method="POST"
@ -97,11 +134,6 @@
<div class="flex flex-col gap-y-2">
<label for="answer">Réponse</label>
<Input name="answer" type="text" placeholder="CAPTAIN, LOOK !" disabled={submitting} />
<!-- <textarea
class="flex h-10 w-full rounded-md border border-primary-600 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:bg-primary-800 focus-visible:outline-none text-primary:ring-2 focus-visible:text-primaryg8brand focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
name="answer"
placeholder="CAPTAIN, LOOK !"
/> -->
</div>
<div class="flex flex-col gap-y-2">
<label for="code_file">Fichier</label>

View file

@ -0,0 +1,10 @@
import { z } from "zod";
export const formSchema = z.object({
answer: z.string().min(1, { message: "Veuillez entrer une réponse." }),
code_file: z.instanceof(File, { message: "Veuillez envoyer votre code source." })
.refine((file) => file.size < 100_000, 'Votre fichier doit faire maximum 100 kB.')
.array()
});
export type FormSchema = typeof formSchema;

View file

@ -1,30 +0,0 @@
import { API_URL } from '$env/static/private';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import type { LeaderboardEvent } from '$lib/types';
import { handleRedirect } from '$lib/utils';
export const load: PageServerLoad = async ({ url, locals: { user }, fetch, params: { chapterId } }) => {
if (!user) redirect(302, handleRedirect('login', url));
if (!chapterId) redirect(302, '/');
const res = await fetch(`${API_URL}/leaderboard/${chapterId}`);
if (!res.ok) return {
leaderboard: []
}
const leaderboard = (await res.json()) as LeaderboardEvent;
if (!leaderboard) return {
leaderboard: []
}
return {
title: "Classement",
leaderboard
};
};

View file

@ -4,10 +4,10 @@ import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import type { Leaderboard } from '$lib/types';
import { handleRedirect } from '$lib/utils';
export const load = (async ({ url, locals: { user }, fetch }) => {
if (!user) redirect(302, handleRedirect('login', url));
const res = await fetch(`${API_URL}/leaderboard`);
@ -18,7 +18,7 @@ export const load = (async ({ url, locals: { user }, fetch }) => {
};
}
const leaderboard = (await res.json()) as Leaderboard[];
const leaderboard: Leaderboard[] = await res.json();
return {
title: 'Classement',

View file

@ -30,6 +30,11 @@
</tr>
</thead>
<tbody class="border-x border-b border-border bg-card align-middle">
{#if data.leaderboard.length === 0}
<tr>
<td colspan="5" class="text-center">Aucun joueur n'a encore joué</td>
</tr>
{:else}
{#each data.leaderboard as player (player)}
<tr class={cn([SCORE_COLORS[player.rank - 1]])}>
<td>{player.rank}</td>
@ -39,6 +44,7 @@
<td class="text-right">{player.tries}</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>

View file

@ -1,11 +1,12 @@
import { API_URL } from '$env/static/private';
import { fail, redirect, type Actions } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { zod } from 'sveltekit-superforms/adapters';
import { setError, superValidate } from 'sveltekit-superforms/server';
import { handleRedirect } from '$lib/utils';
import { settingSchema } from '$lib/validations/auth';
export const load: PageServerLoad = async ({ url, locals: { user } }) => {
@ -21,7 +22,6 @@ export const load: PageServerLoad = async ({ url, locals: { user } }) => {
export const actions: Actions = {
default: async ({ request, fetch, locals: { user } }) => {
if (!user) return fail(401);
const form = await superValidate(request, zod(settingSchema));
@ -41,6 +41,7 @@ export const actions: Actions = {
if (res.status === 400) {
return setError(form, "pseudo", "Ce pseudo est déjà utilisé");
}
return setError(form, "pseudo", "Une erreur est survenue lors de la sauvegarde des paramètres");
}

View file

@ -0,0 +1,7 @@
<script lang="ts">
import { Toaster } from '$lib/components/ui/sonner';
</script>
<slot />
<Toaster />

View file

@ -1,7 +1,7 @@
import { dev } from '$app/environment';
import { API_URL } from '$env/static/private';
import { fail, redirect, type Actions } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { zod } from 'sveltekit-superforms/adapters';
import { setError, superValidate } from 'sveltekit-superforms/server';
@ -9,7 +9,6 @@ import { setError, superValidate } from 'sveltekit-superforms/server';
import { loginSchema } from '$lib/validations/auth';
export const load: PageServerLoad = async ({ locals: { user } }) => {
if (user) redirect(302, '/');
const form = await superValidate(zod(loginSchema));
@ -54,8 +53,7 @@ export const actions: Actions = {
const redirectTo = searchParams.get('redirectTo');
if (redirectTo)
redirect(302, `/${redirectTo.slice(1)}`);
if (redirectTo) redirect(302, `/${redirectTo.slice(1)}`);
redirect(302, '/');
}

View file

@ -8,5 +8,5 @@ export const GET: RequestHandler = async ({ locals: { user }, cookies }) => {
if (session) cookies.delete("session", { path: "/" });
return redirect(302, "/login");
redirect(302, "/login");
};

View file

@ -78,6 +78,7 @@ export const actions: Actions = {
if (!email_valid) return setError(form, 'email', 'Un compte avec cette adresse email existe déjà');
if (!username_valid) return setError(form, 'pseudo', "Ce nom d'utilisateur est déjà utilisé");
}
return setError(form, 'code', "Une erreur est survenue lors de la confirmation");
}
@ -95,8 +96,7 @@ export const actions: Actions = {
const redirectTo = searchParams.get('redirectTo');
if (redirectTo)
redirect(302, `/${redirectTo.slice(1)}`);
if (redirectTo) redirect(302, `/${redirectTo.slice(1)}`);
redirect(302, '/');
}

View file

@ -1,16 +1,18 @@
import { dev } from '$app/environment';
import { API_URL } from '$env/static/private';
import { fail, redirect, type Actions } from '@sveltejs/kit';
import { fail, redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import type { Actions, PageServerLoad } from './$types';
import { zod } from 'sveltekit-superforms/adapters';
import { setError, superValidate } from 'sveltekit-superforms/server';
import { requestPasswordResetSchema, resetPasswordSchema } from '$lib/validations/auth';
export const load: PageServerLoad = async () => {
export const load: PageServerLoad = async ({ locals: { user } }) => {
if (user) redirect(302, '/');
const requestPasswordResetForm = await superValidate(zod(requestPasswordResetSchema));
const resetPasswordForm = await superValidate(zod(resetPasswordSchema));
@ -32,7 +34,7 @@ export const actions: Actions = {
const res = await fetch(`${API_URL}/user/fpw`, {
method: 'POST',
body: JSON.stringify({
...form.data
email: form.data.email
})
});
@ -60,7 +62,14 @@ export const actions: Actions = {
})
});
if (res.ok) {
if (!res.ok) {
if (res.status === 400) {
return setError(form, 'code', "Le code de confirmation est incorrect");
}
return setError(form, 'code', "Une erreur est survenue, veuillez réessayer plus tard");
}
const token = res.headers.get('Authorization')?.split('Bearer ').pop();
if (!token) {
@ -75,16 +84,8 @@ export const actions: Actions = {
const redirectTo = searchParams.get('redirectTo');
if (redirectTo)
redirect(302, `/${redirectTo.slice(1)}`);
if (redirectTo) redirect(302, `/${redirectTo.slice(1)}`);
redirect(302, '/');
}
if (res.status === 400) {
return setError(form, 'code', "Le code de confirmation est incorrect");
}
return setError(form, 'code', "Une erreur est survenue, veuillez réessayer plus tard");
}
}

View file

@ -9,6 +9,7 @@
import * as Form from '$lib/components/ui/form';
import Input from '$lib/components/ui/input/input.svelte';
import { requestPasswordResetSchema, resetPasswordSchema } from '$lib/validations/auth';
export let data: PageData;

View file

@ -1,8 +0,0 @@
import { redirect } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { siteConfig } from "$lib/config";
export const GET: RequestHandler = async () => {
redirect(303, siteConfig.links.discord);
};

View file

@ -1,8 +0,0 @@
import { redirect } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { siteConfig } from "$lib/config";
export const GET: RequestHandler = async () => {
redirect(303, siteConfig.links.github);
};

View file

@ -9,6 +9,7 @@
<h1 class="text-6xl font-bold text-red-500">Oops!</h1>
<p class="mt-4 text-xl">Apparement tu as navigué en eau trouble...</p>
<Button class="mt-4" href="/">Retour au port</Button>
<p class="mt-4 text-xs text-muted-foreground">{$page.error?.message}</p>
<p class="mt-4 text-xs text-muted-foreground">{$page.error?.errorId}</p>
</div>
</div>

View file

@ -2,11 +2,9 @@
import '../app.css';
import { Metadata } from '$lib/components';
import { Toaster } from '$lib/components/ui/sonner';
</script>
<Metadata />
<slot />
<Toaster />

View file

@ -59,9 +59,11 @@ const config = {
fira: ['Fira Code', ...fontFamily.sans]
}
},
},
future: {
hoverOnlyWhenSupported: true
}
hoverOnlyWhenSupported: true,
removeDeprecatedGapUtilities: true,
purgeLayersByDefault: true,
},
plugins: [
require('@tailwindcss/typography'),