feat: merge
12
.dockerignore
Normal 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
|
@ -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
|
@ -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
|
@ -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
|
@ -0,0 +1,2 @@
|
||||||
|
engine-strict=true
|
||||||
|
resolution-mode=highest
|
13
.prettierignore
Normal 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
|
@ -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
|
@ -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
|
@ -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 l’envie de coder et d’apprendre 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
|
@ -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
|
@ -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
6
postcss.config.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
103
src/app.css
Normal 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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -0,0 +1,6 @@
|
||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
16
src/lib/components/Avatar.svelte
Normal 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}
|
23
src/lib/components/Badge.svelte
Normal 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>
|
16
src/lib/components/Card.svelte
Normal 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>
|
42
src/lib/components/Chapter.svelte
Normal 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>
|
26
src/lib/components/Icons/AlignLeft.svelte
Normal 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
|
||||||
|
>
|
21
src/lib/components/Icons/Badge.svelte
Normal 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
|
||||||
|
>
|
20
src/lib/components/Icons/ChevronRight.svelte
Normal 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
|
||||||
|
>
|
21
src/lib/components/Icons/Code.svelte
Normal 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
|
||||||
|
>
|
33
src/lib/components/Icons/Dashboard.svelte
Normal 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
|
||||||
|
>
|
19
src/lib/components/Icons/Discord.svelte
Normal 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
|
||||||
|
>
|
23
src/lib/components/Icons/Git.svelte
Normal 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
|
||||||
|
>
|
23
src/lib/components/Icons/Help.svelte
Normal 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
|
||||||
|
>
|
25
src/lib/components/Icons/Leaderboard.svelte
Normal 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
|
||||||
|
>
|
25
src/lib/components/Icons/Mail.svelte
Normal 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
|
||||||
|
>
|
36
src/lib/components/Icons/Settings.svelte
Normal 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
|
||||||
|
>
|
20
src/lib/components/Icons/X.svelte
Normal 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
|
||||||
|
>
|
95
src/lib/components/Navbar.svelte
Normal 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>
|
82
src/lib/components/Puzzle.svelte
Normal 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>
|
220
src/lib/components/Sidenav.svelte
Normal 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>
|
55
src/lib/components/Toaster.svelte
Normal 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>
|
78
src/lib/components/ui/Button.svelte
Normal 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>
|
52
src/lib/components/ui/Input.svelte
Normal 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
|
@ -0,0 +1 @@
|
||||||
|
export * from './Utils';
|
17
src/lib/stores/Plausible.ts
Normal 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
|
@ -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
|
@ -0,0 +1 @@
|
||||||
|
export * from './Database';
|
7
src/routes/+layout.server.ts
Normal 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
|
@ -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
|
@ -0,0 +1,5 @@
|
||||||
|
import type { LayoutLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: LayoutLoad = async ({ data: { user } }) => {
|
||||||
|
return { user };
|
||||||
|
};
|
7
src/routes/+page.server.ts
Normal 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
|
@ -0,0 +1,2 @@
|
||||||
|
<script lang="ts">
|
||||||
|
</script>
|
6
src/routes/dashboard/+layout.server.ts
Normal 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');
|
||||||
|
};
|
22
src/routes/dashboard/+layout.svelte
Normal 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>
|
71
src/routes/dashboard/+page.svelte
Normal 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'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>
|
5
src/routes/dashboard/badges/+page.server.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load = (async ({ parent }) => {
|
||||||
|
await parent();
|
||||||
|
}) satisfies PageServerLoad;
|
25
src/routes/dashboard/badges/+page.svelte
Normal 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>
|
29
src/routes/dashboard/chapters/+page.server.ts
Normal 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;
|
34
src/routes/dashboard/chapters/+page.svelte
Normal 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>
|
32
src/routes/dashboard/chapters/[chapterId]/+page.server.ts
Normal 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;
|
24
src/routes/dashboard/chapters/[chapterId]/+page.svelte
Normal 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>
|
|
@ -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;
|
|
@ -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;
|
|
@ -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>
|
28
src/routes/dashboard/leaderboard/+page.server.ts
Normal 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;
|
75
src/routes/dashboard/leaderboard/+page.svelte
Normal 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>
|
31
src/routes/dashboard/settings/+page.server.ts
Normal 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;
|
67
src/routes/dashboard/settings/+page.svelte
Normal 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>
|
137
src/routes/forgot-password/+page.server.ts
Normal 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;
|
95
src/routes/forgot-password/+page.svelte
Normal 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>
|
13
src/routes/logout/+page.server.ts
Normal 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, '/');
|
||||||
|
};
|
1
src/routes/logout/+page.svelte
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<script lang="ts"></script>
|
58
src/routes/sign-in/+page.server.ts
Normal 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;
|
63
src/routes/sign-in/+page.svelte
Normal 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>
|
137
src/routes/sign-up/+page.server.ts
Normal 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;
|
133
src/routes/sign-up/+page.svelte
Normal 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>
|
BIN
static/assets/brand/peerat.png
Normal file
After Width: | Height: | Size: 1.2 MiB |
BIN
static/assets/icons/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
static/assets/icons/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 40 KiB |
BIN
static/assets/icons/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 9.9 KiB |
BIN
static/assets/icons/favicon-16x16.png
Normal file
After Width: | Height: | Size: 507 B |
BIN
static/assets/icons/favicon-32x32.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
static/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
static/fonts/FiraCode.woff2
Normal file
BIN
static/fonts/Karrik.woff2
Normal file
17
svelte.config.js
Normal 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
|
@ -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
|
@ -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
|
@ -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
|
@ -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}']
|
||||||
|
}
|
||||||
|
});
|