feat: merge

This commit is contained in:
Théo 2023-09-18 13:08:29 +02:00
parent 9ca74b794b
commit a0f028785a
88 changed files with 6037 additions and 0 deletions

12
.dockerignore Normal file
View file

@ -0,0 +1,12 @@
.git
node_modules
.eslint*
.prettier*
.git*
.vscode
README.md
Dockerfile*
docker-compose.yml
public
.svelte-kit
build

13
.eslintignore Normal file
View file

@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

30
.eslintrc.cjs Normal file
View file

@ -0,0 +1,30 @@
module.exports = {
root: true,
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:svelte/recommended',
'prettier'
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
extraFileExtensions: ['.svelte']
},
env: {
browser: true,
es2017: true,
node: true
},
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser'
}
}
]
};

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

2
.npmrc Normal file
View file

@ -0,0 +1,2 @@
engine-strict=true
resolution-mode=highest

13
.prettierignore Normal file
View file

@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

9
.prettierrc Normal file
View file

@ -0,0 +1,9 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"pluginSearchDirs": ["."],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

40
Dockerfile Normal file
View file

@ -0,0 +1,40 @@
FROM node:18-alpine AS base
RUN npm i -g pnpm
FROM base AS dependencies
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
FROM base AS build
WORKDIR /app
COPY . .
COPY --from=dependencies /app/node_modules ./node_modules
RUN pnpm build
RUN pnpm prune --prod
FROM base AS deploy
WORKDIR /app
COPY --from=build /app/build ./build
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./package.json
ARG PORT=3000
ENV NODE_ENV=production PORT=$PORT
EXPOSE $PORT
CMD ["node", "build"]

41
README.md Normal file
View file

@ -0,0 +1,41 @@
# Peer-at Code
Peer-at Code est un site web qui permet d'offrir un parcours amusant, le but étant de donner lenvie de coder et dapprendre par le jeu.
## Installation
1. Installer [Node.js](https://nodejs.org/en/download/) (v14.15.4 ou supérieur)
2. Installer [pnpm](https://pnpm.io/installation)
3. Exécuter `pnpm install` dans le dossier du projet pour installer les dépendances
4. Exécuter `pnpm dev` pour démarrer le serveur de développement
Ouvre [http://localhost:5173](http://localhost:5173) avec ton navigateur pour accéder au site.
## Déploiement
1. Exécuter `pnpm build` pour générer le site
2. Exécuter `node build` pour démarrer le serveur de production
Ouvre [http://localhost:3000](http://localhost:3000) avec ton navigateur pour accéder au site.
## Contribution
C'est un projet open-source, donc n'hésite pas à contribuer ! Voici quelques conseils pour contribuer :
1. Crée une branche pour tes modifications
2. Fais tes modifications
3. Crée une pull-request
Tu peux aussi créer une issue si tu as des questions ou des suggestions.
N'oublie pas de rejoindre le [serveur Discord](https://discord.gg/72vuHcwUkE) pour discuter avec nous !
## Licence
[GPU GPL V3.0](https://git.peerat.dev/Peer-at-Code/peer-at-code-web/src/branch/main/LICENSE)

55
package.json Normal file
View file

@ -0,0 +1,55 @@
{
"name": "peer-at-code",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"test": "npm run test:integration && npm run test:unit",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write .",
"test:integration": "playwright test",
"test:unit": "vitest"
},
"dependencies": {
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"lucide-svelte": "^0.279.0",
"marked": "^7.0.1",
"svelte-boring-avatars": "^1.2.4",
"tailwind-merge": "^1.14.0"
},
"devDependencies": {
"@melt-ui/pp": "^0.1.2",
"@melt-ui/svelte": "^0.50.0",
"@playwright/test": "^1.28.1",
"@sveltejs/adapter-auto": "^2.0.0",
"@sveltejs/adapter-node": "^1.3.1",
"@sveltejs/kit": "^1.20.4",
"@types/marked": "^5.0.1",
"@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0",
"autoprefixer": "^10.4.14",
"eslint": "^8.28.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-svelte": "^2.30.0",
"postcss": "^8.4.27",
"prettier": "^2.8.8",
"prettier-plugin-svelte": "^2.10.1",
"prettier-plugin-tailwindcss": "^0.4.1",
"svelte": "^4.0.5",
"svelte-check": "^3.4.3",
"svelte-sequential-preprocessor": "^2.0.1",
"sveltekit-superforms": "^1.7.0",
"tailwindcss": "^3.3.3",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^4.4.2",
"vitest": "^0.32.2",
"zod": "^3.21.4"
}
}

12
playwright.config.ts Normal file
View file

@ -0,0 +1,12 @@
import type { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
webServer: {
command: 'pnpm build && pnpm preview',
port: 4173
},
testDir: 'tests',
testMatch: /(.+\.)?(test|spec)\.[jt]s/
};
export default config;

2922
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

103
src/app.css Normal file
View file

@ -0,0 +1,103 @@
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
@font-face {
font-family: 'Karrik';
src: url('/fonts/Karrik.woff2');
}
@font-face {
font-family: 'Fira Code';
src: url('/fonts/FiraCode.woff2');
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--ring: 215 20.2% 65.1%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 85.7% 97.3%;
--ring: 217.2 32.6% 17.5%;
}
}
@layer base {
* {
@apply border-border text-white;
}
body {
@apply text-foreground;
}
}
@layer components {
.console {
@apply relative top-0.5 inline-block;
}
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
textarea:-webkit-autofill,
textarea:-webkit-autofill:hover,
textarea:-webkit-autofill:focus {
-webkit-box-shadow: 0 0 0px 1000px hsl(258deg 15% 17%) inset;
transition: background-color 5000s ease-in-out 0s;
}
}

19
src/app.d.ts vendored Normal file
View file

@ -0,0 +1,19 @@
// See https://kit.svelte.dev/docs/types#app
import type { User } from '$lib/types';
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
interface Locals {
user?: User;
}
interface PageData {
user?: User;
}
// interface Platform {}
}
}
export {};

31
src/app.html Normal file
View file

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="fr" class="scroll-smooth bg-gradient-to-b from-primary-800 to-primary-900">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/x-icon" href="%sveltekit.assets%/favicon.ico" />
<link
rel="apple-touch-icon"
sizes="180x180"
href="%sveltekit.assets%/assets/icons/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="%sveltekit.assets%/assets/icons/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="%sveltekit.assets%/assets/icons/favicon-16x16.png"
/>
<meta name="viewport" content="width=device-width" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="relative min-h-screen">
<main style="display: contents">%sveltekit.body%</main>
</body>
</html>

27
src/hooks.server.ts Normal file
View file

@ -0,0 +1,27 @@
import type { Handle } from '@sveltejs/kit';
import { API_URL } from '$env/static/private';
import type { User } from '$lib/types';
export const handle = (async ({ event, resolve }) => {
const session = event.cookies.get('session');
if (session) {
const res = await fetch(`${API_URL}/player/`, {
headers: {
Authorization: `Bearer ${session}`
}
});
if (res.ok) {
const user = (await res.json()) as User;
event.locals.user = user;
} else {
event.locals.user = undefined;
event.cookies.delete('session');
}
}
return await resolve(event);
}) satisfies Handle;

7
src/index.test.ts Normal file
View file

@ -0,0 +1,7 @@
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});

6
src/lib/Utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View file

