diff --git a/src/lib/components/chapter.svelte b/src/lib/components/chapter.svelte index f447d25..476b455 100644 --- a/src/lib/components/chapter.svelte +++ b/src/lib/components/chapter.svelte @@ -12,21 +12,21 @@ class={cn( 'group relative flex h-full w-full flex-col rounded border border-border bg-card transition-colors duration-150', { - 'hover:bg-card/80': chapter.show, - 'opacity-50': !chapter.show + 'hover:bg-card/80': chapter.show || (chapter.start && chapter.end), + 'opacity-50': !chapter.show && !(chapter.start && chapter.end) } )} > - {#if chapter.show} + {#if chapter.show || (chapter.start && chapter.end)}
{chapter.name} - {#if chapter.id === 1} + {#if chapter.start && chapter.end} {/if}
diff --git a/src/lib/components/layout/navbar/navbar-user.svelte b/src/lib/components/layout/navbar/navbar-user.svelte index 43b65bb..1222150 100644 --- a/src/lib/components/layout/navbar/navbar-user.svelte +++ b/src/lib/components/layout/navbar/navbar-user.svelte @@ -9,6 +9,9 @@ import LogOut from 'lucide-svelte/icons/log-out'; import Settings from 'lucide-svelte/icons/settings'; import Users from 'lucide-svelte/icons/users'; + import Shield from 'lucide-svelte/icons/shield'; + import ScrollText from 'lucide-svelte/icons/scroll-text'; + import Code from 'lucide-svelte/icons/code'; import * as Avatar from '$lib/components/ui/avatar'; import { Button } from '$lib/components/ui/button'; @@ -33,6 +36,29 @@ Salutation, {$page.data.user?.pseudo} + {#if $page.data.user?.email.endsWith('@peerat.dev')} + + + + + Administration + + + + + Logs + + + + Chapitres + + + + Puzzles + + + + {/if} @@ -43,10 +69,10 @@ Mes badges - + diff --git a/src/lib/types/database.ts b/src/lib/types/database.ts index d2267ad..d90eeb3 100644 --- a/src/lib/types/database.ts +++ b/src/lib/types/database.ts @@ -49,6 +49,8 @@ export interface Chapter { name: string; puzzles: Puzzle[]; show?: boolean; + start?: string; + end?: string; } export interface Tag { diff --git a/src/lib/validations/group.ts b/src/lib/validations/group.ts new file mode 100644 index 0000000..0d7a427 --- /dev/null +++ b/src/lib/validations/group.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const groupSchema = z.object({ + name: z.string({ + required_error: 'Un nom est requis', + }) + .min(1, 'Un nom est requis',), +}); diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 686bac7..f7c759e 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -2,6 +2,7 @@ import { navigating } from '$app/stores'; import { Loader, Navbar, Sidenav } from '$lib/components/layout'; + import { Toaster } from '$lib/components/ui/sonner'; {#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" > + diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte index d6d1d5c..01936b9 100644 --- a/src/routes/(app)/+page.svelte +++ b/src/routes/(app)/+page.svelte @@ -2,6 +2,7 @@ import type { PageData } from './$types'; import Card from '$lib/components/card.svelte'; + import Button from '$lib/components/ui/button/button.svelte'; export let data: PageData; @@ -29,7 +30,7 @@

{/if}
@@ -55,7 +52,7 @@

    {#if user?.completionsList?.length} diff --git a/src/routes/(app)/chapters/+page.svelte b/src/routes/(app)/chapters/+page.svelte index 10ce307..fe9efff 100644 --- a/src/routes/(app)/chapters/+page.svelte +++ b/src/routes/(app)/chapters/+page.svelte @@ -17,7 +17,7 @@ }; -
    +

    Chapitres

    @@ -27,7 +27,7 @@

    -
      +
        {#each chapters as chapter (chapter.id)} {/each} diff --git a/src/routes/(app)/chapters/[chapterId]/+page.server.ts b/src/routes/(app)/chapters/[chapterId]/+page.server.ts index 0a1bb24..02a9cbc 100644 --- a/src/routes/(app)/chapters/[chapterId]/+page.server.ts +++ b/src/routes/(app)/chapters/[chapterId]/+page.server.ts @@ -23,10 +23,12 @@ export const load = (async ({ locals: { user }, fetch, cookies, params: { chapte const chapter = (await res.json()) as Chapter; - if (!chapter || !chapter.show) { + if (!chapter || !chapter.show && !(chapter.start && chapter.end)) { redirect(302, '/chapters'); } + if (chapter?.puzzles?.length) chapter.puzzles = chapter.puzzles.sort((a, b) => a.scoreMax - b.scoreMax); + return { title: chapter.name, chapter diff --git a/src/routes/(app)/chapters/[chapterId]/+page.svelte b/src/routes/(app)/chapters/[chapterId]/+page.svelte index f5cf7c4..7e1230b 100644 --- a/src/routes/(app)/chapters/[chapterId]/+page.svelte +++ b/src/routes/(app)/chapters/[chapterId]/+page.svelte @@ -1,30 +1,52 @@ -
        -
        +
        +

        {data.chapter.name}

        -

        - Ils vous restent {data.chapter.puzzles.filter((p) => p.score).length} puzzles à résoudre sur - un total de {data.chapter.puzzles.length} -

        + {#if data.chapter?.puzzles?.length} +

        + Ils vous restent {data.chapter.puzzles.filter((p) => p.score).length} puzzles à résoudre sur + un total de {data.chapter.puzzles.length} +

        + {:else} +

        Le chapitre ne contient pour l'instant aucun puzzle

        + {/if} +
        +
        + + {#if data.chapter.start && data.chapter.end} + + {/if}
        - {#if data.chapter.id === 1} - - {/if}
        -
          - {#each data.chapter.puzzles as puzzle (puzzle.id)} - - {/each} +
            + {#if data.chapter?.puzzles?.length} + {#each data.chapter.puzzles as puzzle (puzzle.id)} + + {:else} +
          • +

            Aucun puzzle trouvé

            +
          • + {/each} + {/if}
        diff --git a/src/routes/(app)/chapters/[chapterId]/groups/+page.server.ts b/src/routes/(app)/chapters/[chapterId]/groups/+page.server.ts new file mode 100644 index 0000000..9a2b524 --- /dev/null +++ b/src/routes/(app)/chapters/[chapterId]/groups/+page.server.ts @@ -0,0 +1,142 @@ +import { API_URL } from '$env/static/private'; + +import type { Actions, PageServerLoad } from './$types'; + +import type { Chapter, Group } from '$lib/types'; +import { redirect } from '@sveltejs/kit'; + +export const load = (async ({ locals: { user }, fetch, cookies, params: { chapterId } }) => { + + if (!user) redirect(302, '/login'); + + const session = cookies.get('session'); + + let res = await fetch(`${API_URL}/chapter/${chapterId}`, { + headers: { + Authorization: `Bearer ${session}` + } + }); + + if (!res.ok) { + redirect(302, '/chapters'); + } + + const chapter = (await res.json()) as Chapter; + + if (!chapter || !chapter.show && !(chapter.start && chapter.end)) { + redirect(302, '/chapters'); + } + + res = await fetch(`${API_URL}/groups/${chapter.id}`, { + headers: + { + Authorization: `Bearer ${session}` + } + }); + + if (!res.ok) { + redirect(302, `/chapters/${chapterId}`); + } + + const groups = (await res.json()) as Group[]; + + return { + title: `${chapter.name} - Groups`, + chapter, + groups + }; +}) satisfies PageServerLoad; + + +export const actions: Actions = { + join: async ({ fetch, params: { chapterId }, cookies, request }) => { + + const data = await request.formData(); + + const name = data.get('name') as string; + + const session = cookies.get('session'); + + const res = await fetch(`${API_URL}/groupJoin`, { + method: 'POST', + headers: { + Authorization: `Bearer ${session}` + }, + body: JSON.stringify({ + name, + chapter: parseInt(chapterId), + }) + }); + + if (res.ok) { + return { + success: true, + message: 'Vous avez rejoint le groupe' + } + } + + if (res.status === 403) { + return { + success: false, + message: 'Vous êtes déjà dans un groupe' + }; + } + + if (res.status === 423) { + return { + success: false, + message: 'Vous ne pouvez plus rejoindre de groupe' + }; + } + + return { + success: false, + message: "Une erreur s'est produite" + }; + }, + leave: async ({ fetch, params: { chapterId }, cookies, request }) => { + + const data = await request.formData(); + + const name = data.get('name') as string; + + const session = cookies.get('session'); + + const res = await fetch(`${API_URL}/groupQuit`, { + method: 'POST', + headers: { + Authorization: `Bearer ${session}` + }, + body: JSON.stringify({ + name, + chapter: parseInt(chapterId), + }) + }); + + if (res.ok) { + return { + success: true, + message: 'Vous avez quitté le groupe' + } + } + + if (res.status === 403) { + return { + success: false, + message: "Vous n'êtes pas dans ce groupe" + }; + } + + if (res.status === 423) { + return { + success: false, + message: "Vous ne pouvez pas quitter ce groupe maintenant" + }; + } + + return { + success: false, + message: "Une erreur s'est produite" + }; + } +}; diff --git a/src/routes/(app)/chapters/[chapterId]/groups/+page.svelte b/src/routes/(app)/chapters/[chapterId]/groups/+page.svelte new file mode 100644 index 0000000..1ee5375 --- /dev/null +++ b/src/routes/(app)/chapters/[chapterId]/groups/+page.svelte @@ -0,0 +1,136 @@ + + +
        +
        +
        +

        {data.chapter.name}

        +

        + Vous pouvez créer ou rejoindre un groupe pour participer à la complétion de ce chapitre +

        +
        +
        + + +
        +
        +
        + +
        +
          + {#if filteredGroups.length === 0} +
        • +

          Aucun groupe trouvé

          +
        • + {:else} + {#each filteredGroups as group (group.name)} +
        • +
          +
          + + {group.name} + +
          +
          + {#if $page.data.user?.groups.some((g) => g.name === group.name)} +
          { + submitting = true; + return async ({ result, update }) => { + switch (result.type) { + case 'success': + await update({ invalidateAll: true }); + break; + } + submitting = false; + }; + }} + > + + +
          + {/if} + {#if !$page.data.user?.groups.some((g) => data.groups + .map((g) => g.name) + .includes(g.name))} +
          { + submitting = true; + return async ({ result, update }) => { + switch (result.type) { + case 'success': + await update({ invalidateAll: true }); + break; + } + submitting = false; + }; + }} + > + + +
          + {/if} +
          +
          +
        • + {/each} + {/if} +
        +
        diff --git a/src/routes/(app)/chapters/[chapterId]/groups/new/+page.server.ts b/src/routes/(app)/chapters/[chapterId]/groups/new/+page.server.ts new file mode 100644 index 0000000..7db9b8a --- /dev/null +++ b/src/routes/(app)/chapters/[chapterId]/groups/new/+page.server.ts @@ -0,0 +1,67 @@ +import { fail, redirect, type Actions } from "@sveltejs/kit"; +import type { PageServerLoad } from "./$types"; + +import { superValidate } from "sveltekit-superforms"; +import { zod } from "sveltekit-superforms/adapters"; + +import { API_URL } from "$env/static/private"; +import { groupSchema } from "$lib/validations/group"; + +export const load: PageServerLoad = async ({ params: { chapterId }, locals: { user } }) => { + + if (!user) redirect(302, '/login'); + + if (user.groups.find(g => g.chapter === parseInt(chapterId))) { + redirect(302, `/chapters/${chapterId}/groups`); + } + + const form = await superValidate(zod(groupSchema)); + + return { + title: 'Nouveau groupe', + form + }; +}; + +export const actions: Actions = { + default: async ({ locals: { user }, fetch, request, cookies, params: { chapterId } }) => { + + if (!user) redirect(302, '/login'); + + if (!chapterId) redirect(302, '/chapters'); + + const session = cookies.get('session'); + + const form = await superValidate(request, zod(groupSchema)); + + if (!form.valid) { + return fail(400, { form }); + } + + const res = await fetch(`${API_URL}/groupCreate`, { + method: 'POST', + headers: { + Authorization: `Bearer ${session}` + }, + body: JSON.stringify({ + ...form.data, + chapter: parseInt(chapterId) + }) + }); + + if (!res.ok) { + + if (res.status === 403) { + form.errors.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 fail(400, { form }); + } + + redirect(302, `/chapters/${chapterId}/groups`); + } +}; diff --git a/src/routes/(app)/chapters/[chapterId]/groups/new/+page.svelte b/src/routes/(app)/chapters/[chapterId]/groups/new/+page.svelte new file mode 100644 index 0000000..b9f8d36 --- /dev/null +++ b/src/routes/(app)/chapters/[chapterId]/groups/new/+page.svelte @@ -0,0 +1,43 @@ + + +
        +
        +

        Nouveau groupe

        +
        + + + Nom du groupe + + + + + + {#if $delayed} + + {/if} + Créer + +
        +
        +
        diff --git a/src/routes/(app)/chapters/[chapterId]/puzzle/[puzzleId]/+page.svelte b/src/routes/(app)/chapters/[chapterId]/puzzle/[puzzleId]/+page.svelte index dcb3e4b..960e2c9 100644 --- a/src/routes/(app)/chapters/[chapterId]/puzzle/[puzzleId]/+page.svelte +++ b/src/routes/(app)/chapters/[chapterId]/puzzle/[puzzleId]/+page.svelte @@ -45,6 +45,7 @@ toast.message('Réponse vide', { description: 'Vous devez entrer une réponse' }); + submitting = false; return cancel(); } @@ -107,7 +108,7 @@
- +
@@ -28,20 +40,28 @@ - {#each data.leaderboard.groups.filter( (g) => g.players.reduce((a, b) => a + b.score, 0) ) as group (group)} - - {group.rank} - - {#if group.players?.length} - {group.players.map((player) => player?.pseudo).join(', ')} - {:else} - Aucun joueur - {/if} - - {group.players.reduce((a, b) => a + b.score, 0)} - {group.players.reduce((a, b) => a + b.tries, 0)} + {#if !data.leaderboard.groups.length} + + Aucun groupe n'a encore de score - {/each} + {:else} + {#each data.leaderboard.groups.filter( (g) => g.players.reduce((a, b) => a + b.score, 0) ) as group (group)} + + {group.rank} + + {#if group.players?.length} + {group.players.map((player) => player?.pseudo).join(', ')} + {:else} + Aucun joueur + {/if} + + {group.players.reduce((a, b) => a + b.score, 0)} + {group.players.reduce((a, b) => a + b.tries, 0)} + + {/each} + {/if}
diff --git a/src/routes/(app)/settings/+layout.svelte b/src/routes/(app)/settings/+layout.svelte new file mode 100644 index 0000000..a858d9c --- /dev/null +++ b/src/routes/(app)/settings/+layout.svelte @@ -0,0 +1,25 @@ + + +
+
+
    + {#each routes as { name, href } (name)} +
  • + +
  • + {/each} +
+
+
+ +
+
diff --git a/src/routes/(app)/settings/+page.server.ts b/src/routes/(app)/settings/+page.server.ts index c1802e0..f44c18d 100644 --- a/src/routes/(app)/settings/+page.server.ts +++ b/src/routes/(app)/settings/+page.server.ts @@ -7,15 +7,9 @@ import { superValidate } from 'sveltekit-superforms/server'; import { z } from 'zod'; const settingSchema = z.object({ - firstname: z.string({ - required_error: 'Prénom manquant' - }), - lastname: z.string({ - required_error: 'Nom manquant' - }), - pseudo: z.string({ - required_error: 'Pseudo manquant' - }) + firstname: z.string().min(1, { message: "Prénom requis" }), + lastname: z.string().min(1, { message: "Nom requis" }), + pseudo: z.string().min(1, { message: "Nom d'utilisateur requi" }), }); export const load = (async ({ locals: { user } }) => { diff --git a/src/routes/(app)/settings/+page.svelte b/src/routes/(app)/settings/+page.svelte index aa6e548..3571914 100644 --- a/src/routes/(app)/settings/+page.svelte +++ b/src/routes/(app)/settings/+page.svelte @@ -5,9 +5,10 @@ import { toast } from 'svelte-sonner'; import { superForm } from 'sveltekit-superforms/client'; - import plausible from '$lib/stores/plausible'; - import Input from '$lib/components/ui/input/input.svelte'; import Button from '$lib/components/ui/button/button.svelte'; + import Input from '$lib/components/ui/input/input.svelte'; + + import plausible from '$lib/stores/plausible'; export let data: PageData; @@ -26,93 +27,80 @@ } }); - $: optedOut = $plausible; - // TODO: Handle overflow X on small screens -TODO +
+
+
+

Paramètres généraux

+ +
-
-
-
    -
  • - -
  • -
-
-
- -
-

Paramètres généraux

- -
+ + - - Prénom + + {#if $errors.firstname} + {$errors.firstname} + {/if} + + + + {#if $errors.lastname} + {$errors.lastname} + {/if} + + + + {#if $errors.pseudo} + {$errors.pseudo} + {/if} + +
+ + plausible.set(!$plausible)} + checked={$plausible} /> +
- - - {#if $errors.firstname} - {$errors.firstname} - {/if} - - - - {#if $errors.lastname} - {$errors.lastname} - {/if} - - - - {#if $errors.pseudo} - {$errors.pseudo} - {/if} - -
- - plausible.set(!optedOut)} - checked={optedOut} - /> -
- -

- Nous utilisons Plausible pour analyser l'utilisation de notre site web de manière anonyme. -

- -
-
+

+ Nous utilisons Plausible pour analyser l'utilisation de notre site web de manière anonyme. +

+ +
diff --git a/src/routes/(auth)/+layout.svelte b/src/routes/(auth)/+layout.svelte new file mode 100644 index 0000000..82a0c08 --- /dev/null +++ b/src/routes/(auth)/+layout.svelte @@ -0,0 +1,7 @@ + + + + + diff --git a/src/routes/(auth)/login/+page.svelte b/src/routes/(auth)/login/+page.svelte index 833571e..559135d 100644 --- a/src/routes/(auth)/login/+page.svelte +++ b/src/routes/(auth)/login/+page.svelte @@ -2,18 +2,18 @@ import type { PageData } from './$types'; import { Loader2 } from 'lucide-svelte'; - import { zod } from 'sveltekit-superforms/adapters'; + 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 { loginSchema } from '$lib/validations/auth'; export let data: PageData; const form = superForm(data.form, { - validators: zod(loginSchema), + validators: zodClient(loginSchema), delayMs: 500, multipleSubmits: 'prevent', onResult: ({ result, formElement }) => { @@ -28,7 +28,7 @@ const { form: formData, enhance, delayed } = form; -
+

Connexion

@@ -43,7 +43,12 @@ Mot de passe - + @@ -56,7 +61,9 @@
- + {#if $registerConfirmationDelayed} - + {/if} Continuer + + -
    -
  • - Se connecter -
  • -
  • - -
  • -
{:else}

Inscription

- - {#if $registerDelayed} - - {/if} - S'inscrire - +
+ + {#if $registerDelayed} + + {/if} + S'inscrire + +
- {/if} +
diff --git a/src/routes/(auth)/reset-password/+page.svelte b/src/routes/(auth)/reset-password/+page.svelte index 220c1a1..66d34e7 100644 --- a/src/routes/(auth)/reset-password/+page.svelte +++ b/src/routes/(auth)/reset-password/+page.svelte @@ -4,24 +4,29 @@ import { Loader2 } from 'lucide-svelte'; import { toast } from 'svelte-sonner'; + 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 { requestPasswordResetSchema } from '$lib/validations/auth'; export let data: PageData; + let confirmation = false; + const requestPasswordResetForm = superForm(data.requestPasswordResetForm, { + validators: zodClient(requestPasswordResetSchema), delayMs: 500, multipleSubmits: 'prevent', onResult({ result }) { switch (result.type) { case 'success': - toast.success('Demande de confirmation', { + toast('Demande de confirmation', { description: `Un code vous à été envoyé à ${$requestPasswordResetFormData.email}` }); resetPasswordFormData.set({ - email: $requestPasswordResetFormData.email, + ...$requestPasswordResetFormData, password: '', code: '' }); @@ -55,8 +60,6 @@ delayed: resetPasswordDelayed } = resetPasswordForm; - let confirmation = false; - export const snapshot: Snapshot = { capture: () => confirmation, restore: (value) => (confirmation = value) diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index d3cc7b6..c627082 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,11 +2,8 @@ import '../app.css'; import { Metadata } from '$lib/components'; - import { Toaster } from "$lib/components/ui/sonner"; - -
{data.daily.chapter.name} @@ -37,13 +38,9 @@ {data.daily.puzzle.name} ({data.daily.puzzle.score ?? '?'}/{data.daily.puzzle.scoreMax})
-
- - - {data.daily.puzzle.score ? 'Voir' : 'Jouer'} - - -
+