@ -0,0 +1,16 @@
<script lang="ts">
import { page } from '$app/stores';
import Avatar from 'svelte-boring-avatars';
$: user = $page.data.user;
</script>
{#if user?.avatar}
<img
src="data:image;base64,${user.avatar}"
alt="Avatar de {user.pseudo}"
class="h-9 w-9 rounded-full object-cover"
/>
{:else}
<Avatar name={user?.pseudo} size={35} variant="beam" />
{/if}

View file

@ -0,0 +1,23 @@
<script lang="ts">
import { cn } from '$lib/Utils';
export let name: string;
export let src: string;
export let alt: string;
export let level: number;
</script>
<div class="flex w-24 flex-col space-y-2 text-center">
<img
src={`data:image;base64,${src}`}
{alt}
class={cn(`rounded-full border-2 lg:border-4`, {
'border-green-600': level === 1,
'border-yellow-600': level === 2,
'border-red-600': level === 3
})}
width={500}
height={500}
/>
<span class="text-sm font-semibold break-all">{name}</span>
</div>

View file

@ -0,0 +1,16 @@
<script lang="ts">
export let title: string;
export let data: any;
</script>
<div
class="flex w-full items-center space-x-4 rounded-lg border-2 border-highlight-primary bg-primary-700 p-4 shadow-md"
>
<!-- <Icon class="text-muted" size={30} /> -->
<div class="flex w-full items-center justify-between">
<div class="flex-col">
<h2 class="text-xl font-semibold">{data}</h2>
<p class="text-muted">{title}</p>
</div>
</div>
</div>

View file

@ -0,0 +1,42 @@
<script lang="ts">
import { cn } from '$lib/Utils';
import type { Chapter } from '$lib/types';
import ChevronRight from './Icons/ChevronRight.svelte';
export let chapter: Chapter;
</script>
<li
class={cn(
'font-code group relative flex h-full w-full rounded-md bg-primary-700 transition-colors duration-150 ',
{
'hover:bg-primary-600': chapter.show,
'opacity-50': !chapter.show
}
)}
>
{#if chapter.show}
<a
class="flex h-full w-full items-center gap-4 p-4"
href={chapter.show ? `/dashboard/chapters/${chapter.id}` : '#'}
>
<div class="flex w-full flex-col justify-between gap-2 sm:flex-row">
<h2 class="text-base font-semibold">
{chapter.name}
</h2>
</div>
<span class="translate-x-0 transform-gpu duration-300 group-hover:translate-x-2">
<ChevronRight />
</span>
</a>
{:else}
<span class="flex h-full w-full items-center gap-4 p-4">
<div class="flex w-full flex-col justify-between gap-2 sm:flex-row">
<h2 class="text-base font-semibold">
{chapter.name}
</h2>
</div>
</span>
{/if}
</li>

View file

@ -0,0 +1,26 @@
<script lang="ts">
import { cn } from '$lib';
let className: string | undefined | null = undefined;
export { className as class };
</script>
<svg
class={cn(className)}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><line x1="21" x2="3" y1="6" y2="6" /><line x1="15" x2="3" y1="12" y2="12" /><line
x1="17"
x2="3"
y1="18"
y2="18"
/></svg
>

View file

@ -0,0 +1,21 @@
<script lang="ts">
import { cn } from '$lib';
let className: string | undefined | null = undefined;
export { className as class };
</script>
<svg
class={cn(className)}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><circle cx="12" cy="8" r="6" /><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11" /></svg
>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { cn } from '$lib';
let className: string | undefined | null = undefined;
export { className as class };
</script>
<svg
class={cn(className)}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"><path d="m9 18 6-6-6-6" /></svg
>

View file

@ -0,0 +1,21 @@
<script lang="ts">
import { cn } from '$lib';
let className: string | undefined | null = undefined;
export { className as class };
</script>
<svg
class={cn(className)}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" /></svg
>

View file

@ -0,0 +1,33 @@
<script lang="ts">
import { cn } from '$lib';
let className: string | undefined | null = undefined;
export { className as class };
</script>
<svg
class={cn(className)}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><rect width="7" height="9" x="3" y="3" rx="1" /><rect
width="7"
height="5"
x="14"
y="3"
rx="1"
/><rect width="7" height="9" x="14" y="12" rx="1" /><rect
width="7"
height="5"
x="3"
y="16"
rx="1"
/></svg
>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { cn } from '$lib';
let className: string | undefined | null = undefined;
export { className as class };
</script>
<svg
class={cn(className)}
xmlns="http://www.w3.org/2000/svg"
height="24"
width="24"
viewBox="0 0 640 512"
fill="currentColor"
><path
d="M524.531,69.836a1.5,1.5,0,0,0-.764-.7A485.065,485.065,0,0,0,404.081,32.03a1.816,1.816,0,0,0-1.923.91,337.461,337.461,0,0,0-14.9,30.6,447.848,447.848,0,0,0-134.426,0,309.541,309.541,0,0,0-15.135-30.6,1.89,1.89,0,0,0-1.924-.91A483.689,483.689,0,0,0,116.085,69.137a1.712,1.712,0,0,0-.788.676C39.068,183.651,18.186,294.69,28.43,404.354a2.016,2.016,0,0,0,.765,1.375A487.666,487.666,0,0,0,176.02,479.918a1.9,1.9,0,0,0,2.063-.676A348.2,348.2,0,0,0,208.12,430.4a1.86,1.86,0,0,0-1.019-2.588,321.173,321.173,0,0,1-45.868-21.853,1.885,1.885,0,0,1-.185-3.126c3.082-2.309,6.166-4.711,9.109-7.137a1.819,1.819,0,0,1,1.9-.256c96.229,43.917,200.41,43.917,295.5,0a1.812,1.812,0,0,1,1.924.233c2.944,2.426,6.027,4.851,9.132,7.16a1.884,1.884,0,0,1-.162,3.126,301.407,301.407,0,0,1-45.89,21.83,1.875,1.875,0,0,0-1,2.611,391.055,391.055,0,0,0,30.014,48.815,1.864,1.864,0,0,0,2.063.7A486.048,486.048,0,0,0,610.7,405.729a1.882,1.882,0,0,0,.765-1.352C623.729,277.594,590.933,167.465,524.531,69.836ZM222.491,337.58c-28.972,0-52.844-26.587-52.844-59.239S193.056,219.1,222.491,219.1c29.665,0,53.306,26.82,52.843,59.239C275.334,310.993,251.924,337.58,222.491,337.58Zm195.38,0c-28.971,0-52.843-26.587-52.843-59.239S388.437,219.1,417.871,219.1c29.667,0,53.307,26.82,52.844,59.239C470.715,310.993,447.538,337.58,417.871,337.58Z"
/></svg
>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import { cn } from '$lib';
let className: string | undefined | null = undefined;
export { className as class };
</script>
<svg
class={cn(className)}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path
d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"
/><path d="M9 18c-4.51 2-5-2-7-2" /></svg
>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import { cn } from '$lib';
let className: string | undefined | null = undefined;
export { className as class };
</script>
<svg
class={cn(className)}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><circle cx="12" cy="12" r="10" /><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" /><path
d="M12 17h.01"
/></svg
>

View file

@ -0,0 +1,25 @@
<script lang="ts">
import { cn } from '$lib';
let className: string | undefined | null = undefined;
export { className as class };
</script>
<svg
class={cn(className)}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><line x1="18" x2="18" y1="20" y2="10" /><line x1="12" x2="12" y1="20" y2="4" /><line
x1="6"
x2="6"
y1="20"
y2="14"
/></svg
>

View file

@ -0,0 +1,25 @@
<script lang="ts">
import { cn } from '$lib';
let className: string | undefined | null = undefined;
export { className as class };
</script>
<svg
class={cn(className)}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M22 10.5V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v12c0 1.1.9 2 2 2h12.5" /><path
d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"
/><path d="M18 15.28c.2-.4.5-.8.9-1a2.1 2.1 0 0 1 2.6.4c.3.4.5.8.5 1.3 0 1.3-2 2-2 2" /><path
d="M20 22v.01"
/></svg
>

View file

@ -0,0 +1,36 @@
<script lang="ts">
import { cn } from '$lib';
let className: string | undefined | null = undefined;
export { className as class };
</script>
<svg
class={cn(className)}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><line x1="21" x2="14" y1="4" y2="4" /><line x1="10" x2="3" y1="4" y2="4" /><line
x1="21"
x2="12"
y1="12"
y2="12"
/><line x1="8" x2="3" y1="12" y2="12" /><line x1="21" x2="16" y1="20" y2="20" /><line
x1="12"
x2="3"
y1="20"
y2="20"
/><line x1="14" x2="14" y1="2" y2="6" /><line x1="8" x2="8" y1="10" y2="14" /><line
x1="16"
x2="16"
y1="18"
y2="22"
/></svg
>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { cn } from '$lib';
let className: string | undefined | null = undefined;
export { className as class };
</script>
<svg
class={cn(className)}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg
>

View file

@ -0,0 +1,95 @@
<script lang="ts">
import { page } from '$app/stores';
import Avatar from './Avatar.svelte';
import AlignLeft from './Icons/AlignLeft.svelte';
import X from './Icons/X.svelte';
export let isOpen: boolean;
$: user = $page.data.user;
$: segments = $page.url.pathname.slice(1).split('/');
$: breadcrumb = segments.map((segment, index) => {
return { name: segment, href: '/' + segments.slice(0, index + 1).join('/') };
}) as { name: string; href: string }[];
function handleToggle() {
isOpen = !isOpen;
}
</script>
<div
class="z-50 flex w-full flex-row items-center justify-between border-b border-solid border-highlight-primary px-4 py-4 sm:px-8"
>
<div class="flex flex-row items-center space-x-2 sm:space-x-0">
<div class="flex items-center">
<button on:click={handleToggle} class="block sm:hidden">
{#if isOpen}
<X class="h-5 w-5 text-muted" />
{:else}
<AlignLeft class="h-5 w-5 text-muted" />
{/if}
</button>
</div>
{#if !isOpen && segments.length}
<div class="hidden items-center justify-center capitalize text-highlight-secondary sm:flex">
{#each breadcrumb as segment}
{@const last = segment === breadcrumb[breadcrumb.length - 1]}
<a class="hover:text-primary hover:underline" href={segment.href}>
{segment.name}
</a>
{#if !last}
<span class="mx-1 text-highlight-secondary">/</span>
{/if}
{/each}
</div>
{/if}
<!-- {#if !isOpen && segment}
<div class="flex items-center justify-center capitalize text-highlight-secondary">
{segment}
</div>
{/if} -->
</div>
<div class="flex flex-row items-center gap-2">
{#if !isOpen}
<a href="/logout">
<button
class="flex items-center gap-1 rounded-md p-2 text-destructive hover:bg-destructive/10"
>
Se déconnecter
</button>
</a>
{/if}
<div class="flex flex-row items-center gap-2">
<Avatar />
{user?.pseudo}
</div>
<!-- {!isLoading && me ? (
<Popover
open={isMenuOpen}
onOpenChange={setIsMenuOpen}
trigger={
<button class="mx-auto flex items-center gap-2">
<AvatarComponent name={me.pseudo} src={me.avatar} class="h-9 w-9" />
<span>{me?.pseudo}</span>
</button>
}
>
<nav class="flex w-32 flex-col gap-2">
<button
class="flex items-center gap-1 p-2 text-destructive hover:bg-destructive/10"
onClick={() => router.push('/logout')}
>
Se déconnecter
</button>
</nav>
</Popover>
) : (
<div class="animate-pulse">
<div class="flex items-center gap-2">
<div class="h-9 w-9 rounded-full bg-highlight-primary" />
<div class="h-4 w-14 rounded-full bg-highlight-primary" />
</div>
</div>
)} -->
</div>
</div>

View file

@ -0,0 +1,82 @@
<script lang="ts">
import { page } from '$app/stores';
import { cn } from '$lib/Utils';
import type { Puzzle } from '$lib/types';
import ChevronRight from './Icons/ChevronRight.svelte';
export let puzzle: Puzzle;
const chapterId = $page.params.chapterId;
$: tags = puzzle.tags?.filter((tag) => !['easy', 'medium', 'hard'].includes(tag.name));
</script>
<li
class={cn(
'font-code group relative flex h-full w-full rounded-md border-2 bg-primary-700 transition-colors duration-150',
{
'border-green-600/30': puzzle.tags?.find((tag) => tag.name.toLowerCase() === 'easy'),
'border-yellow-600/30': puzzle.tags?.find((tag) => tag.name.toLowerCase() === 'medium'),
'border-red-600/30': puzzle.tags?.find((tag) => tag.name.toLowerCase() === 'hard'),
'border-highlight-primary': !puzzle.tags?.length,
'hover:bg-primary-600': puzzle.show,
'opacity-50': !puzzle.show
}
)}
>
{#if puzzle.show}
<a
class="flex h-full w-full items-center gap-4 p-4"
href="/dashboard/chapters/{chapterId}/puzzle/{puzzle.id}"
>
<div class="flex w-full flex-col justify-between gap-2 sm:flex-row">
<h2 class="text-base font-semibold">
{puzzle.name}
<span class="text-sm text-highlight-secondary">
({puzzle.score ? `${puzzle.score}` : '?'}/{puzzle.scoreMax} points)
</span>
</h2>
<div class="flex items-center gap-x-6">
{#if puzzle.tags?.length}
<div class="flex gap-x-2 text-sm">
{#each puzzle.tags as tag}
<span
class="inline-block rounded-md bg-primary-800 px-2 py-1 text-highlight-secondary"
>
{tag.name}
</span>
{/each}
</div>
{/if}
</div>
</div>
<span class="translate-x-0 transform-gpu duration-300 group-hover:translate-x-2">
<ChevronRight />
</span>
</a>
{:else}
<span class="flex h-full w-full items-center gap-4 p-4">
<div class="flex w-full flex-col justify-between gap-2 sm:flex-row">
<h2 class="text-base font-semibold">
{puzzle.name}
<span class="text-sm text-highlight-secondary">
({puzzle.score ? `${puzzle.score}` : '?'}/{puzzle.scoreMax} points)
</span>
</h2>
<div class="flex items-center gap-x-6">
{#if puzzle.tags?.length}
<div class="flex gap-x-2 text-sm">
{#each puzzle.tags as tag}
<span
class="inline-block rounded-md bg-primary-800 px-2 py-1 text-highlight-secondary"
>
{tag.name}
</span>
{/each}
</div>
{/if}
</div>
</div>
</span>
{/if}
</li>

View file

@ -0,0 +1,220 @@
<script lang="ts">
import { page } from '$app/stores';
import { cn } from '$lib/Utils';
import Badge from '$lib/components/Icons/Badge.svelte';
import Code from '$lib/components/Icons/Code.svelte';
import Dashboard from '$lib/components/Icons/Dashboard.svelte';
import Leaderboard from '$lib/components/Icons/Leaderboard.svelte';
import Settings from '$lib/components/Icons/Settings.svelte';
import Discord from './Icons/Discord.svelte';
import Git from './Icons/Git.svelte';
import Help from './Icons/Help.svelte';
import Mail from './Icons/Mail.svelte';
$: path = $page.url.pathname;
$: isActive = (slug: string) => path === slug;
export let isOpen: boolean;
const navItems = [
{
name: 'Dashboard',
slug: '/dashboard',
icon: Dashboard
},
{
name: 'Classement',
slug: '/dashboard/leaderboard',
icon: Leaderboard
},
{
name: 'Chapitres',
slug: '/dashboard/chapters',
icon: Code
},
{
name: 'Badges',
slug: '/dashboard/badges',
icon: Badge
},
{
name: 'Paramètres',
slug: '/dashboard/settings',
icon: Settings
}
];
</script>
<aside
class={cn(
'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 lg:w-60',
{
'bottom-0 -translate-x-full sm:translate-x-0': !isOpen,
'bottom-0 w-full sm:w-28': isOpen
}
)}
>
<div class="flex h-full flex-col">
<div class="flex w-full justify-center p-[8.5px]">
<img
src="/assets/brand/peerat.png"
alt="Logo"
width="50"
height="50"
loading="eager"
draggable="false"
/>
</div>
<div class="px-4">
<hr class="border-highlight-primary" />
</div>
<div class="px-4 pt-4">
<ul class="flex flex-col gap-4">
{#each navItems as item}
<li>
<a
on:click={() => {
isOpen = false;
}}
href={item.slug}
class={cn(
'flex justify-center rounded-md px-3 py-3 text-sm transition-colors duration-150 lg:justify-start',
{
'bg-primary-700': isActive(item.slug),
'group hover:bg-primary-700': !isActive(item.slug)
}
)}
>
<div class="flex items-center gap-2">
<svelte:component
this={item.icon}
class={cn({
'stroke-highlight-secondary transition-colors duration-150 group-hover:stroke-primary-0':
!isActive(item.slug)
})}
/>
<span
class={cn('hidden lg:block', {
'block sm:hidden': isOpen,
hidden: !isOpen,
'text-highlight-secondary transition-colors duration-150 group-hover:text-primary':
!isActive(item.slug)
})}
>
{item.name}
</span>
</div>
</a>
</li>
{/each}
</ul>
</div>
<div class="px-4 pt-4">
<hr class="border-highlight-primary" />
</div>
<div class="px-4 pt-4">
<ul class="flex flex-col gap-4">
<li>
<span
class="group pointer-events-none flex justify-center rounded-md px-3 py-3 text-sm opacity-50 transition-colors duration-150 lg:justify-start"
>
<div class="flex items-center gap-2">
<Help class="stroke-highlight-secondary transition-colors duration-150" />
<span
class={cn(
'hidden text-highlight-secondary transition-colors duration-150 lg:block',
{
'block sm:hidden': isOpen,
hidden: !isOpen
}
)}
>
Aide
</span>
</div>
</span>
</li>
<li>
<a
on:click={() => {
isOpen = false;
}}
href="mailto:cyberbottle@peerat.dev"
class="group flex justify-center rounded-md px-3 py-3 text-sm transition-colors duration-150 hover:bg-primary-700 lg:justify-start"
>
<div class="flex items-center gap-2">
<Mail
class="stroke-highlight-secondary transition-colors duration-150 group-hover:stroke-primary-0"
/>
<span
class={cn(
'hidden text-highlight-secondary transition-colors duration-150 group-hover:text-primary lg:block',
{
'block sm:hidden': isOpen,
hidden: !isOpen
}
)}
>
Mail
</span>
</div>
</a>
</li>
<li>
<a
on:click={() => {
isOpen = false;
}}
href="//discord.gg/72vuHcwUkE"
class="group flex justify-center rounded-md px-3 py-3 text-sm transition-colors duration-150 hover:bg-primary-700 lg:justify-start"
>
<div class="flex items-center gap-2">
<Discord
class="fill-highlight-secondary transition-colors duration-150 group-hover:fill-primary-0"
/>
<span
class={cn(
'hidden text-highlight-secondary transition-colors duration-150 group-hover:text-primary lg:block',
{
'block sm:hidden': isOpen,
hidden: !isOpen
}
)}
>
Discord
</span>
</div>
</a>
</li>
<li>
<a
on:click={() => {
isOpen = false;
}}
href="//git.peerat.dev"
class="group flex justify-center rounded-md px-3 py-3 text-sm transition-colors duration-150 hover:bg-primary-700 lg:justify-start"
>
<div class="flex items-center gap-2">
<Git
class="stroke-highlight-secondary transition-colors duration-150 group-hover:stroke-primary-0"
/>
<span
class={cn(
'hidden text-highlight-secondary transition-colors duration-150 group-hover:text-primary lg:block',
{
'block sm:hidden': isOpen,
hidden: !isOpen
}
)}
>
Git
</span>
</div>
</a>
</li>
</ul>
</div>
</div>
</aside>

View file

@ -0,0 +1,55 @@
<script lang="ts" context="module">
export type ToastData = {
title: string;
description: string;
};
const {
elements: { content, title, description, close },
helpers,
states: { toasts },
actions: { portal }
} = createToaster<ToastData>();
export const addToast = helpers.addToast;
</script>
<script lang="ts">
import { createToaster, melt } from '@melt-ui/svelte';
import { flip } from 'svelte/animate';
import { fly } from 'svelte/transition';
import { X } from 'lucide-svelte';
</script>
<div class="fixed right-0 top-0 z-50 m-4 flex flex-col items-end gap-2 sm:top-20" use:portal>
{#each $toasts as { id, data } (id)}
<div
use:melt={$content(id)}
animate:flip={{ duration: 500 }}
in:fly={{ duration: 150, x: '100%' }}
out:fly={{ duration: 150, x: '100%' }}
class="rounded-lg border border-primary-600 bg-highlight-primary text-white shadow-md"
>
<div
class="relative flex w-[24rem] max-w-[calc(100vw-2rem)] items-center justify-between gap-4 p-5"
>
<div>
<h3 use:melt={$title(id)} class="flex items-center gap-2 font-semibold">
{data.title}
<span class="square-1.5 rounded-full" />
</h3>
<div use:melt={$description(id)}>
{data.description}
</div>
</div>
<button
use:melt={$close(id)}
class="text-magnum-500 square-6 hover:bg-magnum-900/50 absolute right-4 top-4 grid place-items-center
rounded-full"
>
<X class="square-4" />
</button>
</div>
</div>
{/each}
</div>

View file

@ -0,0 +1,78 @@
<script lang="ts">
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { type VariantProps, cva } from 'class-variance-authority';
import { cn } from '$lib';
// TODO: Remove this
export 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 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: 'underline-offset-4 hover:underline text-primary',
brand: 'bg-gradient-to-tl from-brand to-brand-accent transition-opacity hover:opacity-90'
},
size: {
default: 'h-10 py-2 px-4',
sm: 'h-9 px-3 rounded-md',
lg: 'h-11 px-8 rounded-md'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
);
let className: string | undefined | null = undefined;
export { className as class };
export let href: HTMLAnchorAttributes['href'] = undefined;
export let type: HTMLButtonAttributes['type'] = undefined;
export let variant: VariantProps<typeof buttonVariants>['variant'] = 'default';
export let size: VariantProps<typeof buttonVariants>['size'] = 'default';
type Props = {
class?: string | null;
variant?: VariantProps<typeof buttonVariants>['variant'];
size?: VariantProps<typeof buttonVariants>['size'];
};
interface AnchorElement extends Props, HTMLAnchorAttributes {
href?: HTMLAnchorAttributes['href'];
type?: never;
}
interface ButtonElement extends Props, HTMLButtonAttributes {
type?: HTMLButtonAttributes['type'];
href?: never;
}
type $$Props = AnchorElement | ButtonElement;
</script>
<svelte:element
this={href ? 'a' : 'button'}
type={href ? undefined : type}
{href}
class={cn(buttonVariants({ variant, size, className }))}
{...$$restProps}
on:click
on:change
on:keydown
on:keyup
on:mouseenter
on:mouseleave
role="button"
tabindex="0"
>
<slot />
</svelte:element>

View file

@ -0,0 +1,52 @@
<script lang="ts">
import type { HTMLInputAttributes } from 'svelte/elements';
import { cn } from '$lib';
type FormInputEvent<T extends Event = Event> = T & {
currentTarget: EventTarget & HTMLInputElement;
};
type InputEvents = {
blur: FormInputEvent<FocusEvent>;
change: FormInputEvent<Event>;
click: FormInputEvent<MouseEvent>;
focus: FormInputEvent<FocusEvent>;
keydown: FormInputEvent<KeyboardEvent>;
keypress: FormInputEvent<KeyboardEvent>;
keyup: FormInputEvent<KeyboardEvent>;
mouseover: FormInputEvent<MouseEvent>;
mouseenter: FormInputEvent<MouseEvent>;
mouseleave: FormInputEvent<MouseEvent>;
paste: FormInputEvent<ClipboardEvent>;
input: FormInputEvent<InputEvent>;
};
type $$Props = HTMLInputAttributes;
type $$Events = InputEvents;
let className: $$Props['class'] = undefined;
export let value: $$Props['value'] = undefined;
export { className as class };
</script>
<input
class={cn(
'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 focus-visible:ring-2 focus-visible:ring-brand 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}
/>

1
src/lib/index.ts Normal file
View file

@ -0,0 +1 @@
export * from './Utils';

View file

@ -0,0 +1,17 @@
import { browser } from '$app/environment';
import { writable } from 'svelte/store';
const defaultValue = true;
const initialValue = browser
? window.localStorage.getItem('plausible_ignore') === 'true'
: defaultValue;
const plausible = writable<boolean>(initialValue);
plausible.subscribe((value) => {
if (browser) {
window.localStorage.setItem('plausible_ignore', value ? 'true' : 'false');
}
});
export default plausible;

84
src/lib/types/Database.ts Normal file
View file

@ -0,0 +1,84 @@
export interface User {
email: string;
pseudo: string;
firstname: string;
lastname: string;
description: string;
avatar: string;
groups: Group[];
score: number;
tries: number;
completions: number;
rank: number;
completionsList: Completion[];
badges: Badge[] | null;
}
export interface Badge {
name: string;
level: number;
logo: string;
}
export interface Completion {
puzzleName: string;
tries: number;
score: number;
}
export interface Group {
id: number;
name: string;
chapter?: number;
puzzle?: number;
}
export interface Puzzle {
id: number;
name: string;
content: string;
scoreMax: number;
show: boolean;
tags: Tag[] | null;
tries?: number;
score?: number;
}
export interface Chapter {
id: number;
name: string;
puzzles: Puzzle[];
show?: boolean;
}
export interface Tag {
name: string;
}
export interface Leaderboard {
score: number;
tries: number;
completions: number;
pseudo: string;
groups: Group[];
rank: number;
}
export interface LeaderboardEvent {
start_date: string;
end_date: string;
groups: [
{
name: string;
rank: number;
players: [
{
pseudo: string;
tries: number;
completion: number;
score: number;
}
];
}
];
}

1
src/lib/types/index.ts Normal file
View file

@ -0,0 +1 @@
export * from './Database';

View file

@ -0,0 +1,7 @@
import type { ServerLoad } from '@sveltejs/kit';
export const load: ServerLoad = async ({ locals: { user } }) => {
return {
user
};
};

41
src/routes/+layout.svelte Normal file
View file

@ -0,0 +1,41 @@
<script lang="ts">
import '../app.css';
import { page } from '$app/stores';
$: origin = $page.url.origin;
$: domain = $page.url.hostname;
</script>
<svelte:head>
<title>Peer-at Code</title>
<meta name="title" content="Peer-at Code" />
<meta name="description" content="Apprendre la programmation et la cybersécurité en s'amusant." />
<meta name="theme-color" content="#110F15" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="language" content="French" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content={origin} />
<meta property="og:title" content="Peer-at Code" />
<meta
property="og:description"
content="Apprendre la programmation et la cybersécurité en s'amusant."
/>
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={origin} />
<meta property="twitter:title" content="Peer-at Code" />
<meta
property="twitter:description"
content="Apprendre la programmation et la cybersécurité en s'amusant."
/>
<script defer data-domain={domain} src="https://plosibl.peerat.dev/js/script.js"></script>
</svelte:head>
<slot />

5
src/routes/+layout.ts Normal file
View file

@ -0,0 +1,5 @@
import type { LayoutLoad } from './$types';
export const load: LayoutLoad = async ({ data: { user } }) => {
return { user };
};

View file

@ -0,0 +1,7 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load = (async ({ locals: { user } }) => {
if (user) throw redirect(303, '/dashboard');
throw redirect(303, '/sign-in');
}) satisfies PageServerLoad;

2
src/routes/+page.svelte Normal file
View file

@ -0,0 +1,2 @@
<script lang="ts">
</script>

View file

@ -0,0 +1,6 @@
import { redirect, type ServerLoad } from '@sveltejs/kit';
export const load: ServerLoad = async ({ locals: { user }, parent }) => {
await parent();
if (!user) throw redirect(303, '/sign-in');
};

View file

@ -0,0 +1,22 @@
<script class="ts">
import Navbar from '$lib/components/Navbar.svelte';
import Sidenav from '$lib/components/Sidenav.svelte';
import Toaster from '$lib/components/Toaster.svelte';
let isOpen = false;
</script>
<div class="flex h-screen w-full flex-col">
<div class="flex flex-1 overflow-hidden">
<Sidenav bind:isOpen />
<div class="flex flex-1 flex-col">
<Navbar bind:isOpen />
<Toaster />
<div
class="flex w-full flex-1 transform flex-col overflow-y-scroll p-4 duration-300 ease-in-out sm:p-8"
>
<slot />
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,71 @@
<script lang="ts">
import { page } from '$app/stores';
import Card from '$lib/components/Card.svelte';
export let data;
$: user = data.user;
</script>
<section class="w-full flex-col space-y-4">
<header>
<h1 class="text-xl font-semibold">Tableau de bord</h1>
<p class="text-highlight-secondary">Ceci est la page d&apos;accueil du dashboard</p>
</header>
<main class="flex-col space-y-4">
<div
class="w-full flex-col justify-between space-x-0 space-y-4 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é'} />
</div>
<div class="grid grid-cols-1 gap-4">
<div class="flex flex-col space-y-4">
<header>
<h2 class="text-lg font-semibold">Derniers puzzles</h2>
<p class="text-highlight-secondary">
Voici les derniers puzzles que vous avez résolus ou essayer de résoudres
</p>
</header>
<div
class="h-full max-h-96 overflow-y-scroll rounded-lg border-2 border-highlight-primary bg-primary-700 p-4 shadow-md"
>
<ul class="flex flex-col space-y-2">
{#if user?.completionsList && user.completionsList.length > 0}
{#each 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">
<span class="text-lg">{completion.puzzleName}</span>
</div>
</div>
<div class="flex items-center space-x-4">
<div class="flex flex-col">
<span class="text-sm font-semibold">
Essai{completion.tries > 1 ? 's' : ''}
</span>
<span class="text-right text-lg text-highlight-secondary">
{completion.tries}
</span>
</div>
<div class="flex flex-col">
<span class="text-sm font-semibold">Score</span>
<span class="text-right text-lg text-highlight-secondary">
{completion.score}
</span>
</div>
</div>
</li>
{/each}
{:else}
<li class="m-auto flex items-center justify-center">
<span class="text-lg text-highlight-secondary"> Aucun puzzles </span>
</li>
{/if}
</ul>
</div>
</div>
</div>
</main>
</section>

View file

@ -0,0 +1,5 @@
import type { PageServerLoad } from './$types';
export const load = (async ({ parent }) => {
await parent();
}) satisfies PageServerLoad;

View file

@ -0,0 +1,25 @@
<script lang="ts">
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);
</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">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">
{#if badges && badges.length}
{#each badges as badge}
<Badge name={badge.name} src={badge.logo} alt={badge.name} level={badge.level} />
{/each}
{:else}
<p class="text-muted">Aucun badge</p>
{/if}
</div>
</main>
</section>

View file

@ -0,0 +1,29 @@
import { API_URL } from '$env/static/private';
import type { PageServerLoad } from './$types';
import type { Chapter } from '$lib/types';
export const load = (async ({ parent, fetch, cookies }) => {
await parent();
const session = cookies.get('session');
const res = await fetch(`${API_URL}/chapters`, {
headers: {
Authorization: `Bearer ${session}`
}
});
if (!res.ok) {
return {
chapters: []
};
}
const chapters = (await res.json()) as Chapter[];
return {
chapters
};
}) satisfies PageServerLoad;

View file

@ -0,0 +1,34 @@
<script lang="ts">
import type { PageData } from './$types';
import type { Chapter as IChapter } from '$lib/types';
import Chapter from '$lib/components/Chapter.svelte';
export let data: PageData;
$: chapters = data.chapters;
const toBeContinued: IChapter = {
id: Math.random() * 999,
name: 'To be continued ...',
puzzles: [],
show: false
};
</script>
<section class="flex w-full flex-col space-y-6">
<header class="sticky flex items-center justify-between">
<div class="flex flex-col">
<h1 class="text-xl font-semibold">Chapitres</h1>
<p class="text-muted">
Les chapitres sont les différentes parties du jeu. Chaque chapitre est composé de plusieurs
puzzles.
</p>
</div>
</header>
{#each chapters as chapter}
<Chapter {chapter} />
{/each}
<Chapter chapter={toBeContinued} />
</section>

View file

@ -0,0 +1,32 @@
import { API_URL } from '$env/static/private';
import type { PageServerLoad } from './$types';
import type { Chapter } from '$lib/types';
import { redirect } from '@sveltejs/kit';
export const load = (async ({ parent, fetch, cookies, params: { chapterId } }) => {
await parent();
const session = cookies.get('session');
const res = await fetch(`${API_URL}/chapter/${chapterId}`, {
headers: {
Authorization: `Bearer ${session}`
}
});
if (!res.ok) {
throw redirect(302, '/dashboard/chapters');
}
const chapter = (await res.json()) as Chapter;
if (!chapter || !chapter.show) {
throw redirect(302, '/dashboard/chapters');
}
return {
chapter
};
}) satisfies PageServerLoad;

View file

@ -0,0 +1,24 @@
<script lang="ts">
import type { PageData } from './$types';
import Puzzle from '$lib/components/Puzzle.svelte';
export let data: PageData;
data.chapter.puzzles = data.chapter.puzzles.sort((a, b) => a.scoreMax - b.scoreMax);
</script>
<section class="flex w-full flex-col space-y-6">
<div class="flex flex-col gap-4">
<div class="flex flex-col justify-between md:flex-row md:items-center">
<div class="flex items-center gap-2">
<h1 class="text-xl font-semibold">{data.chapter.name}</h1>
</div>
</div>
<ul class="flex flex-col gap-4">
{#each data.chapter.puzzles as puzzle}
<Puzzle {puzzle} />
{/each}
</ul>
</div>
</section>

View file

@ -0,0 +1,8 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load = (async ({ parent, params: { chapterId } }) => {
await parent();
throw redirect(303, chapterId ? `/dashboard/chapters/${chapterId}` : `/dashboard/chapters`);
}) satisfies PageServerLoad;

View file

@ -0,0 +1,80 @@
import { API_URL } from '$env/static/private';
import { error, redirect, type Actions, fail } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import type Puzzle from '$lib/components/Puzzle.svelte';
import type { Chapter } from '$lib/types';
import { superValidate } from 'sveltekit-superforms/server';
import { z } from 'zod';
const puzzleSchema = z.object({
// answer: z.string().trim(),
// answer need to be filled
answer: z
.string({
required_error: 'Réponse manquante'
})
.refine((val) => val.trim() !== '', {
message: 'Réponse manquante'
}),
file: z.any().optional()
});
export const load = (async ({ parent, fetch, cookies, params: { chapterId, puzzleId } }) => {
await parent();
const session = cookies.get('session');
if (isNaN(parseInt(puzzleId))) {
throw redirect(303, `/dashboard/chapters/${chapterId}`);
}
let res = await fetch(`${API_URL}/chapter/${chapterId}`, {
headers: {
Authorization: `Bearer ${session}`
}
});
if (!res.ok) {
throw redirect(303, `/dashboard/chapters`);
}
const chapter = (await res.json()) as Chapter;
if (!chapter || !chapter.show) {
throw redirect(303, `/dashboard/chapters`);
}
if (!chapter.puzzles.some((puzzle) => puzzle.id === parseInt(puzzleId))) {
throw redirect(303, `/dashboard/chapters/${chapterId}`);
}
res = await fetch(`${API_URL}/puzzle/${puzzleId}`, {
headers: {
Authorization: `Bearer ${session}`
}
});
if (!res.ok) {
throw error(404, 'Puzzle not found');
}
const puzzle = await res.json();
if (!puzzle) {
throw error(404, 'Puzzle not found');
}
return {
puzzle: puzzle as Puzzle,
url: `${API_URL}/puzzleResponse/${puzzleId}`,
session
};
}) satisfies PageServerLoad;
export const actions = {
default: async ({ params, request, cookies }) => {
throw redirect(303, `/dashboard/chapters/${params.chapterId}/puzzle/${params.puzzleId}`);
}
} satisfies Actions;

View file

@ -0,0 +1,158 @@
<script lang="ts">
import type { PageData } from './$types';
import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { marked, type MarkedOptions } from 'marked';
import { cn } from '$lib';
import { addToast } from '$lib/components/Toaster.svelte';
import Button from '$lib/components/ui/Button.svelte';
import Input from '$lib/components/ui/Input.svelte';
export let data: PageData;
$: puzzle = data.puzzle;
$: chapterId = $page.params.chapterId;
const renderer = new marked.Renderer();
renderer.link = (href, title, text) => {
const html = marked.Renderer.prototype.link.call(renderer, href, title, text);
return html.replace(
/^<a /,
'<a target="_blank" rel="nofollow" class="text-brand hover:text-brand/90" '
);
};
renderer.br = () => {
return '<br />';
};
renderer.codespan = (code) => {
return `<code class="cursor-default select-none text-transparent transition-colors delay-150 hover:text-highlight-secondary">${code}</code>`;
};
const options: MarkedOptions = {
breaks: true,
renderer,
gfm: true
};
</script>
<div class="flex h-full w-full flex-col justify-between gap-4">
<h1 class="text-2xl font-bold sm:text-3xl md:text-4xl">
{puzzle.name}
<span class="text-xl text-highlight-secondary">({puzzle.scoreMax} points)</span>
</h1>
<div class="h-screen w-full overflow-y-auto break-all font-fira text-xs sm:text-base">
{@html marked(puzzle.content, options)}
</div>
{#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={async ({ formData, cancel }) => {
if (formData.get('answer') === '') {
addToast({
data: {
title: 'Réponse vide',
description: 'Vous devez entrer une réponse'
},
closeDelay: 5000
});
return cancel();
}
const res = await fetch(data.url, {
method: 'POST',
body: formData,
headers: {
Authorization: `Bearer ${data.session}`
}
});
if (res.ok || res.status === 403 || res.status === 406 || res.status === 423) {
const data = res.ok || res.status === 406 ? await res.json() : null;
if (data && data.score) {
addToast({
data: {
title: 'Bravo !',
description: `Vous avez trouvé la bonne réponse en ${data.tries} tentative${
data.tries > 1 ? 's' : ''
} !`
}
});
} else if (data && data.tries)
addToast({
data: {
title: 'Mauvaise réponse',
description: `Vous avez effectué ${data.tries} tentative${
data.tries > 1 ? 's' : ''
} !`
}
});
else if (res.ok && data?.success)
addToast({
data: {
title: 'Bravo !',
description: `Vous avez trouvé la bonne réponse !`
}
});
else if (res.status === 423)
addToast({
data: {
title: 'Puzzle désactivé',
description: `Ce puzzle est désactivé pour le moment.`
}
});
}
return async ({ result }) => {
if (result.type === 'redirect') {
goto(result.location, {
invalidateAll: true
});
}
};
}}
>
<div class="flex w-full flex-col gap-2 sm:flex-row sm:gap-4">
<div class="flex flex-col gap-y-2">
<label for="answer">Réponse</label>
<textarea
class={cn(
'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 focus-visible:ring-2 focus-visible:ring-brand 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>
<Input name="code_file" type="file" accept=".py,.js,.ts,.java,.rs,.c" />
</div>
</div>
<Button class="w-full sm:w-44" variant="brand">Valider</Button>
</form>
{:else}
<div class="flex flex-col items-center justify-between gap-2 sm:flex-row">
<div class="flex items-center gap-2">
<p>
Tentative{puzzle.tries && puzzle.tries > 1 ? 's' : ''} :{' '}
<span class="text-brand-accent">{puzzle.tries}</span>
</p>
<p>
Score : <span class="text-brand-accent">{puzzle.score}</span>
</p>
</div>
<Button href="/dashboard/chapters/{chapterId}" class="w-full sm:w-44" variant="brand">
Retour aux puzzles
</Button>
</div>
{/if}
</div>

View file

@ -0,0 +1,28 @@
import { API_URL } from '$env/static/private';
import type { Leaderboard } from '$lib/types';
import type { PageServerLoad } from './$types';
export const load = (async ({ parent, fetch, cookies }) => {
await parent();
const session = cookies.get('session');
const res = await fetch(`${API_URL}/leaderboard`, {
headers: {
Authorization: `Bearer ${session}`
}
});
if (!res.ok) {
return {
leaderboard: [] as Leaderboard[]
};
}
const leaderboard = (await res.json()) as Leaderboard[];
return {
leaderboard
};
}) satisfies PageServerLoad;

View file

@ -0,0 +1,75 @@
<script lang="ts">
import { cn } from '$lib';
import type { PageData } from './$types';
export let data: PageData;
$: players = data.leaderboard;
const SCORE_COLORS = ['text-yellow-400', 'text-gray-400', 'text-orange-400'];
</script>
<section class="flex h-full w-full flex-col gap-4">
<header class="sticky flex items-center justify-between">
<div class="flex flex-col">
<h2 class="text-xl font-semibold">Tableau des scores</h2>
<p class="text-muted">Suivez la progression des élèves en direct</p>
</div>
</header>
<!-- <Separator /> -->
<main class="flex flex-col justify-between gap-4 pb-4">
<!-- {data && <Podium score={scores} />} -->
<!-- {data && data.end_date && (
<Timer class="flex justify-end" targetDate={new Date(data.end_date)} />
)} -->
<ul class="flex flex-col gap-2">
{#each players as player}
<!-- {@const players = group.players.sort((a, b) => b.score - a.score)} -->
<!-- {@const last = players[players.length - 1]} -->
<li class="flex justify-between gap-2">
<div class="flex items-center gap-4">
<span
class={cn('font-semibold text-highlight-secondary', SCORE_COLORS[player.rank - 1])}
>
{player.rank}
</span>
<div class="flex items-center gap-2">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<span class="text-lg">{player.pseudo}</span>
<span class="text-sm text-highlight-secondary">
<!-- {#if players.length > 1}
{#each players as player}
{player.pseudo || 'Anonyme'}{#if player !== last}, {/if}
{' '}
{/each}
{:else}
{player.players[0].pseudo}
{/if} -->
</span>
</div>
</div>
</div>
<div class="flex items-center gap-4">
<div class="flex flex-col">
<span class="text-sm font-semibold">Completion(s)</span>
<span class="text-lg text-highlight-secondary">{player.completions}</span>
<!-- <span class="text-sm font-semibold"
>Essai{group.players.reduce((a, b) => a + b.tries, 0) || 0 ? 's' : ''}</span
>
<span class="text-lg text-highlight-secondary"
>{group.players.reduce((a, b) => a + b.tries, 0) || 0}</span
> -->
</div>
<div class="flex flex-col">
<span class="text-sm font-semibold">Score</span>
<span class="text-lg text-highlight-secondary">{player.score}</span>
<!-- <span class="text-lg text-highlight-secondary">
{group.players.reduce((a, b) => a + b.score, 0)}
</span> -->
</div>
</div>
</li>
{/each}
</ul>
</main>
</section>

View file

@ -0,0 +1,31 @@
import type { Actions } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load = (async ({ parent }) => {
await parent();
}) satisfies PageServerLoad;
export const actions = {
default: async (event) => {
return {
success: true
};
// throw redirect(303, `/dashboard/puzzles/${id}`);
// if (res.ok) {
// const token = res.headers.get('Authorization')?.split(' ')[1];
// if (!token) throw new Error('No token found');
// event.cookies.set('session', token, {
// path: '/'
// });
// throw redirect(303, '/dashboard');
// }
// throw redirect(303, '/sign-in');
}
} satisfies Actions;

View file

@ -0,0 +1,67 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { page } from '$app/stores';
import type { ActionData } from './$types';
import Button from '$lib/components/ui/Button.svelte';
import Input from '$lib/components/ui/Input.svelte';
import plausible from '$lib/stores/Plausible';
$: user = $page.data.user;
export let form: ActionData;
$: optedOut = $plausible;
</script>
<form class="flex flex-col gap-4" method="POST" use:enhance>
<label for="email">Email</label>
<Input
name="email"
type="email"
placeholder="philipzcwbarlow@peerat.dev"
value={user?.email}
disabled
/>
<label for="firstname">Prénom</label>
<Input name="firstname" type="text" placeholder="Philip" value={user?.firstname} />
<label for="lastname">Nom</label>
<Input name="lastname" type="text" placeholder="Barlow" value={user?.lastname} />
<label for="pseudo"> Nom d'utilisateur </label>
<Input name="pseudo" type="text" placeholder="Cypher Wolf" value={user?.pseudo} />
<label for="description"> Description </label>
<Input
name="description"
placeholder="Je serai le plus grand pirate de l'espace"
type="text"
value={user?.description}
/>
<!-- TODO -->
<div class="flex items-center justify-between">
<label for="optout"> Ne pas me tracer de manière anonyme </label>
<input
class="h-4 w-4"
name="optout"
type="checkbox"
value={optedOut}
on:change={() => plausible.set(!optedOut)}
checked={optedOut}
/>
</div>
<p class="text-sm text-highlight-secondary">
Nous utilisons Plausible pour analyser l'utilisation de notre site web de manière anonyme.
</p>
<Button variant="brand">
<!-- {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} -->
Modifier
</Button>
</form>

View file

@ -0,0 +1,137 @@
import { API_URL } from '$env/static/private';
import { fail, redirect, type Actions } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { superValidate } from 'sveltekit-superforms/server';
import { z } from 'zod';
const forgotSchema = z.object({
email: z
.string()
.email({
message: 'Email invalide'
})
.trim(),
code: z
.string({
required_error: 'Code manquant'
})
.regex(/^[0-9]{4}$/, {
message: 'Code invalide, il doit contenir 4 chiffres'
})
.optional(),
passwd: z.string().optional()
});
const confirmationSchema = z.object({
email: z
.string()
.email({
message: 'Email invalide'
})
.trim(),
code: z
.string({
required_error: 'Code manquant'
})
.regex(/^[0-9]{4}$/, {
message: 'Code invalide, il doit contenir 4 chiffres'
}),
passwd: z.string()
});
export const load = (async ({ locals: { user } }) => {
if (user) throw redirect(303, '/dashboard');
const form = await superValidate(forgotSchema);
return {
form
};
}) satisfies PageServerLoad;
export const actions = {
forgot: async ({ request, cookies }) => {
const form = await superValidate(request, forgotSchema);
if (!form.valid) {
return fail(400, { form });
}
const data = {
email: form.data.email
} as Record<string, unknown>;
if (form.data.code) {
data.code = parseInt(form.data.code);
}
if (form.data.passwd) {
data.password = form.data.passwd;
}
const res = await fetch(`${API_URL}/user/fpw`, {
method: 'POST',
body: JSON.stringify(data)
});
console.log(res);
if (res.ok) {
const token = res.headers.get('Authorization')?.split('Bearer ')[1];
if (token) {
cookies.set('session', token, {
path: '/'
});
throw redirect(303, '/dashboard');
}
return {
form
};
}
form.errors.passwd = ['Code invalide ou expiré'];
return fail(400, {
form
});
},
confirmation: async ({ request, cookies }) => {
const form = await superValidate(request, confirmationSchema);
if (!form.valid) {
return fail(400, { form });
}
const res = await fetch(`${API_URL}/confirmation`, {
method: 'POST',
body: JSON.stringify({
email: form.data.email,
passwd: form.data.passwd,
code: parseInt(form.data.code)
})
});
if (res.ok) {
const token = res.headers.get('Authorization')?.split('Bearer ')[1];
if (!token) throw new Error('No token');
cookies.set('session', token, {
path: '/'
});
throw redirect(303, '/dashboard');
}
form.errors.code = [`Une erreur s'est produite (${res.status} ${res.statusText})`];
return fail(400, {
form
});
}
} satisfies Actions;

View file

@ -0,0 +1,95 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { fade } from 'svelte/transition';
import { superForm } from 'sveltekit-superforms/client';
import type { PageData, Snapshot } from './$types';
import Button from '$lib/components/ui/Button.svelte';
import Input from '$lib/components/ui/Input.svelte';
export let data: PageData;
const { form, errors, enhance } = superForm(data.form, {
onResult({ result }) {
switch (result.type) {
case 'success':
confirmation = true;
break;
case 'error':
confirmation = false;
break;
case 'redirect':
goto(result.location, {
replaceState: true
});
break;
}
}
});
let confirmation = false;
export const snapshot: Snapshot = {
capture: () => confirmation,
restore: (value) => (confirmation = value)
};
</script>
<div class="flex h-screen w-full">
<div class="flex w-full flex-col items-center justify-center">
<div class="flex w-full max-w-xs flex-col gap-4">
<h2 class="mx-auto text-xl font-bold">
{confirmation ? 'Changer le mot de passe' : 'Mot de passe oublié'}
</h2>
<form class="flex flex-col justify-center gap-2" method="POST" action="?/forgot" use:enhance>
<label for="email">Email</label>
<Input
bind:value={$form.email}
name="email"
type="email"
placeholder="philipzcwbarlow@peerat.dev"
autocomplete="off"
required
/>
{#if $errors.email}<span class="text-sm text-red-500">{$errors.email}</span>{/if}
{#if confirmation}
<div
class="flex flex-col gap-2"
transition:fade={{
duration: 300
}}
>
<label for="passwd"> Mot de passe </label>
<Input name="passwd" placeholder="************" type="password" />
{#if $errors.passwd}<span class="text-sm text-red-500">{$errors.passwd}</span>{/if}
<label for="code"> Code </label>
<Input name="code" placeholder="1234" type="text" />
{#if $errors.code}<span class="text-sm text-red-500">{$errors.code}</span>{/if}
</div>
{/if}
<Button class="mt-2" variant="brand">
<!-- {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} -->
{confirmation ? 'Modifier' : 'Envoyer le mail'}
</Button>
<ul class="flex justify-between">
<li>
<a class="text-highlight-secondary hover:text-brand" href="/sign-in">Se connecter</a>
</li>
{#if confirmation}
<li>
<button formaction="?/register" class="text-highlight-secondary hover:text-brand"
>Pas reçu ?</button
>
</li>
{/if}
</ul>
</form>
</div>
</div>
</div>

View file

@ -0,0 +1,13 @@
import { redirect, type ServerLoad } from '@sveltejs/kit';
export const load: ServerLoad = async ({ cookies, locals }) => {
const session = cookies.get('session');
if (session) {
cookies.delete('session', { path: '/' });
}
locals.user = undefined;
throw redirect(303, '/');
};

View file

@ -0,0 +1 @@
<script lang="ts"></script>

View file

@ -0,0 +1,58 @@
import { API_URL } from '$env/static/private';
import { redirect, type Actions, fail } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { superValidate } from 'sveltekit-superforms/server';
import { z } from 'zod';
const schema = z.object({
pseudo: z.string().trim(),
passwd: z.string()
});
export const load = (async ({ locals: { user } }) => {
if (user) throw redirect(303, '/dashboard');
const form = await superValidate(schema);
return {
form
};
}) satisfies PageServerLoad;
export const actions = {
default: async ({ request, cookies }) => {
const form = await superValidate(request, schema);
if (!form.valid) {
return fail(400, { form });
}
const res = await fetch(`${API_URL}/login`, {
method: 'POST',
body: JSON.stringify({
...form.data
})
});
if (res.ok) {
const token = res.headers.get('Authorization')?.split(' ')[1];
if (!token) throw new Error('No token found');
cookies.set('session', token, {
path: '/'
});
throw redirect(303, '/dashboard');
}
form.errors.passwd = ["Nom d'utilisateur ou mot de passe incorrect"];
return fail(400, {
form
});
}
} satisfies Actions;

View file

@ -0,0 +1,63 @@
<script lang="ts">
import type { PageData } from './$types';
import { goto } from '$app/navigation';
import { superForm } from 'sveltekit-superforms/client';
import Button from '$lib/components/ui/Button.svelte';
import Input from '$lib/components/ui/Input.svelte';
export let data: PageData;
const { form, errors, enhance } = superForm(data.form, {
onResult({ result }) {
switch (result.type) {
case 'redirect':
goto(result.location, {
replaceState: true
});
break;
}
}
});
</script>
<div class="flex h-screen w-full">
<div class="flex w-full flex-col items-center justify-center">
<div class="flex w-full max-w-xs flex-col gap-4">
<h1 class="mx-auto text-xl font-bold">Connexion</h1>
<form class="flex flex-col justify-center gap-2" method="POST" use:enhance>
<label for="pseudo"> Nom d'utilisateur </label>
<Input name="pseudo" placeholder="Barlow" type="text" required bind:value={$form.pseudo} />
{#if $errors.pseudo}<span class="text-sm text-red-500">{$errors.pseudo}</span>{/if}
<label for="passwd"> Mot de passe </label>
<Input
name="passwd"
placeholder="************"
type="password"
required
bind:value={$form.passwd}
/>
{#if $errors.passwd}<span class="text-sm text-red-500">{$errors.passwd}</span>{/if}
<Button class="mt-2" variant="brand">
<!-- {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} -->
Se connecter
</Button>
<ul class="flex justify-between">
<li>
<a class="text-highlight-secondary hover:text-brand" href="/sign-up">S'inscrire</a>
</li>
<li>
<a class="text-highlight-secondary hover:text-brand" href="/forgot-password"
>Mot de passe oublié</a
>
</li>
</ul>
</form>
</div>
</div>
</div>

View file

@ -0,0 +1,137 @@
import { API_URL } from '$env/static/private';
import { fail, redirect, type Actions } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { superValidate } from 'sveltekit-superforms/server';
import { z } from 'zod';
const registerSchema = z.object({
email: z
.string()
.email({
message: 'Email invalide'
})
.trim(),
firstname: z.string().trim(),
lastname: z.string().trim(),
pseudo: z.string().trim(),
code: z
.string({
required_error: 'Code manquant'
})
.length(4, {
message: 'Code invalide, il doit contenir 4 chiffres'
})
.regex(/^[0-9]+$/)
.optional(),
passwd: z.string().optional()
});
const confirmationSchema = z.object({
email: z
.string()
.email({
message: 'Email invalide'
})
.trim(),
firstname: z.string().trim(),
lastname: z.string().trim(),
pseudo: z.string().trim(),
code: z
.string({
required_error: 'Code manquant'
})
.regex(/^[0-9]{4}$/, {
message: 'Code invalide, il doit contenir 4 chiffres'
}),
passwd: z.string()
});
export const load = (async ({ locals: { user } }) => {
if (user) throw redirect(303, '/dashboard');
const form = await superValidate(registerSchema);
return {
form
};
}) satisfies PageServerLoad;
export const actions = {
register: async ({ request }) => {
const form = await superValidate(request, registerSchema);
if (!form.valid) {
return fail(400, { form });
}
const res = await fetch(`${API_URL}/register`, {
method: 'POST',
body: JSON.stringify({
pseudo: form.data.pseudo,
firstname: form.data.firstname,
lastname: form.data.lastname,
email: form.data.email
})
});
if (res.ok) {
return {
form
};
}
if (res.status === 400) {
const { email_valid } = await res.json();
if (!email_valid) form.errors.email = ['Un compte avec cette adresse email existe déjà'];
return fail(400, { form });
}
form.errors.passwd = ["Une erreur s'est produite"];
return fail(400, {
form
});
},
confirmation: async ({ request, cookies }) => {
const form = await superValidate(request, confirmationSchema);
if (!form.valid) {
return fail(400, { form });
}
const res = await fetch(`${API_URL}/confirmation`, {
method: 'POST',
body: JSON.stringify({
firstname: form.data.firstname,
lastname: form.data.lastname,
pseudo: form.data.pseudo,
email: form.data.email,
code: parseInt(form.data.code),
passwd: form.data.passwd
})
});
if (res.ok) {
const token = res.headers.get('Authorization')?.split('Bearer ')[1];
if (!token) throw new Error('No token');
cookies.set('session', token, {
path: '/'
});
throw redirect(303, '/dashboard');
}
form.errors.code = [`Une erreur s'est produite (${res.status} ${res.statusText})`];
return fail(400, {
form
});
}
} satisfies Actions;

View file

@ -0,0 +1,133 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { fade } from 'svelte/transition';
import { superForm } from 'sveltekit-superforms/client';
import type { PageData, Snapshot } from './$types';
import Button from '$lib/components/ui/Button.svelte';
import Input from '$lib/components/ui/Input.svelte';
export let data: PageData;
const { form, errors, enhance } = superForm(data.form, {
onResult({ result }) {
switch (result.type) {
case 'success':
confirmation = true;
break;
case 'error':
confirmation = false;
break;
case 'redirect':
goto(result.location, {
replaceState: true
});
break;
}
}
});
let confirmation = false;
export const snapshot: Snapshot = {
capture: () => confirmation,
restore: (value) => (confirmation = value)
};
</script>
<div class="flex h-screen w-full">
<div class="flex w-full flex-col items-center justify-center">
<div class="flex w-full max-w-xs flex-col gap-4">
<h2 class="mx-auto text-xl font-bold">
{confirmation ? 'Confirmation' : 'Inscription'}
</h2>
<form
class="flex flex-col justify-center gap-2"
method="POST"
action={confirmation ? '?/confirmation' : '?/register'}
use:enhance
>
<label for="email">Email</label>
<Input
bind:value={$form.email}
name="email"
type="email"
placeholder="philipzcwbarlow@peerat.dev"
autocomplete="off"
required
/>
{#if $errors.email}<span class="text-sm text-red-500">{$errors.email}</span>{/if}
<label for="firstname">Prénom</label>
<Input
bind:value={$form.firstname}
name="firstname"
type="text"
placeholder="Philip"
autocomplete="off"
required
/>
{#if $errors.firstname}<span class="text-sm text-red-500">{$errors.firstname}</span>{/if}
<label for="lastname">Nom</label>
<Input
bind:value={$form.lastname}
name="lastname"
type="text"
placeholder="Barlow"
autocomplete="off"
required
/>
{#if $errors.lastname}<span class="text-sm text-red-500">{$errors.lastname}</span>{/if}
<label for="pseudo"> Nom d'utilisateur </label>
<Input
bind:value={$form.pseudo}
name="pseudo"
type="text"
placeholder="Cypher Wolf"
autocomplete="off"
required
/>
{#if $errors.pseudo}<span class="text-sm text-red-500">{$errors.pseudo}</span>{/if}
{#if confirmation}
<div
class="flex flex-col gap-2"
transition:fade={{
duration: 300
}}
>
<label for="passwd"> Mot de passe </label>
<Input name="passwd" placeholder="************" type="password" />
{#if $errors.passwd}<span class="text-sm text-red-500">{$errors.passwd}</span>{/if}
<label for="code"> Code </label>
<Input name="code" placeholder="1234" type="text" />
{#if $errors.code}<span class="text-sm text-red-500">{$errors.code}</span>{/if}
</div>
{/if}
<Button class="mt-2" variant="brand">
<!-- {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} -->
{confirmation ? "S'inscrire" : 'Continuer'}
</Button>
<ul class="flex justify-between">
<li>
<a class="text-highlight-secondary hover:text-brand" href="/sign-in">Se connecter</a>
</li>
{#if confirmation}
<li>
<button formaction="?/register" class="text-highlight-secondary hover:text-brand"
>Pas reçu ?</button
>
</li>
{/if}
</ul>
</form>
</div>
</div>
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 507 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
static/fonts/FiraCode.woff2 Normal file

Binary file not shown.

BIN
static/fonts/Karrik.woff2 Normal file

Binary file not shown.

17
svelte.config.js Normal file
View file

@ -0,0 +1,17 @@
import { preprocessMeltUI } from '@melt-ui/pp';
import sequence from 'svelte-sequential-preprocessor';
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/kit/vite';
/** @type {import('@sveltejs/kit').Config}*/
const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: sequence([vitePreprocess(), preprocessMeltUI()]),
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;

117
tailwind.config.js Normal file
View file

@ -0,0 +1,117 @@
import { fontFamily } from 'tailwindcss/defaultTheme';
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {
fontFamily: {
sans: ['Karrik', ...fontFamily.sans],
fira: ['Fira Code', ...fontFamily.sans]
},
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
// primary: {
// DEFAULT: 'hsl(var(--primary))',
// foreground: 'hsl(var(--primary-foreground))'
// },
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
primary: {
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: '#5049ca',
accent: '#913fb6'
},
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%)'
}
},
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%)'
}
}
},
plugins: []
};

51
tests/index.test.ts Normal file
View file

@ -0,0 +1,51 @@
import { expect, test } from '@playwright/test';
test('index page redirects to login page', async ({ page }) => {
await page.goto('/');
await page.waitForURL('/sign-in');
await expect(page.url()).toContain('/sign-in');
});
test('sign-in page has a sign-up link that redirects to the sign-up page', async ({ page }) => {
await page.goto('/sign-in');
const link = await page.$('a[href*="/sign-up"]');
await link?.click();
await page.waitForURL('/sign-up');
await expect(page.url()).toContain('/sign-up');
});
test('sign-up page has a sign-in link that redirects to the sign-in page', async ({ page }) => {
await page.goto('/sign-up');
const link = await page.$('a[href*="/sign-in"]');
await link?.click();
await page.waitForURL('/sign-in');
await expect(page.url()).toContain('/sign-in');
});
test('dashboard page redirects to sign-in page if user is not logged in', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.url()).toContain('/sign-in');
});
test('login form accepts valid credentials', async ({ page }) => {
await page.context().clearCookies();
await page.goto('/sign-in');
await page.fill('input[name="pseudo"]', 'glazk0');
await page.fill('input[name="passwd"]', 'Cookies Are #Miam42');
await Promise.all([page.getByRole('button').click(), page.waitForURL('/dashboard')]);
await expect(page.url()).toContain('/dashboard');
});

17
tsconfig.json Normal file
View file

@ -0,0 +1,17 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

9
vite.config.ts Normal file
View file

@ -0,0 +1,9 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [sveltekit()],
test: {
include: ['src/**/*.{test,spec}.{js,ts}']
}
});