ui: Almost all interface done with Svelte
32
frontend/ui/package-lock.json
generated
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "~TODO~",
|
||||
"name": "fic-frontend",
|
||||
"version": "0.0.1",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
@ -83,6 +83,12 @@
|
||||
"integrity": "sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w==",
|
||||
"dev": true
|
||||
},
|
||||
"@popperjs/core": {
|
||||
"version": "2.9.3",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.9.3.tgz",
|
||||
"integrity": "sha512-xDu17cEfh7Kid/d95kB6tZsLOmSWKCZKtprnhVepjsSaCij+lM3mItSJDuuHDMbCWTh8Ejmebwb+KONcCJ0eXQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@rollup/pluginutils": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.1.1.tgz",
|
||||
@ -93,6 +99,11 @@
|
||||
"picomatch": "^2.2.2"
|
||||
}
|
||||
},
|
||||
"@sveltejs/adapter-static": {
|
||||
"version": "1.0.0-next.17",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-1.0.0-next.17.tgz",
|
||||
"integrity": "sha512-RKYNkQxtsMgt0wD8PhfXR1hGT1Tmq1E5eZeTr1KxIerczITRnWVT8LElfu/9Kusv44yYlyQtNc1mLoYqgloOQw=="
|
||||
},
|
||||
"@sveltejs/kit": {
|
||||
"version": "1.0.0-next.156",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-1.0.0-next.156.tgz",
|
||||
@ -185,6 +196,16 @@
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true
|
||||
},
|
||||
"bootstrap": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.1.0.tgz",
|
||||
"integrity": "sha512-bs74WNI9BgBo3cEovmdMHikSKoXnDgA6VQjJ7TyTotU6L7d41ZyCEEelPwkYEzsG/Zjv3ie9IE3EMAje0W9Xew=="
|
||||
},
|
||||
"bootstrap-icons": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.5.0.tgz",
|
||||
"integrity": "sha512-44feMc7DE1Ccpsas/1wioN8ewFJNquvi5FewA06wLnqct7CwMdGDVy41ieHaacogzDqLfG8nADIvMNp9e4bfbA=="
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
@ -1103,6 +1124,15 @@
|
||||
"integrity": "sha512-pDrzgcWSoMaK6AJkBWkmgIsecW0GChxYZSZieIYfCP0v2oPyx2CYU/zm7TBIcjLVUPP714WxmViE9Thht4etog==",
|
||||
"dev": true
|
||||
},
|
||||
"sveltestrap": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/sveltestrap/-/sveltestrap-5.6.2.tgz",
|
||||
"integrity": "sha512-g+WhMNsQjdGtSIMGmfVrMVP5Pz0d+o2L49GdTZnKnd9CEeZHcgWObn7+DDnAIxxjQUsaCgdZPgPVSD8/bU8/zA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@popperjs/core": "^2.9.2"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/table/-/table-6.7.1.tgz",
|
||||
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "~TODO~",
|
||||
"name": "fic-frontend",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "svelte-kit dev",
|
||||
@ -9,13 +9,20 @@
|
||||
"format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@popperjs/core": "^2.9.3",
|
||||
"@sveltejs/kit": "next",
|
||||
"eslint": "^7.22.0",
|
||||
"eslint-config-prettier": "^8.1.0",
|
||||
"eslint-plugin-svelte3": "^3.2.0",
|
||||
"prettier": "~2.2.1",
|
||||
"prettier-plugin-svelte": "^2.2.0",
|
||||
"svelte": "^3.34.0"
|
||||
"svelte": "^3.34.0",
|
||||
"sveltestrap": "^5.6.2"
|
||||
},
|
||||
"type": "module"
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@sveltejs/adapter-static": "^1.0.0-next.17",
|
||||
"bootstrap": "^5.1.0",
|
||||
"bootstrap-icons": "^1.5.0"
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico">
|
||||
<meta name="author" content="EPITA Laboratoire SRS">
|
||||
<meta name="robots" content="all">
|
||||
%svelte.head%
|
||||
</head>
|
||||
<body>
|
||||
|
57
frontend/ui/src/components/CardTheme.svelte
Normal file
@ -0,0 +1,57 @@
|
||||
<script>
|
||||
import {
|
||||
Alert,
|
||||
Card,
|
||||
CardBody,
|
||||
CardTitle,
|
||||
Col,
|
||||
Icon,
|
||||
Row,
|
||||
} from 'sveltestrap';
|
||||
|
||||
export { className as class };
|
||||
export let theme = {};
|
||||
export let exercice = null;
|
||||
let className = '';
|
||||
</script>
|
||||
|
||||
<div class="theme-card h-100">
|
||||
<Card
|
||||
class="text-light h-100 rounded-3 niceborder {className}"
|
||||
color="dark"
|
||||
on:click
|
||||
>
|
||||
{#if theme.image}
|
||||
<div class="card-img-top" style="background-image: url({ theme.image.substr(0, theme.image.length-3) }thumb.jpg)"></div>
|
||||
{/if}
|
||||
<CardBody class="text-indent">
|
||||
<CardTitle class="fw-bolder">
|
||||
{#if exercice}{exercice.title}{:else}{theme.name}{/if}
|
||||
</CardTitle>
|
||||
<p class="card-text text-justify">{#if exercice}{@html exercice.headline}{:else}{@html theme.headline}{/if}</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.theme-card {
|
||||
cursor: pointer;
|
||||
transition: transform 250ms;
|
||||
}
|
||||
.theme-card:hover {
|
||||
transform: scale(1.07);
|
||||
}
|
||||
|
||||
.card-img-top {
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
}
|
||||
.theme-card .card-img-top {
|
||||
height: 10rem;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #ccc;
|
||||
}
|
||||
</style>
|
46
frontend/ui/src/components/ExerciceDownloads.svelte
Normal file
@ -0,0 +1,46 @@
|
||||
<script>
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
CardText,
|
||||
Icon,
|
||||
ListGroup,
|
||||
ListGroupItem,
|
||||
} from 'sveltestrap';
|
||||
|
||||
export let files = [];
|
||||
</script>
|
||||
|
||||
{#if files.length}
|
||||
<Card class="mb-2">
|
||||
<CardHeader>
|
||||
<Icon name="download" />
|
||||
Téléchargements
|
||||
</CardHeader>
|
||||
<CardBody class="text-indent">
|
||||
<CardText class="text-danger text-justify">
|
||||
<strong>Attention :</strong> puisqu'il s'agit de captures effectuées dans le but de découvrir si des actes malveillants ont été commis, les contenus qui sont téléchargeables <em>peuvent</em> contenir du contenu malveillant !
|
||||
</CardText>
|
||||
</CardBody>
|
||||
<ListGroup>
|
||||
{#each files as file (file.id)}
|
||||
<ListGroupItem tag="a" href="{file.path}" target="_self" class="d-flex">
|
||||
<h1><Icon name="arrow-down-circle" /></h1>
|
||||
<div>
|
||||
<h4 class="fw-bold"><samp>{file.name}</samp></h4>
|
||||
<nobr>
|
||||
Taille :
|
||||
<span title="{file.size} octets">{file.size}</span>
|
||||
</nobr>
|
||||
–
|
||||
<nobr>
|
||||
<span title="blake2.net">b2sum</span> :
|
||||
<samp class="cksum" title="{file.checksum}">{file.checksum}</samp>
|
||||
</nobr>
|
||||
</div>
|
||||
</ListGroupItem>
|
||||
{/each}
|
||||
</ListGroup>
|
||||
</Card>
|
||||
{/if}
|
74
frontend/ui/src/components/ExerciceFlags.svelte
Normal file
@ -0,0 +1,74 @@
|
||||
<script>
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
CardText,
|
||||
Icon,
|
||||
ListGroup,
|
||||
ListGroupItem,
|
||||
} from 'sveltestrap';
|
||||
|
||||
export let exercice = {};
|
||||
export let flags = [];
|
||||
|
||||
function submitFlags(event) {
|
||||
console.log(event);
|
||||
}
|
||||
|
||||
let sberr = "";
|
||||
let message = "";
|
||||
</script>
|
||||
|
||||
<Card class="border-danger mb-2">
|
||||
<CardHeader class="bg-danger">
|
||||
<Icon name="flag-fill" />
|
||||
Faire son rapport
|
||||
</CardHeader>
|
||||
{#if exercice.tries || exercice.submitted || sberr}
|
||||
<ListGroup>
|
||||
{#if exercice.solved_time && exercice.tries}
|
||||
<ListGroupItem class="text-warning">
|
||||
{exercice.tries} {exercice.tries==1?"tentative effectuée":"tentatives effectuées"}.
|
||||
Dernière solution envoyée à {exercice.solved_time}.
|
||||
</ListGroupItem>
|
||||
{/if}
|
||||
{#if exercice.solve_dist}
|
||||
<ListGroupItem>
|
||||
{exercice.solve_dist} {exercice.solve_dist == 1?"réponse erronée":"réponses erronées"}.
|
||||
</ListGroupItem>
|
||||
{/if}
|
||||
{#if exercice.submitted || sberr}
|
||||
<ListGroupItem>
|
||||
{#if !sberr}
|
||||
<strong>Votre solution a bien été envoyée !</strong>
|
||||
{:else}
|
||||
<strong>{sberr}</strong> {message}
|
||||
{/if}
|
||||
</ListGroupItem>
|
||||
{/if}
|
||||
{#if exercice.timeouted}
|
||||
<ListGroupItem class="text-danger">
|
||||
<strong>Oops</strong>
|
||||
La requête a dépassé le délai d'attente. Vous devriez réessayer dans quelques instant…
|
||||
</ListGroupItem>
|
||||
{/if}
|
||||
</ListGroup>
|
||||
{/if}
|
||||
{#if !exercice.submitted || sberr}
|
||||
<CardBody>
|
||||
<form on:submit|preventDefault={submitFlags}>
|
||||
{JSON.stringify(flags)}
|
||||
<div class="form-group mt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
color="danger"
|
||||
>
|
||||
Soumettre
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
{/if}
|
||||
</Card>
|
70
frontend/ui/src/components/ExerciceHints.svelte
Normal file
@ -0,0 +1,70 @@
|
||||
<script>
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
CardText,
|
||||
Icon,
|
||||
ListGroup,
|
||||
ListGroupItem,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { settings } from '../stores/settings.js';
|
||||
|
||||
export let hints = [];
|
||||
|
||||
let hinterror = "";
|
||||
</script>
|
||||
|
||||
{#if hints.length}
|
||||
<Card class="mb-2">
|
||||
<CardHeader class="bg-info">
|
||||
<Icon name="lightbulb-fill" />
|
||||
Indices
|
||||
</CardHeader>
|
||||
{#if hinterror}
|
||||
<CardBody>
|
||||
<CardText class="text-danger">
|
||||
{hinterror}
|
||||
</CardText>
|
||||
</CardBody>
|
||||
{/if}
|
||||
<ListGroup>
|
||||
{#each hints as hint (hint.id)}
|
||||
<ListGroupItem tag="a" href="{hint.file}" target="_self" class="d-flex">
|
||||
{#if hint.file}
|
||||
<h1><Icon name="arrow-down-circle" /></h1>
|
||||
{/if}
|
||||
<div>
|
||||
{#if !(hint.content || hint.file)}
|
||||
<button type="button" ng-click="hsubmit(hint)" class="float-end btn btn-info" class:disabled={hint.submitted}>
|
||||
<Icon name="lock" aria-hidden="true" />
|
||||
Débloquer
|
||||
</button>
|
||||
{/if}
|
||||
{#if !hint.file && hint.hidden}
|
||||
<button type="button" ng-click="hint.hidden = false;" class="float-end btn btn-info">
|
||||
<Icon name="lock" aria-hidden="true" />
|
||||
Afficher
|
||||
</button>
|
||||
{/if}
|
||||
<h4 class="fw-bold">{hint.name}</h4>
|
||||
{#if hint.file}
|
||||
<p>
|
||||
Cliquez ici pour télécharger l'indice.<br>
|
||||
b2sum :
|
||||
<samp class="cksum" title="Somme de contrôle BLAKE2b : {hint.content}">{hint.content}</samp>
|
||||
</p>
|
||||
{:else if hint.content && !hint.hidden}
|
||||
<p>{@html hint.content}</p>
|
||||
{:else}
|
||||
<p>
|
||||
Débloquer cet indice vous fera perdre {hint.cost * settings.hintCurrentCoefficient} {hint.cost * settings.hintCurrentCoefficient==1?"point":"points"}.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</ListGroupItem>
|
||||
{/each}
|
||||
</ListGroup>
|
||||
</Card>
|
||||
{/if}
|
41
frontend/ui/src/components/ExerciceSolved.svelte
Normal file
@ -0,0 +1,41 @@
|
||||
<script>
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
CardText,
|
||||
Icon,
|
||||
ListGroup,
|
||||
ListGroupItem,
|
||||
} from 'sveltestrap';
|
||||
|
||||
export let theme = {};
|
||||
export let exercice = {};
|
||||
</script>
|
||||
|
||||
<Card class="border-success mb-2">
|
||||
<CardHeader class="bg-success">
|
||||
<Icon name="flag-fill" />
|
||||
Défi réussi !
|
||||
</CardHeader>
|
||||
<CardBody class="text-indent">
|
||||
<CardText>
|
||||
{#if exercice.solved_rank}
|
||||
Vous êtes la {exercice.solved_rank}<sup>{exercice.solved_rank==1?"re":"e"}</sup> équipe à avoir résolu ce défi à {exercice.solved_time}.
|
||||
{:else}
|
||||
Bravo, vous avez résolu ce défi à {exercice.solved_time}.
|
||||
{/if}
|
||||
Vous avez marqué {exercice.gain} {exercice.gain==1?"point":"points"} !
|
||||
</CardText>
|
||||
{#if exercice.finished}
|
||||
<hr>
|
||||
<CardText>{@html exercice.finished}</CardText>
|
||||
{#if exercice.next}
|
||||
<hr>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if theme.exercices[exercice.id].next}
|
||||
<a href="/{theme.urlid}/{theme.exercices[theme.exercices[exercice.id].next].urlid}" class="btn btn-success">Passer au défi suivant</a>
|
||||
{/if}
|
||||
</CardBody>
|
||||
</Card>
|
26
frontend/ui/src/components/ExerciceVideo.svelte
Normal file
@ -0,0 +1,26 @@
|
||||
<script>
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
CardText,
|
||||
Icon,
|
||||
ListGroup,
|
||||
ListGroupItem,
|
||||
} from 'sveltestrap';
|
||||
|
||||
export let uri = "";
|
||||
</script>
|
||||
|
||||
<Card class="border-success mb-2">
|
||||
<CardHeader class="bg-success">
|
||||
<Icon name="laptop-fill" />
|
||||
Solution du défi
|
||||
</CardHeader>
|
||||
<CardBody class="text-indent">
|
||||
<div class="embed-responsive embed-responsive-16by9">
|
||||
<iframe type="text/html" src="{uri}" class="embed-responsive-item" title="Vidéo de résolution">
|
||||
Regardez la vidéo de résolution de ce défi : <a href="{uri}">{uri}</a>.
|
||||
</iframe>
|
||||
</CardBody>
|
||||
</Card>
|
47
frontend/ui/src/components/FormIssue.svelte
Normal file
@ -0,0 +1,47 @@
|
||||
<script>
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { issues, issues_idx } from '../stores/issues.js';
|
||||
|
||||
export let exercice = null;
|
||||
export let issue = { };
|
||||
</script>
|
||||
|
||||
<form on:submit|preventDefault>
|
||||
{#if exercice || issue.id_exercice}
|
||||
<div class="row mb-3">
|
||||
<label for="idExercice" class="col-sm-2 col-form-label">Défi</label>
|
||||
<div class="col-sm-10">
|
||||
{#if exercice.id}
|
||||
<input type="text" readonly class="form-control-plaintext" id="idExercice" value={exercice.title}>
|
||||
{:else}
|
||||
<input type="text" readonly class="form-control-plaintext" id="idExercice" value="{issue.id_exercice}">
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="row mb-3">
|
||||
<label for="subject" class="col col-form-label">Objet</label>
|
||||
<div class="col-sm-10">
|
||||
{#if issue.id && $issues_idx[issue.id]}
|
||||
<input type="text" readonly class="form-control-plaintext" id="subject" value="Re: {$issues_idx[issue.id].subject}">
|
||||
{:else}
|
||||
<input type="text" class="form-control" id="subject" bind:value={issue.subject} placeholder="Intitulé succinct">
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="description" class="col col-form-label">Description</label>
|
||||
<div class="col-sm-10">
|
||||
<textarea class="form-control" id="description" bind:value={issue.description} placeholder="Décrivez en détail votre problème ici. Si nécessaire, incluez un lien vers une capture d'écran montrant votre problème."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" color="warning" class="float-end">
|
||||
Envoyer le rapport
|
||||
</Button>
|
||||
</form>
|
112
frontend/ui/src/components/Header.svelte
Normal file
@ -0,0 +1,112 @@
|
||||
<script>
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Col,
|
||||
Collapse,
|
||||
Container,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownToggle,
|
||||
Icon,
|
||||
Navbar,
|
||||
NavbarToggler,
|
||||
NavbarBrand,
|
||||
Nav,
|
||||
NavItem,
|
||||
NavLink,
|
||||
Progress,
|
||||
Row,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { my } from '../stores/my.js';
|
||||
import { teams } from '../stores/teams.js';
|
||||
import { settings, time } from '../stores/settings.js';
|
||||
|
||||
import HeaderClock from './HeaderClock.svelte';
|
||||
import HeaderIssues from './HeaderIssues.svelte';
|
||||
import HeaderPartners from './HeaderPartners.svelte';
|
||||
import NavThemes from './NavThemes.svelte';
|
||||
import NavTags from './NavTags.svelte';
|
||||
|
||||
let isOpen = false;
|
||||
|
||||
function handleUpdate(event) {
|
||||
isOpen = event.detail.isOpen;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container-fluid bg-dark" style="max-height: 15vh;">
|
||||
<div style="height: 100%; max-height: inherit; width: 98%; position: absolute">
|
||||
<Container class="d-flex justify-content-center align-items-center text-light" style="height: 100%; max-height: inherit">
|
||||
<HeaderClock />
|
||||
</Container>
|
||||
</div>
|
||||
<Container class="d-flex justify-content-between p-1" style="max-height: inherit">
|
||||
<a href="/">
|
||||
<img src="/img/fic.png" alt="Forum International de la Cybersécurité" class="h-100">
|
||||
</a>
|
||||
<HeaderPartners />
|
||||
</Container>
|
||||
</div>
|
||||
<Navbar class="sticky-top" color="dark" dark expand="md">
|
||||
<NavbarToggler on:click={() => (isOpen = !isOpen)} />
|
||||
<Collapse {isOpen} navbar expand="md" on:update={handleUpdate}>
|
||||
<Nav navbar>
|
||||
<NavItem>
|
||||
<NavLink href="/">
|
||||
<Icon name="box-seam" />
|
||||
Accueil
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
<NavThemes />
|
||||
<NavTags />
|
||||
{#if $settings && $settings.end - $settings.start >= 0}
|
||||
<NavItem>
|
||||
<NavLink href="/rank">
|
||||
<Icon name="sort-down" />
|
||||
Classement
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
{/if}
|
||||
<HeaderIssues />
|
||||
<NavItem>
|
||||
<NavLink href="/rules">
|
||||
<Icon name="signpost-split" />
|
||||
Aide
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
</Nav>
|
||||
<Nav class="ms-auto text-light" navbar>
|
||||
{#if $my && $my.team_id}
|
||||
<NavItem>
|
||||
{$my.score} {$my.score === 1 ? 'point' : 'points'}
|
||||
{#if $teams[$my.team_id].rank}
|
||||
– {$teams[$my.team_id].rank}<sup>e</sup> sur {Object.keys($teams).length}
|
||||
{/if}
|
||||
</NavItem>
|
||||
{/if}
|
||||
<NavItem class="ms-2">
|
||||
{#if !$my}
|
||||
<Badge href="/register" color="warning">
|
||||
Inscription
|
||||
</Badge>
|
||||
{:else if $my.team_id}
|
||||
<Badge href="/edit" style="background-color: {$teams[$my.team_id].color} !important; color: {$teams[$my.team_id].color};">
|
||||
<span class="teamname">{$my.name}</span>
|
||||
</Badge>
|
||||
{/if}
|
||||
</NavItem>
|
||||
</Nav>
|
||||
</Collapse>
|
||||
</Navbar>
|
||||
<Progress value={$time.progression * 100} color="info" style="height: 5px; border-radius: 0;" />
|
||||
|
||||
<style>
|
||||
.teamname {
|
||||
-webkit-filter: invert(100%);
|
||||
filter: invert(100%);
|
||||
}
|
||||
</style>
|
108
frontend/ui/src/components/HeaderClock.svelte
Normal file
@ -0,0 +1,108 @@
|
||||
<script>
|
||||
import {
|
||||
ButtonGroup,
|
||||
Icon,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { settings, time } from '../stores/settings.js';
|
||||
</script>
|
||||
|
||||
{#if $settings}
|
||||
{#if $settings.end - $settings.start > 0}
|
||||
<div
|
||||
class="clock display-2"
|
||||
class:expired={$time.expired}
|
||||
class:end={$time.end}
|
||||
class:wait={$time.startIn}
|
||||
>
|
||||
{#if $time.seconds}
|
||||
<span id="hours">
|
||||
{$time.hours}
|
||||
</span>
|
||||
<span class="point">
|
||||
:
|
||||
</span>
|
||||
<span id="minutes">
|
||||
{$time.minutes}
|
||||
</span>
|
||||
<span class="point">
|
||||
:
|
||||
</span>
|
||||
<span id="seconds">
|
||||
{$time.seconds}
|
||||
</span>
|
||||
{:else}
|
||||
Chargement
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="d-flex h-100 justify-content-center align-items-center">
|
||||
<ButtonGroup size="lg">
|
||||
<a
|
||||
href="/"
|
||||
class="btn btn-light"
|
||||
>
|
||||
<Icon name="ui-checks-grid" />
|
||||
Accueil
|
||||
</a>
|
||||
<a
|
||||
href="/rank"
|
||||
class="btn btn-light"
|
||||
>
|
||||
<Icon name="sort-down" />
|
||||
Classement
|
||||
</a>
|
||||
<a
|
||||
href="{$settings.videoslink}"
|
||||
class="btn btn-light"
|
||||
class:disabled={$settings.videoslink === ''}
|
||||
>
|
||||
<Icon name="laptop-fill" />
|
||||
Vidéos
|
||||
</a>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="d-flex h-100 justify-content-center align-items-center">
|
||||
<h1 class="display-3 m-0">
|
||||
Challenge forensic
|
||||
</h1>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.clock:not(.expired):not(.wait) .point, .clock.expired {
|
||||
transition: color text-shadow 1s;
|
||||
position: relative;
|
||||
animation: clockanim 1s ease infinite;
|
||||
-moz-animation: clockanim 1s ease infinite;
|
||||
-webkit-animation: clockanim 1s ease infinite;
|
||||
}
|
||||
.clock.wait .point {
|
||||
transition: color text-shadow 1s;
|
||||
position: relative;
|
||||
animation: clockwait 1s ease infinite;
|
||||
-moz-animation: clockwait 1s ease infinite;
|
||||
-webkit-animation: clockwait 1s ease infinite;
|
||||
}
|
||||
.end {
|
||||
color: #e64143;
|
||||
}
|
||||
.point {
|
||||
text-shadow: 0 0 20px #4eaee6;
|
||||
}
|
||||
.end .point {
|
||||
text-shadow: 0 0 20px #e64143;
|
||||
}
|
||||
@keyframes clockanim {
|
||||
0% { opacity: 1.0; }
|
||||
50% { opacity: 0; }
|
||||
100% { opacity: 1.0; }
|
||||
}
|
||||
@keyframes clockwait {
|
||||
0% { text-shadow: 0 0 20px #A6D6F2; }
|
||||
50% { text-shadow: 0 0 2px #A6D6F2; }
|
||||
100% { text-shadow: 0 0 20px #A6D6F2; }
|
||||
}
|
||||
</style>
|
34
frontend/ui/src/components/HeaderIssues.svelte
Normal file
@ -0,0 +1,34 @@
|
||||
<script>
|
||||
import {
|
||||
Badge,
|
||||
Icon,
|
||||
NavItem,
|
||||
NavLink,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { issues, issues_need_info, issues_nb_responses, issues_known_responses } from '../stores/issues.js';
|
||||
import { settings } from '../stores/settings.js';
|
||||
|
||||
let badge_color = 'secondary';
|
||||
$: {
|
||||
if ($issues_known_responses != $issues_nb_responses) {
|
||||
if ($issues_need_info) {
|
||||
badge_color = 'danger';
|
||||
} else {
|
||||
badge_color = 'warning';
|
||||
}
|
||||
} else {
|
||||
badge_color = 'light';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $issues.length}
|
||||
<NavItem>
|
||||
<NavLink href="/issues">
|
||||
<Icon name="bug" />
|
||||
Problèmes
|
||||
<Badge color={badge_color}>{$issues_nb_responses}</Badge>
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
{/if}
|
40
frontend/ui/src/components/HeaderPartners.svelte
Normal file
@ -0,0 +1,40 @@
|
||||
<script>
|
||||
import {
|
||||
Carousel,
|
||||
CarouselItem,
|
||||
} from 'sveltestrap';
|
||||
|
||||
let partners = [
|
||||
{
|
||||
img: '/img/epita.png',
|
||||
alt: 'Epita',
|
||||
href: 'https://www.epita.fr/',
|
||||
},
|
||||
{
|
||||
img: '/img/srs.png',
|
||||
alt: 'Laboratoire SRS Épita',
|
||||
href: 'https://srs.epita.fr/',
|
||||
},
|
||||
{
|
||||
img: '/img/comcyber.png',
|
||||
alt: 'Réserves de cyberdéfense',
|
||||
},
|
||||
];
|
||||
let activePartner = 0;
|
||||
</script>
|
||||
|
||||
<Carousel items={partners} bind:activeIndex={activePartner} ride="carousel" pause="hover" interval={25000}>
|
||||
<div class="carousel-inner h-100">
|
||||
{#each partners as partner, index}
|
||||
<CarouselItem bind:activeIndex={activePartner} itemIndex={index} class="h-100 text-end">
|
||||
{#if partner.href}
|
||||
<a href="{partner.href}" target="_blank" class="h-100">
|
||||
<img src={partner.img} class="h-100" alt={partner.alt}>
|
||||
</a>
|
||||
{:else}
|
||||
<img src={partner.img} class="h-100" alt={partner.alt}>
|
||||
{/if}
|
||||
</CarouselItem>
|
||||
{/each}
|
||||
</div>
|
||||
</Carousel>
|
49
frontend/ui/src/components/NavTags.svelte
Normal file
@ -0,0 +1,49 @@
|
||||
<script>
|
||||
import {
|
||||
Badge,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownToggle,
|
||||
Icon,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { my } from '../stores/my.js';
|
||||
import { tags } from '../stores/mythemes.js';
|
||||
|
||||
let filter = "";
|
||||
</script>
|
||||
|
||||
<Dropdown nav inNavbar>
|
||||
<DropdownToggle nav caret>
|
||||
<Icon name="tags" />
|
||||
Tags
|
||||
</DropdownToggle>
|
||||
<DropdownMenu class="niceborder" end>
|
||||
<input
|
||||
type="text"
|
||||
class="dropdown-item"
|
||||
placeholder="Filtrer"
|
||||
bind:value={filter}
|
||||
>
|
||||
<div>
|
||||
{#each Object.keys($tags).sort() as itag, index}
|
||||
{#if filter === "" || itag.toLowerCase().indexOf(filter.toLowerCase()) >= 0}
|
||||
<DropdownItem href="/tags/{itag}">
|
||||
#{itag}
|
||||
<Badge>
|
||||
{#if $my && $my.team_id}{$tags[itag].solved}/{/if}{$tags[itag].count}
|
||||
</Badge>
|
||||
</DropdownItem>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
|
||||
<style>
|
||||
div {
|
||||
overflow-y: auto;
|
||||
max-height: calc(66vh - 100px);
|
||||
}
|
||||
</style>
|
41
frontend/ui/src/components/NavThemes.svelte
Normal file
@ -0,0 +1,41 @@
|
||||
<script>
|
||||
import {
|
||||
Badge,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownToggle,
|
||||
Icon,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { my } from '../stores/my.js';
|
||||
import { max_solved, themes } from '../stores/themes.js';
|
||||
import { myThemes } from '../stores/mythemes.js';
|
||||
</script>
|
||||
|
||||
<Dropdown nav inNavbar>
|
||||
<DropdownToggle nav caret>
|
||||
<Icon name="tv" />
|
||||
Scénarii
|
||||
</DropdownToggle>
|
||||
<DropdownMenu class="niceborder" end>
|
||||
{#each Object.keys($themes) as th, index}
|
||||
<DropdownItem href="/{$themes[th].urlid}">
|
||||
{$themes[th].name}
|
||||
{#if $max_solved > 1 && $themes[th].solved == $max_solved}
|
||||
<Badge color="danger">
|
||||
<Icon name="heart-fill" />
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if $themes[th].exercice_coeff_max > 1}
|
||||
<Badge color="success">
|
||||
<Icon name="gift-fill" />
|
||||
</Badge>
|
||||
{/if}
|
||||
<Badge>
|
||||
{#if $my && $my.team_id}{$myThemes[th].exercice_solved}/{/if}{$themes[th].exercice_count}
|
||||
</Badge>
|
||||
</DropdownItem>
|
||||
{/each}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
109
frontend/ui/src/components/RegistrationFormCreateTeam.svelte
Normal file
@ -0,0 +1,109 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Container,
|
||||
Icon,
|
||||
Row,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { settings } from '../stores/settings.js';
|
||||
|
||||
import RegistrationRowMember from './RegistrationRowMember.svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let value = {members:[{}]};
|
||||
|
||||
function validateTeamName() {
|
||||
if (typeof value.members !== 'Array') {
|
||||
value.members = [{ }];
|
||||
} else if (value.members.length <= 0) {
|
||||
value.members.push({ });
|
||||
}
|
||||
partR = true;
|
||||
}
|
||||
|
||||
function AddMember() {
|
||||
value.members.push({ });
|
||||
value = value;
|
||||
}
|
||||
|
||||
function RemoveMember(mid) {
|
||||
console.log(mid);
|
||||
}
|
||||
|
||||
function submit(event) {
|
||||
if (!partR) {
|
||||
validateTeamName();
|
||||
} else {
|
||||
dispatch('submit', event);
|
||||
}
|
||||
}
|
||||
|
||||
const max_team_members = 3;
|
||||
let jTeam = false;
|
||||
export let partR = false;
|
||||
let message = "";
|
||||
let messageClass = "danger";
|
||||
</script>
|
||||
|
||||
<form on:submit|preventDefault={submit}>
|
||||
<Row>
|
||||
<label for="teamName" class="col col-form-label">Nom d'équipe</label>
|
||||
<div class="col-sm-10">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="teamName" bind:value={value.teamName} placeholder="" autofocus required>
|
||||
<Button color="info" type="button" on:click={validateTeamName} disabled={jTeam}>Valider</Button>
|
||||
<div class="invalid-feedback">
|
||||
Veuillez indiquer un nom d'équipe valide.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{#if partR}
|
||||
<h4 class="mt-4">
|
||||
{#if !$settings.canJoinTeam}
|
||||
Membres d'équipe
|
||||
<Button
|
||||
color="success"
|
||||
disabled={value.members.length >= max_team_members}
|
||||
size="sm"
|
||||
type="button"
|
||||
on:click={AddMember}
|
||||
>
|
||||
<Icon name="person-plus-fill" />
|
||||
Ajouter un membre
|
||||
</Button>
|
||||
{:else}
|
||||
Chef d'équipe
|
||||
{/if}
|
||||
</h4>
|
||||
{#if message}
|
||||
<p class={messageClass}>{message}</p>
|
||||
{/if}
|
||||
|
||||
{#each value.members as member, mid}
|
||||
<RegistrationRowMember
|
||||
canDelete={!$settings.canJoinTeam && value.members.length > 1}
|
||||
bind:member={member}
|
||||
on:delete={RemoveMember}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<Row>
|
||||
<Col sm={{ size: 9, offset: 3 }} md={{ size: 8, offset: 4 }}>
|
||||
<Button color="info" class="mt-4" type="submit" disabled={jTeam}>
|
||||
C'est parti !
|
||||
<Icon name="chevron-right" />
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
{/if}
|
||||
</form>
|
99
frontend/ui/src/components/RegistrationFormJoinTeam.svelte
Normal file
@ -0,0 +1,99 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Container,
|
||||
Icon,
|
||||
Row,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { settings } from '../stores/settings.js';
|
||||
import { teams } from '../stores/teams.js';
|
||||
|
||||
import RegistrationRowMember from './RegistrationRowMember.svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let value = { };
|
||||
|
||||
function JvalidateTeam() {
|
||||
if (!value.members || value.members.length == 0) {
|
||||
value.members = [{ }];
|
||||
}
|
||||
value = value;
|
||||
|
||||
partJ = true;
|
||||
}
|
||||
|
||||
function submit(event) {
|
||||
if (!partJ) {
|
||||
JvalidateTeam();
|
||||
} else {
|
||||
dispatch('submit', event);
|
||||
}
|
||||
}
|
||||
|
||||
export let partJ = false;
|
||||
let message = "";
|
||||
let messageClass = "danger";
|
||||
</script>
|
||||
|
||||
{#if Object.keys($teams).length}
|
||||
<form on:submit|preventDefault={submit}>
|
||||
<Row>
|
||||
<label for="jTeam" class="col col-form-label">Nom d'équipe</label>
|
||||
<div class="col-sm-10">
|
||||
<div class="input-group">
|
||||
<select
|
||||
class="form-select"
|
||||
id="jTeam"
|
||||
bind:value={value.jTeam}
|
||||
required
|
||||
disabled={partJ}
|
||||
>
|
||||
{#each Object.keys($teams) as tid, index}
|
||||
<option value={$teams[tid].id}>
|
||||
{$teams[tid].name}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<Button color="info" type="button" on:click={JvalidateTeam} disabled={partJ}>Valider</Button>
|
||||
<div class="invalid-feedback">
|
||||
Veuillez indiquer une équipe valide.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{#if partJ}
|
||||
<h4 class="mt-4">
|
||||
Vos informations
|
||||
</h4>
|
||||
{#if message}
|
||||
<p class={messageClass}>{message}</p>
|
||||
{/if}
|
||||
|
||||
<RegistrationRowMember
|
||||
bind:member={value.members[0]}
|
||||
/>
|
||||
|
||||
<Row>
|
||||
<Col sm={{ size: 9, offset: 3 }} md={{ size: 8, offset: 4 }}>
|
||||
<Button color="info" class="mt-4" type="submit" disabled={!value.jTeam}>
|
||||
C'est parti !
|
||||
<Icon name="chevron-right" />
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
{/if}
|
||||
</form>
|
||||
{:else}
|
||||
<p class="card-text">
|
||||
Aucune équipe enregistrée pour l'instant.
|
||||
</p>
|
||||
{/if}
|
36
frontend/ui/src/components/RegistrationRowMember.svelte
Normal file
@ -0,0 +1,36 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
Row,
|
||||
} from 'sveltestrap';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let member = {};
|
||||
export let canDelete = false;
|
||||
</script>
|
||||
|
||||
<Row class="form-group my-3">
|
||||
<div class="col-sm">
|
||||
<input type="text" class="form-control" bind:value={member.lastname} placeholder="Nom" autofocus>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<input type="text" class="form-control" bind:value={member.firstname} placeholder="Prénom">
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<input type="text" class="form-control" bind:value={member.nickname} placeholder="Pseudo">
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<input type="text" class="form-control" bind:value={member.company} placeholder="Entreprise">
|
||||
</div>
|
||||
{#if canDelete}
|
||||
<div class="col-sm-auto">
|
||||
<Button color="danger" type="button" on:click={dispatch('delete', member)}>
|
||||
<Icon name="trash" />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</Row>
|
109
frontend/ui/src/components/TeamChangeName.svelte
Normal file
@ -0,0 +1,109 @@
|
||||
<script>
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Icon,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { my } from '../stores/my.js';
|
||||
|
||||
export let refresh_my;
|
||||
|
||||
let newTeamName = "";
|
||||
|
||||
function gotoHomeOnDiff(i) {
|
||||
refresh_my((my) => {
|
||||
if (my && my.name == newTeamName) {
|
||||
newTeamName = "";
|
||||
messageClass = "info";
|
||||
sberr = "Votre nom d'équipe a été changé avec succès.";
|
||||
message = "";
|
||||
} else {
|
||||
setTimeout(gotoHomeOnDiff, 850, i-1);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function submitChangeName(event) {
|
||||
message = "";
|
||||
sberr = "";
|
||||
|
||||
if (newTeamName.length < 1) {
|
||||
messageClass = "danger";
|
||||
sberr = "Nom d'équipe invalide: pas d'entrée.";
|
||||
return false;
|
||||
}
|
||||
else if (newTeamName.length > 32) {
|
||||
messageClass = "danger";
|
||||
sberr = "Nom d'équipe invalide: pas plus de 32 caractères.";
|
||||
return false;
|
||||
}
|
||||
else if (!newTeamName.match(/^[A-Za-z0-9 àéèêëîïôùûü_-]+$/)) {
|
||||
messageClass = "danger";
|
||||
sberr = "Nom d'équipe invalide: seuls les caractères alpha-numériques sont autorisés.";
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await fetch('/chname', {
|
||||
method: "POST",
|
||||
body: JSON.stringify({newName: newTeamName}),
|
||||
});
|
||||
|
||||
if (response.status < 300) {
|
||||
const data = await response.json();
|
||||
messageClass = 'success';
|
||||
message = data.errmsg;
|
||||
gotoHomeOnDiff(15);
|
||||
} else {
|
||||
messageClass = 'danger';
|
||||
|
||||
let data = "";
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch(e) {
|
||||
data = null;
|
||||
}
|
||||
|
||||
if (data && data.errmsg)
|
||||
message = data.errmsg;
|
||||
if (response.statys != 402)
|
||||
sberr = "Une erreur est survenue lors de la demande de changement de nom. Veuillez réessayer dans quelques instants.";
|
||||
}
|
||||
}
|
||||
|
||||
let sberr = "";
|
||||
let message = "";
|
||||
let messageClass = "danger";
|
||||
</script>
|
||||
|
||||
<Card class="mb-3 border-info">
|
||||
<CardHeader class="bg-info">
|
||||
<Icon name="input-cursor-text" />
|
||||
Changer de nom d'équipe
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{#if sberr || message}
|
||||
<p class="card-text text-{messageClass}">
|
||||
{#if !sberr}
|
||||
<strong>Votre demande a bien été envoyée !</strong>
|
||||
{:else}
|
||||
<strong>{sberr}</strong>
|
||||
{/if}
|
||||
{message}
|
||||
</p>
|
||||
{/if}
|
||||
<form on:submit|preventDefault={submitChangeName}>
|
||||
<div class="form-group row">
|
||||
<label for="newName" class="col col-form-label">Nouveau nom</label>
|
||||
<div class="col-sm-10">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="newName" bind:value={newTeamName} placeholder="{$my.name}">
|
||||
<Button type="submit" class="btn btn-info">Valider</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
37
frontend/ui/src/components/TeamMembers.svelte
Normal file
@ -0,0 +1,37 @@
|
||||
<script>
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Icon,
|
||||
ListGroup,
|
||||
ListGroupItem,
|
||||
} from 'sveltestrap';
|
||||
|
||||
export let members = [];
|
||||
</script>
|
||||
|
||||
<Card class="mb-3">
|
||||
<CardHeader>
|
||||
<Icon name="people-fill" />
|
||||
Membres de l'équipe
|
||||
</CardHeader>
|
||||
{#if members.length}
|
||||
<ListGroup>
|
||||
{#each members as member (member.id)}
|
||||
<ListGroupItem class="list-group-item-action">
|
||||
{member.firstname}
|
||||
{#if member.nickname}
|
||||
<span style="font-style: italic">{member.nickname}</span>
|
||||
{/if}
|
||||
<span style="font-variant: small-caps;">{member.lastname}</span>
|
||||
{#if member.company}– {member.company}{/if}
|
||||
</ListGroupItem>
|
||||
{/each}
|
||||
</ListGroup>
|
||||
{:else}
|
||||
<CardBody>
|
||||
Passez voir l'équipe d'organisation pour compléter ces informations.
|
||||
</CardBody>
|
||||
{/if}
|
||||
</Card>
|
59
frontend/ui/src/components/ThemeNav.svelte
Normal file
@ -0,0 +1,59 @@
|
||||
<script>
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
Card,
|
||||
Icon,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { my } from '../stores/my.js';
|
||||
|
||||
export let theme = {};
|
||||
export let exercice = {};
|
||||
</script>
|
||||
|
||||
<Card body class="mb-3" color="dark">
|
||||
<Breadcrumb listClassName="mb-0">
|
||||
{#each Object.keys(theme.exercices) as k, index}
|
||||
<BreadcrumbItem active={k == exercice.id}>
|
||||
{#if k == exercice.id}
|
||||
<strong class="text-info">
|
||||
{theme.exercices[k].title}
|
||||
{#if theme.exercices[k].curcoeff > 1.0}
|
||||
<Icon name="gift" aria-hidden="true" />
|
||||
{/if}
|
||||
{#if $my && $my.team_id && $my.exercices[k] && $my.exercices[k].solved}
|
||||
<Icon name="check" class="text-success" aria-hidden="true" />
|
||||
{/if}
|
||||
</strong>
|
||||
{:else if $my && $my.exercices[k]}
|
||||
<a href="/{theme.urlid}/{theme.exercices[k].urlid}" class:text-success={$my.exercices[k].solved}>
|
||||
{theme.exercices[k].title}
|
||||
{#if theme.exercices[k].curcoeff > 1.0}
|
||||
<Icon name="gift" aria-hidden="true" />
|
||||
{/if}
|
||||
{#if $my.team_id && $my.exercices[k].solved}
|
||||
<Icon name="check" class="text-success" aria-hidden="true" />
|
||||
{/if}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="text-muted">
|
||||
{theme.exercices[k].title}
|
||||
{#if theme.exercices[k].curcoeff > 1.0}
|
||||
<Icon name="gift" aria-hidden="true" />
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</BreadcrumbItem>
|
||||
{/each}
|
||||
</Breadcrumb>
|
||||
</Card>
|
||||
|
||||
<style>
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
a[href]:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
161
frontend/ui/src/routes/[theme]/[exercice].svelte
Normal file
@ -0,0 +1,161 @@
|
||||
<script context="module">
|
||||
import { get_store_value } from 'svelte/internal';
|
||||
|
||||
import { themes } from '../../stores/themes.js';
|
||||
|
||||
export async function load({ page, fetch, session, context }) {
|
||||
let exercice = null;
|
||||
|
||||
for (let ex in context.theme.exercices) {
|
||||
if (context.theme.exercices[ex].urlid === page.params.exercice) {
|
||||
exercice = context.theme.exercices[ex];
|
||||
exercice.id = ex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
theme: context.theme,
|
||||
exercice: exercice,
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Card,
|
||||
Col,
|
||||
Icon,
|
||||
Row,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import ExerciceDownloads from '../../components/ExerciceDownloads.svelte';
|
||||
import ExerciceFlags from '../../components/ExerciceFlags.svelte';
|
||||
import ExerciceHints from '../../components/ExerciceHints.svelte';
|
||||
import ExerciceSolved from '../../components/ExerciceSolved.svelte';
|
||||
import ExerciceVideo from '../../components/ExerciceVideo.svelte';
|
||||
import ThemeNav from '../../components/ThemeNav.svelte';
|
||||
|
||||
import { my } from '../../stores/my.js';
|
||||
import { settings } from '../../stores/settings.js';
|
||||
|
||||
export let theme;
|
||||
export let exercice;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{exercice.title} - {$settings.title}</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if exercice}
|
||||
<ThemeNav {theme} {exercice} />
|
||||
{/if}
|
||||
|
||||
{#if !$my || !$my.exercices[exercice.id]}
|
||||
<Alert color="warning" class="mt-3" fade={false}>
|
||||
<Icon name="dash-circle-fill" />
|
||||
Vous n'avez pas encore accès à ce défi.
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
{#if exercice}
|
||||
<Card body class="niceborder text-indent my-3">
|
||||
<h3 class="display-4">{exercice.title}</h3>
|
||||
<div>
|
||||
{#each exercice.tags as tag, index}
|
||||
<Badge href="/tags/{tag}" pill color="secondary" class="mx-1 mb-2" >#{tag}</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
{#if !$my || !$my.exercices[exercice.id]}
|
||||
<p class="lead text-justify">{@html exercice.headline}</p>
|
||||
{:else}
|
||||
<p class="lead text-justify">{@html $my.exercices[exercice.id].statement}</p>
|
||||
{#if $my.exercices[exercice.id].issue}
|
||||
<Alert color="{$my.exercices[exercice.id].issuekind}">
|
||||
{@html $my.exercices[exercice.id].issue}
|
||||
</Alert>
|
||||
{/if}
|
||||
{/if}
|
||||
<hr class="mt-0 mb-4">
|
||||
<Row>
|
||||
<Col>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Gain :</strong>
|
||||
{exercice.gain} {exercice.gain==1?"point":"points"}
|
||||
{#if $settings.firstBlood && exercice.solved < 1}
|
||||
<em>+{$settings.firstBlood * 100}% (prem's)</em>
|
||||
{/if}
|
||||
{#if exercice.curcoeff != 1.0 || $settings.exerciceCurrentCoefficient != 1.0}
|
||||
<em>{#if exercice.curcoeff * $settings.exerciceCurrentCoefficient > 1}+{Math.round((exercice.curcoeff * $settings.exerciceCurrentCoefficient - 1) * 100)}{:else}-{Math.round((1-(exercice.curcoeff * $settings.exerciceCurrentCoefficient)) * 100)}{/if}% (bonus)</em>
|
||||
{/if}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Tenté par :</strong>
|
||||
{#if !exercice.tried}
|
||||
aucune équipe
|
||||
{:else}
|
||||
{exercice.tried} {exercice.tried == 1?"équipe":"équipes"}
|
||||
{#if $my && $my.exercices[exercice.id].total_tries}
|
||||
(cumulant {$my.exercices[exercice.id].total_tries} {$my.exercices[exercice.id].total_tries == 1?"tentative":"tentatives"})
|
||||
{/if}
|
||||
{/if}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Résolu par :</strong>
|
||||
{#if !exercice.solved}
|
||||
aucune équipe
|
||||
{:else}
|
||||
{exercice.solved} {exercice.solved == 1?"équipe":"équipes"}
|
||||
{/if}
|
||||
</li>
|
||||
</ul>
|
||||
</Col>
|
||||
{#if $my && $my.team_id}
|
||||
<Col>
|
||||
{#if $settings.acceptNewIssue}
|
||||
<a href="/issues/?eid={exercice.id}" class="float-end btn btn-sm btn-warning">
|
||||
<Icon name="bug" />
|
||||
Rapporter une anomalie sur ce défi
|
||||
</a>
|
||||
{/if}
|
||||
{#if $settings.QAenabled}
|
||||
<a href="/qa/exercices/{exercice.id}" class="float-end btn btn-sm btn-info" target="_self">
|
||||
<Icon name="bug" />
|
||||
Voir les éléments QA sur ce défi
|
||||
</a>
|
||||
{/if}
|
||||
</Col>
|
||||
{/if}
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{#if $my && $my.exercices[exercice.id]}
|
||||
<Row class="mt-4">
|
||||
{#if $my.exercices[exercice.id].files || $my.exercices[exercice.id].hints}
|
||||
<Col lg class="mb-5">
|
||||
{#if $my.exercices[exercice.id].files}
|
||||
<ExerciceDownloads files={$my.exercices[exercice.id].files} />
|
||||
{/if}
|
||||
{#if $my.exercices[exercice.id].hints}
|
||||
<ExerciceHints hints={$my.exercices[exercice.id].hints} />
|
||||
{/if}
|
||||
</Col>
|
||||
{/if}
|
||||
<Col lg class="mb-5">
|
||||
{#if !$my.exercices[exercice.id].solved}
|
||||
<ExerciceFlags {exercice} flags={$my.exercices[exercice.id].flags} />
|
||||
{:else}
|
||||
<ExerciceSolved theme={theme} exercice={$my.exercices[exercice.id]} />
|
||||
{/if}
|
||||
{#if $my.exercices[exercice.id].video_uri}
|
||||
<ExerciceVideo uri={$my.exercices[exercice.id].video_uri} />
|
||||
{/if}
|
||||
</Col>
|
||||
</Row>
|
||||
{/if}
|
||||
{/if}
|
94
frontend/ui/src/routes/[theme]/__layout.svelte
Normal file
@ -0,0 +1,94 @@
|
||||
<script context="module">
|
||||
import { get_store_value } from 'svelte/internal';
|
||||
|
||||
import { themes } from '../../stores/themes.js';
|
||||
|
||||
export async function load({ page, fetch, session, context }) {
|
||||
const thms = get_store_value(themes);
|
||||
|
||||
let theme = null;
|
||||
|
||||
for (let th in thms) {
|
||||
if (thms[th].urlid === page.params.theme) {
|
||||
theme = thms[th];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
context: {
|
||||
...context,
|
||||
theme: theme,
|
||||
}, props: {
|
||||
theme: theme,
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import {
|
||||
Container,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { settings } from '../../stores/settings.js';
|
||||
|
||||
export let theme = null;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{theme.name} - {$settings.title}</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if theme}
|
||||
<div style="background-image: url({theme.image})" class="page-header">
|
||||
<Container class="text-primary">
|
||||
<h1 class="display-2">
|
||||
<a href="/{theme.urlid}">{theme.name}</a>
|
||||
</h1>
|
||||
<h2>{@html theme.authors}</h2>
|
||||
</Container>
|
||||
<div class="headerfade"></div>
|
||||
</div>
|
||||
{/if}
|
||||
<Container>
|
||||
<slot></slot>
|
||||
</Container>
|
||||
|
||||
<style>
|
||||
.page-header {
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
margin-bottom: -15rem;
|
||||
}
|
||||
.page-header h1 {
|
||||
text-shadow: 0 0 15px rgba(255,255,255,0.95), 0 0 5px rgb(255,255,255)
|
||||
}
|
||||
.page-header h1, .page-header h1 a {
|
||||
color: black;
|
||||
text-decoration: none;
|
||||
}
|
||||
.page-header h2 {
|
||||
font-size: 100%;
|
||||
text-shadow: 1px 1px 1px rgba(0,0,0,0.9)
|
||||
}
|
||||
.page-header h2, .page-header h2 a {
|
||||
color: #4eaee6;
|
||||
}
|
||||
.page-header h2 a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.page-header h1 {
|
||||
padding-top: 4rem;
|
||||
text-align: center;
|
||||
}
|
||||
.page-header h2 {
|
||||
padding-bottom: 14rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.page-header .headerfade {
|
||||
background: linear-gradient(transparent 0%, rgb(233,236,239) 100%);
|
||||
height: 3rem;
|
||||
}
|
||||
</style>
|
103
frontend/ui/src/routes/[theme]/index.svelte
Normal file
@ -0,0 +1,103 @@
|
||||
<script context="module">
|
||||
export async function load({ page, fetch, session, context }) {
|
||||
return {
|
||||
props: {
|
||||
theme: context.theme,
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Icon,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
import { my } from '../../stores/my.js';
|
||||
|
||||
export let theme = null;
|
||||
</script>
|
||||
|
||||
{#if theme && theme.exercices}
|
||||
<div class="card niceborder text-indent mt-2 mb-4">
|
||||
|
||||
<div class="card-body bg-dark text-light">
|
||||
<p class="mt-4 mx-3 card-text lead text-justify">{@html theme.headline}</p>
|
||||
<p class="mb-4 mx-3 card-text text-justify">{@html theme.intro}</p>
|
||||
</div>
|
||||
|
||||
<ul class="list-group">
|
||||
{#each Object.keys(theme.exercices) as k, index}
|
||||
<li
|
||||
class="list-group-item"
|
||||
class:list-group-item-action={$my && $my.exercices[k]}
|
||||
on:click={goto(`/${theme.urlid}/${theme.exercices[k].urlid}`)}
|
||||
>
|
||||
<div class="row">
|
||||
<div class="col-1" style="margin-top: -0.5rem; margin-bottom: -0.5rem; text-align: right; border-right: 5px solid #{$my && $my.exercices[k] && $my.exercices[k].solved ? '62c462' : 'aaa'}">
|
||||
</div>
|
||||
<div class="col-10">
|
||||
<div style="position: absolute; margin-left: calc(var(--bs-gutter-x) * -.5 - 15px); margin-top: -0.5rem;">
|
||||
<svg style="height: 50px; width: 23px;">
|
||||
<rect
|
||||
style="fill:#{$my && $my.exercices[k] && (index < 1 || $my.exercices[Object.keys(theme.exercices)[index-1]].solved) ? '62c462' : 'aaa'}"
|
||||
width="5"
|
||||
height="30"
|
||||
x="10"
|
||||
y="0" />
|
||||
<path
|
||||
style="fill:#{$my && $my.exercices[k] ? ($my.exercices[k].solved ? '62c462' : (theme.exercices[k].curcoeff > 1.0 ? 'f89406' : '5bc0de')) : '555'}"
|
||||
d="m 22,20 a 9.5700617,9.5700617 0 0 1 -9.5690181,9.57006 9.5700617,9.5700617 0 0 1 -9.57110534,-9.56797 9.5700617,9.5700617 0 0 1 9.56692984,-9.57215 9.5700617,9.5700617 0 0 1 9.5731926,9.56588" />
|
||||
</svg>
|
||||
</div>
|
||||
{#each theme.exercices[k].tags as tag, idx}
|
||||
<Badge href="/tags/{tag}" pill color="secondary" class="mx-1 float-end">#{tag}</Badge>
|
||||
{/each}
|
||||
<h5 class="fw-bold">
|
||||
{#if $my && $my.exercices[k]}
|
||||
{theme.exercices[k].title}
|
||||
{:else}
|
||||
<span style="white-space: nowrap">
|
||||
<Icon name="lock-fill" aria-hidden="true" title="Vous n'avez pas encore accès à ce défi" />
|
||||
{theme.exercices[k].title}
|
||||
</span>
|
||||
{/if}
|
||||
{#if theme.exercices[k].curcoeff > 1.0}
|
||||
<Icon name="gift" aria-hidden="true" title="Un bonus est actuellement appliqué lors de la résolution de ce défi" />
|
||||
{/if}
|
||||
</h5>
|
||||
<p>{@html theme.exercices[k].headline}</p>
|
||||
</div>
|
||||
<div class="d-none d-md-block col-1">
|
||||
{#if $my && $my.exercices[k]}
|
||||
<a class="float-right" href="{theme.urlid}/{theme.exercices[k].urlid}" style="font-size: 3rem">
|
||||
<Icon name="chevron-right" aria-hidden="true" />
|
||||
</a>
|
||||
{:else}
|
||||
<span class="float-right" style="font-size: 3rem">
|
||||
<Icon name="chevron-right" aria-hidden="true" />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
{:else}
|
||||
<Alert color="danger" fade={false}>
|
||||
<Icon name="dash-circle-fill" />
|
||||
Ce scénario n'existe pas.
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.list-group-item-action {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
138
frontend/ui/src/routes/__layout.svelte
Normal file
@ -0,0 +1,138 @@
|
||||
<script context="module">
|
||||
import { issuesStore } from '../stores/issues.js';
|
||||
import { my } from '../stores/my.js';
|
||||
import { teamsStore } from '../stores/teams.js';
|
||||
import { themesStore } from '../stores/themes.js';
|
||||
import { settings, time } from '../stores/settings.js';
|
||||
|
||||
let refresh_interval_settings = null;
|
||||
async function refresh_settings(cb=null, interval=null) {
|
||||
if (refresh_interval_settings)
|
||||
clearInterval(refresh_interval_settings);
|
||||
if (interval === null) {
|
||||
interval = Math.floor(Math.random() * 24000) + 32000;
|
||||
}
|
||||
refresh_interval_settings = setInterval(refresh_settings, interval);
|
||||
|
||||
if (!cb) {
|
||||
// Before we start, update settings more frequently.
|
||||
cb = function(stgs) {
|
||||
const srv_cur = new Date(Date.now() + (stgs.currentTime - stgs.recvTime));
|
||||
|
||||
if (settings.start > srv_cur) {
|
||||
const startIn = settings.start - srv_cur;
|
||||
if (startIn > 15000) {
|
||||
setTimeout(refresh_settings, Math.floor(Math.random() * 10000) + 2400)
|
||||
} else if (startIn > 1500) {
|
||||
setTimeout(refresh_settings, startIn - 1000 - Math.floor(Math.random() * 500))
|
||||
} else {
|
||||
// On scheduled start time, refresh my.json file
|
||||
setTimeout(refresh_my, startIn + Math.floor(Math.random() * 200))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
settings.update(await fetch('/settings.json'), cb);
|
||||
}
|
||||
|
||||
let refresh_interval_teams = null;
|
||||
async function refresh_teams(cb=null, interval=null) {
|
||||
if (refresh_interval_teams)
|
||||
clearInterval(refresh_interval_teams);
|
||||
if (interval === null) {
|
||||
interval = Math.floor(Math.random() * 24000) + 32000;
|
||||
}
|
||||
refresh_interval_teams = setInterval(refresh_teams, interval);
|
||||
|
||||
teamsStore.update(await fetch('/teams.json'), cb);
|
||||
}
|
||||
|
||||
let refresh_interval_themes = null;
|
||||
async function refresh_themes(cb=null, interval=null) {
|
||||
if (refresh_interval_themes)
|
||||
clearInterval(refresh_interval_themes);
|
||||
if (interval === null) {
|
||||
interval = Math.floor(Math.random() * 24000) + 32000;
|
||||
}
|
||||
refresh_interval_themes = setInterval(refresh_themes, interval);
|
||||
|
||||
themesStore.update(await fetch('/themes.json'), cb);
|
||||
}
|
||||
|
||||
let refresh_interval_my = null;
|
||||
async function refresh_my(cb=null, interval=null) {
|
||||
if (refresh_interval_my)
|
||||
clearInterval(refresh_interval_my);
|
||||
if (interval === null) {
|
||||
interval = Math.floor(Math.random() * 24000) + 24000;
|
||||
}
|
||||
refresh_interval_my = setInterval(refresh_my, interval);
|
||||
|
||||
my.update(await fetch('/my.json'), cb);
|
||||
}
|
||||
|
||||
let refresh_interval_issues = null;
|
||||
async function refresh_issues(cb=null, interval=null) {
|
||||
if (refresh_interval_issues)
|
||||
clearInterval(refresh_interval_issues);
|
||||
if (interval === null) {
|
||||
interval = Math.floor(Math.random() * 24000) + 32000;
|
||||
}
|
||||
refresh_interval_issues = setInterval(refresh_issues, interval);
|
||||
|
||||
issuesStore.update(await fetch('/issues.json'), cb);
|
||||
}
|
||||
|
||||
export async function load({ page, fetch, session, context }) {
|
||||
await refresh_settings();
|
||||
await refresh_themes();
|
||||
refresh_teams();
|
||||
refresh_my();
|
||||
refresh_issues();
|
||||
|
||||
return {
|
||||
context: {
|
||||
...context,
|
||||
refresh_settings,
|
||||
refresh_teams,
|
||||
refresh_themes,
|
||||
refresh_my,
|
||||
refresh_issues,
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import "bootstrap-icons/font/bootstrap-icons.css";
|
||||
|
||||
import {
|
||||
Container,
|
||||
//Styles,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import Header from '../components/Header.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$settings.title}</title>
|
||||
</svelte:head>
|
||||
|
||||
<!--Styles /-->
|
||||
|
||||
<Header />
|
||||
<slot></slot>
|
||||
|
||||
<style>
|
||||
:global(a.badge) {
|
||||
text-decoration: none;
|
||||
}
|
||||
:global(.text-justify) {
|
||||
text-align: justify;
|
||||
}
|
||||
:global(.niceborder) {
|
||||
border-bottom: 5px #4eaee6 solid !important;
|
||||
}
|
||||
</style>
|
57
frontend/ui/src/routes/edit.svelte
Normal file
@ -0,0 +1,57 @@
|
||||
<script context="module">
|
||||
export async function load({ page, fetch, session, context }) {
|
||||
return {
|
||||
props: {
|
||||
refresh_my: context.refresh_my,
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Card,
|
||||
Col,
|
||||
Container,
|
||||
Icon,
|
||||
Row,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import TeamChangeName from '../components/TeamChangeName.svelte';
|
||||
import TeamMembers from '../components/TeamMembers.svelte';
|
||||
|
||||
import { my } from '../stores/my.js';
|
||||
import { settings } from '../stores/settings.js';
|
||||
|
||||
export let refresh_my;
|
||||
</script>
|
||||
|
||||
<Container class="my-3">
|
||||
<h1>
|
||||
Votre équipe
|
||||
{#if $my}
|
||||
<small class="text-muted">{$my.name}</small>
|
||||
{/if}
|
||||
</h1>
|
||||
|
||||
{#if $my}
|
||||
<Row>
|
||||
<Col md>
|
||||
<TeamMembers members={$my.members} />
|
||||
{#if !$settings.denyNameChange}
|
||||
<TeamChangeName {refresh_my} />
|
||||
{/if}
|
||||
</Col>
|
||||
<Col md>
|
||||
<!--BrowserNotify /-->
|
||||
</Col>
|
||||
</Row>
|
||||
{:else}
|
||||
<Alert color="danger">
|
||||
<strong>Vous n'avez pas encore d'équipe !</strong>
|
||||
Rendez-vous sur <a href="/register">la page d'inscription</a> pour plus d'information.
|
||||
</Alert>
|
||||
{/if}
|
||||
</Container>
|
@ -1,2 +1,62 @@
|
||||
<h1>Welcome to SvelteKit</h1>
|
||||
<p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p>
|
||||
<script>
|
||||
import {
|
||||
Alert,
|
||||
Container,
|
||||
Card,
|
||||
CardBody,
|
||||
CardTitle,
|
||||
Col,
|
||||
Icon,
|
||||
Row,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
import CardTheme from '../components/CardTheme.svelte';
|
||||
|
||||
import { my } from '../stores/my.js';
|
||||
import { teams } from '../stores/teams.js';
|
||||
import { themes } from '../stores/themes.js';
|
||||
import { myThemes } from '../stores/mythemes.js';
|
||||
import { settings } from '../stores/settings.js';
|
||||
</script>
|
||||
|
||||
<Container class="mt-3">
|
||||
{#if !$my}
|
||||
{#if $settings.allowRegistration}
|
||||
<Alert color="warning" class="text-justify" fade={false}>
|
||||
<strong>Votre équipe n'est pas encore enregistrée.</strong> Rendez-vous sur <a href="/register">cette page</a> pour procéder à votre inscription.
|
||||
</Alert>
|
||||
{:else}
|
||||
<Alert color="danger" class="text-justify" fade={false}>
|
||||
<strong>Il semblerait qu'il y ait eu un problème lors de l'attribution de votre certificat.</strong> Veuillez vous signaler auprès de notre équipe afin de corriger ce problème.
|
||||
</Alert>
|
||||
{/if}
|
||||
{:else if !($my.team_id)}
|
||||
<Alert color="danger" fade={false}>
|
||||
<strong>Attention :</strong> puisqu'il s'agit de captures effectuées dans le but de découvrir si des actes malveillants ont été commis sur différents systèmes d'information, les contenus qui sont téléchargeables <em>peuvent</em> contenir du contenu malveillant !
|
||||
</Alert>
|
||||
{:else}
|
||||
<Alert color="info" class="text-justify" fade={false}>
|
||||
<strong>Félicitations {#each $my.members as member, index (member.id)}{#if member.id !== $my.members[0].id}{#if member.id === $my.members[$my.members.length - 1].id} et {:else}, {/if}{/if}{member.firstname} {member.lastname}{/each} !</strong> vous êtes maintenant connecté à l'espace de votre équipe <em>{$teams[$my.team_id].name}</em>. Vous pouvez changer ce nom dès maintenant en vous rendant sur la page de <a href="edit">votre équipe</a>.
|
||||
</Alert>
|
||||
|
||||
{#if ($my.team_id && !$my.members.length)}
|
||||
<Alert color="warning" class="text-justify" fade={false}>
|
||||
<strong>Les membres de votre équipe ne sont pas encore enregistrés.</strong> Passez voir l'équipe serveur pour corriger cela.
|
||||
</Alert>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<Row cols="3">
|
||||
{#each Object.keys($themes) as th, index}
|
||||
<Col class="mb-3">
|
||||
<CardTheme
|
||||
class="{$my && $my.team_id && $myThemes[th].exercice_solved > 0?'border-success ':''}{$themes[th].exercice_coeff_max > 1?'border-warning ':''}"
|
||||
theme={$themes[th]}
|
||||
on:click={goto(`/${$themes[th].urlid}`)}
|
||||
/>
|
||||
</Col>
|
||||
{/each}
|
||||
</Row>
|
||||
</Container>
|
||||
|
186
frontend/ui/src/routes/issues.svelte
Normal file
@ -0,0 +1,186 @@
|
||||
<script context="module">
|
||||
import { get_store_value } from 'svelte/internal';
|
||||
|
||||
import { exercices_idx } from '../stores/themes.js';
|
||||
|
||||
export async function load({ page, fetch, session, context }) {
|
||||
const eidx = get_store_value(exercices_idx);
|
||||
|
||||
const exercice = eidx[page.query.get("eid")]?eidx[page.query.get("eid")]:null;
|
||||
|
||||
return {
|
||||
props: {
|
||||
exercice: exercice,
|
||||
fillIssue: exercice !== null || page.query.get("fill-issue") !== null,
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Container,
|
||||
Icon,
|
||||
Table,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { issues, issues_nb_responses, issues_known_responses } from '../stores/issues.js';
|
||||
import { settings } from '../stores/settings.js';
|
||||
|
||||
import FormIssue from '../components/FormIssue.svelte';
|
||||
|
||||
export let exercice = null;
|
||||
export let fillIssue = false;
|
||||
let issue = {};
|
||||
|
||||
issues_known_responses.set($issues_nb_responses);
|
||||
|
||||
function newIssue() {
|
||||
fillIssue = true;
|
||||
}
|
||||
|
||||
let sberr = "";
|
||||
let message = "";
|
||||
let messageClass = "success";
|
||||
|
||||
function respondTo(_issue) {
|
||||
exercice = null;
|
||||
issue = {id: _issue.id, description: ''};
|
||||
fillIssue = true;
|
||||
}
|
||||
|
||||
async function submit_issue(event) {
|
||||
sberr = "";
|
||||
if (!issue.id && issue.subject.length < 3) {
|
||||
messageClass = "warning";
|
||||
sberr = "L'objet de votre rapport d'anomalie est trop court !";
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await fetch('/issue', {
|
||||
method: "POST",
|
||||
body: JSON.stringify(issue),
|
||||
});
|
||||
|
||||
if (response.status < 300) {
|
||||
const data = await response.json();
|
||||
messageClass = 'success';
|
||||
message = data.errmsg;
|
||||
issue = { };
|
||||
exercice = null;
|
||||
} else {
|
||||
messageClass = 'danger';
|
||||
|
||||
let data = "";
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch(e) {
|
||||
data = null;
|
||||
}
|
||||
|
||||
if (data && data.errmsg)
|
||||
message = data.errmsg;
|
||||
if (response.statys != 402)
|
||||
sberr = "Une erreur est survenue lors de l'envoi. Veuillez réessayer dans quelques instants.";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Container fluid class="my-3">
|
||||
{#if message || sberr}
|
||||
<Alert color={messageClass} fade={false}>
|
||||
{#if !sberr}
|
||||
<strong>Votre rapport a bien été envoyé !</strong>
|
||||
{:else}
|
||||
<strong>{sberr}</strong>
|
||||
{/if}
|
||||
{message}
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
{#if fillIssue}
|
||||
<Card class="border-warning mt-3 mb-5">
|
||||
<CardHeader class="bg-warning text-light">
|
||||
<Icon name="file-earmark-plus" />
|
||||
{#if issue.id}
|
||||
Répondre à un message
|
||||
{:else}
|
||||
Rapporter une anomalie sur un défi
|
||||
{/if}
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{#if !$settings.acceptNewIssue}
|
||||
<p class="card-text">Rapprochez-vous d'un membre de l'équipe afin d'obtenir de l'aide.</p>
|
||||
{:else}
|
||||
<FormIssue
|
||||
{exercice}
|
||||
bind:issue={issue}
|
||||
on:submit={submit_issue}
|
||||
/>
|
||||
{/if}
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<Card>
|
||||
<Table hover striped>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Objet</th>
|
||||
<th>État / Priorité</th>
|
||||
<th>Géré par</th>
|
||||
<th>Messages</th>
|
||||
<th>
|
||||
{#if !fillIssue}
|
||||
<Button sm color="warning" on:click={newIssue}>
|
||||
<Icon name="file-earmark-plus" />
|
||||
</Button>
|
||||
{/if}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each $issues as issue (issue.id)}
|
||||
<tr>
|
||||
<td>
|
||||
{issue.subject}
|
||||
{#if issue.exercice} (défi <a href="/{issue.url}">{issue.exercice}</a>){/if}
|
||||
</td>
|
||||
<td>{issue.state} / {issue.priority}</td>
|
||||
<td>{#if issue.assignee}{issue.assignee}{:else}En attente d'attribution{/if}</td>
|
||||
<td>
|
||||
{#each issue.texts as text, index}
|
||||
<p style="margin-left: 15px; text-indent: -15px">
|
||||
{#if !text.assignee || text.assignee == '$team'}Vous{:else}{text.assignee}{/if}
|
||||
à {text.date} :
|
||||
<span style="white-space: pre-line">{text.cnt}</span>
|
||||
</p>
|
||||
{/each}
|
||||
</td>
|
||||
<td>
|
||||
<Button
|
||||
sm
|
||||
color={issue.state == 'need-info'?'danger':'light'}
|
||||
on:click={respondTo(issue)}
|
||||
>
|
||||
<Icon name={issue.state == 'need-info'?'envelope-fill':'envelope-open-fill'} />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center py-2">
|
||||
Aucune anomalie remontée pour l'instant.<br>
|
||||
Vous souhaitez nous faire <a href="?fill-issue">remonter un problème</a> ?
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
</Container>
|
52
frontend/ui/src/routes/rank.svelte
Normal file
@ -0,0 +1,52 @@
|
||||
<script>
|
||||
import {
|
||||
Alert,
|
||||
Card,
|
||||
CardBody,
|
||||
CardTitle,
|
||||
Col,
|
||||
Container,
|
||||
Icon,
|
||||
Row,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { my } from '../stores/my.js';
|
||||
import { rank } from '../stores/teams.js';
|
||||
import { settings } from '../stores/settings.js';
|
||||
|
||||
import CardTheme from '../components/CardTheme.svelte';
|
||||
|
||||
let search = "";
|
||||
</script>
|
||||
|
||||
<Container fluid class="my-3">
|
||||
<h1>
|
||||
{$settings.title}
|
||||
<small class="text-muted">Classement</small>
|
||||
</h1>
|
||||
<div class="card niceborder text-light">
|
||||
<div class="card-body">
|
||||
<input type="text" class="form-control" placeholder="Rechercher" bind:value={search}>
|
||||
</div>
|
||||
<table class="table table-hover table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rang</th>
|
||||
<th>Équipe</th>
|
||||
<th>Points</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each $rank as team (team.id)}
|
||||
{#if team.rank != 0 || ($my && $my.team_id == team.id)}
|
||||
<tr class:bg-info={$my && $my.team_id == team.id} class:bg-warning={search.length && team.name.toLowerCase().indexOf(search.toLowerCase()) >= 0}>
|
||||
<td>{team.rank}</td>
|
||||
<td>{team.name}</td>
|
||||
<td>{team.score}</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Container>
|
141
frontend/ui/src/routes/register.svelte
Normal file
@ -0,0 +1,141 @@
|
||||
<script context="module">
|
||||
export async function load({ page, fetch, session, context }) {
|
||||
return {
|
||||
props: {
|
||||
refresh_my: context.refresh_my,
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Card,
|
||||
Col,
|
||||
Container,
|
||||
Icon,
|
||||
Row,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
import { my } from '../stores/my.js';
|
||||
import { settings } from '../stores/settings.js';
|
||||
|
||||
import RegistrationFormCreateTeam from '../components/RegistrationFormCreateTeam.svelte';
|
||||
import RegistrationFormJoinTeam from '../components/RegistrationFormJoinTeam.svelte';
|
||||
|
||||
export let refresh_my;
|
||||
|
||||
let form = { };
|
||||
let partR = false;
|
||||
let partJ = false;
|
||||
let messageClass;
|
||||
let message;
|
||||
|
||||
function gotoHomeOnDiff(i) {
|
||||
refresh_my((my) => {
|
||||
if (my && my.team_id) {
|
||||
goto('/');
|
||||
} else {
|
||||
setTimeout(gotoHomeOnDiff, 650, i-1);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function submit(event) {
|
||||
message = "";
|
||||
|
||||
// Remove empty members
|
||||
form.members = form.members.filter(function(m) {
|
||||
return ((m.lastname != undefined && m.lastname != "") || (m.firstname != undefined && m.firstname != "") || (m.nickname != undefined && m.nickname != ""));
|
||||
});
|
||||
|
||||
if (form.members.length == 0) {
|
||||
messageClass = 'danger';
|
||||
if (partJ) {
|
||||
message = "Veuillez compléter vos informations avant de rejoindre l'équipe.";
|
||||
} else {
|
||||
message = "Veuillez ajouter au moins un membre dans votre équipe !";
|
||||
}
|
||||
|
||||
form.members.push({ });
|
||||
form = form
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/registration', {
|
||||
method: "POST",
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
|
||||
if (response.status < 300) {
|
||||
const data = await response.json();
|
||||
messageClass = 'success';
|
||||
message = data.errmsg;
|
||||
|
||||
gotoHomeOnDiff(20);
|
||||
} else {
|
||||
messageClass = 'danger';
|
||||
|
||||
let data = "";
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch(e) {
|
||||
data = null;
|
||||
}
|
||||
|
||||
if (data && data.errmsg)
|
||||
message = data.errmsg;
|
||||
else
|
||||
message = "Une erreur est survenue lors de l'inscription de l'équipe. Veuillez réessayer dans quelques instants.";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Container class="my-3">
|
||||
<Alert color="success" class="my-3">
|
||||
<Icon name="shield-check" />
|
||||
<strong>Félicitations ! vous êtes maintenant authentifié auprès de notre serveur !</strong>
|
||||
</Alert>
|
||||
{#if !$my}
|
||||
{#if message}
|
||||
<Alert color="{messageClass}" class="my-3">
|
||||
<strong>{message}</strong>
|
||||
</Alert>
|
||||
{/if}
|
||||
{#if !$settings.allowRegistration}
|
||||
<Alert color="danger" class="my-3">
|
||||
<strong>Oups, il semblerait qu'il y ait eu un problème lors de l'attribution de votre certificat.</strong>
|
||||
Veuillez vous signaler auprès de notre équipe afin de corriger ce problème.
|
||||
</Alert>
|
||||
{:else}
|
||||
{#if !$settings.denyTeamCreation && !partJ}
|
||||
<Card body class="niceborder my-3">
|
||||
<p>
|
||||
Votre équipe n'est pas encore enregistrée sur notre serveur. Afin de
|
||||
pouvoir participer au challenge, nous vous remercions de bien vouloir
|
||||
remplir le formulaire d'inscription suivant :
|
||||
</p>
|
||||
<RegistrationFormCreateTeam bind:partR={partR} bind:value={form} on:submit={submit} />
|
||||
</Card>
|
||||
{/if}
|
||||
{#if $settings.canJoinTeam && !partR}
|
||||
<Card body class="niceborder my-3">
|
||||
<p>
|
||||
{#if !$settings.denyTeamCreation}
|
||||
Si votre équipe est déjà créée, rejoignez-là !
|
||||
{:else}
|
||||
Vous n'êtes pas encore enregistré·e sur notre serveur. Afin de
|
||||
pouvoir participer au challenge, nous vous remercions de bien vouloir
|
||||
rejoindre votre équipe :
|
||||
{/if}
|
||||
</p>
|
||||
<RegistrationFormJoinTeam bind:partJ={partJ} bind:value={form} on:submit={submit} />
|
||||
</Card>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</Container>
|
146
frontend/ui/src/routes/rules.svelte
Normal file
@ -0,0 +1,146 @@
|
||||
<script>
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
Container,
|
||||
Icon,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { settings } from '../stores/settings.js';
|
||||
</script>
|
||||
|
||||
<Container class="my-3">
|
||||
<h1>
|
||||
{$settings.title}
|
||||
<small class="text-muted">Règles générales</small>
|
||||
</h1>
|
||||
|
||||
<div class="card-group text-justify mb-5">
|
||||
<div class="card niceborder">
|
||||
<div class="card-body text-indent">
|
||||
<h2>Débloquage des challenges</h2>
|
||||
<p>
|
||||
Au début, seul le premier défi de chaque scénario est
|
||||
accessible. Les défis de niveau supérieur sont débloqués en
|
||||
validant celui du niveau qui le précéde.
|
||||
</p>
|
||||
<hr>
|
||||
|
||||
<h2>Le classement</h2>
|
||||
<p>
|
||||
Pour figurer dans le classement, il faut avoir réalisé au moins une
|
||||
action : qu'elle ajoute ou retire des points.
|
||||
</p>
|
||||
<p>
|
||||
En cas d'égalité au score, les équipes sont départagées selon leur
|
||||
ordre d'arrivée à ce score.
|
||||
</p>
|
||||
|
||||
<hr>
|
||||
<h2>Calcul des points</h2>
|
||||
<p>
|
||||
Pour gagner des points, vous devez résoudre les défis qui vous sont
|
||||
proposés. Plus le challenge est compliqué, plus il rapporte de points.
|
||||
</p>
|
||||
|
||||
<h3>Coût des tentatives</h3>
|
||||
<p>
|
||||
Vous disposez de 10 tentatives pour trouver la/les solutions d'un
|
||||
challenge. Au delà, chaque tentative vous fait perdre une petite quantité
|
||||
de points comme suit :
|
||||
</p>
|
||||
<table class="table table-sm table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nombre de tentatives</th>
|
||||
<th>Coût par tentative</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>0 à 10</td>
|
||||
<td>0 point</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>11 à 20</td>
|
||||
<td>{Math.round($settings.submissionCostBase * 10) / 10} {$settings.submissionCostBase < 2?"point":"points"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>21 à 30</td>
|
||||
<td>{Math.round($settings.submissionCostBase * 20) / 10} {$settings.submissionCostBase * 2 < 2?"point":"points"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>31 à 40</td>
|
||||
<td>{Math.round($settings.submissionCostBase * 30) / 10} {$settings.submissionCostBase * 3 < 2?"point":"points"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>41 à 50</td>
|
||||
<td>{Math.round($settings.submissionCostBase * 40) / 10} {$settings.submissionCostBase * 4 < 2?"point":"points"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>...</td>
|
||||
<td>...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card niceborder">
|
||||
<div class="card-body text-indent">
|
||||
<p>
|
||||
Par exemple :
|
||||
</p>
|
||||
<ul>
|
||||
<li>À 10 tentatives, vous aurez perdu {$settings.submissionCostBase * 0} {$settings.submissionCostBase * 0 < 2?"point":"points"}.</li>
|
||||
<li>À 15 tentatives, vous aurez perdu en tout {$settings.submissionCostBase * 5} {$settings.submissionCostBase * 5 < 2?"point":"points"} : <samp> {$settings.submissionCostBase} × 5</samp>.</li>
|
||||
<li>25 tentatives vous coûteront en tout {$settings.submissionCostBase * 20} {$settings.submissionCostBase * 20 < 2?"point":"points"} : <samp>{$settings.submissionCostBase} × 10 + {$settings.submissionCostBase} × 2 × 5</samp>.</li>
|
||||
<li>50 tentatives vous coûteront en tout {$settings.submissionCostBase * 100} {$settings.submissionCostBase * 100 < 2?"point":"points"} : <samp>{$settings.submissionCostBase} × 10 + {$settings.submissionCostBase} × 2 × 10 + {$settings.submissionCostBase} × 3 × 10 + {$settings.submissionCostBase} × 4 × 10</samp>.</li>
|
||||
</ul>
|
||||
<p>
|
||||
La dernière tentative (lorsque tous les flags sont bons) est comptabilisée
|
||||
parmi ce nombre de tentatives.
|
||||
</p>
|
||||
<hr>
|
||||
|
||||
<h3>Coût des indices</h3>
|
||||
<p>
|
||||
Pour vous aider, certains défis vous proposent un ou
|
||||
plusieurs <strong>indices</strong>. Ces indices vous font perdre des
|
||||
points, la valeur de points perdus est indiquée pour chaque indice.
|
||||
</p>
|
||||
<p>
|
||||
Ces points sont perdus, que vous réussissiez ou non le défi.
|
||||
</p>
|
||||
<p>
|
||||
Vous pouvez débloquer des indices même si vous ne disposez pas de
|
||||
suffisamment de points (ou même si vous n'en avez pas encore) ; dans ce
|
||||
cas, votre score sera négatif.
|
||||
</p>
|
||||
<hr>
|
||||
|
||||
<h3>Bonus</h3>
|
||||
<p>
|
||||
Plusieurs bonus peuvent s'appliquer en même temps, dans ce cas, le calcul
|
||||
du bonus est toujours effectué à partir du nombre de points initiaux du
|
||||
défi.
|
||||
</p>
|
||||
|
||||
<h4>Prem's</h4>
|
||||
<p>
|
||||
Un bonus de +{$settings.firstBlood * 100} % est attribué à la première équipe qui résout un défi.
|
||||
</p>
|
||||
|
||||
<h4>Bonus temporaires <small><Icon name="gift" aria-hidden="true" title="Des
|
||||
bonus existent pour au moins un challenge de ce thème" /></small></h4>
|
||||
<p>
|
||||
Au cours du challenge, afin de booster les équipes ou certains challenges,
|
||||
un bonus peut-être attribué si une tentative valide est envoyée durant la
|
||||
période d'activité du bonus. Restez à l'écoute et observez les challenges
|
||||
portant cette icône : <Icon name="gift"
|
||||
aria-hidden="true" title="Des bonus existent pour au moins un challenge de ce
|
||||
thème" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
69
frontend/ui/src/routes/tags/[tag].svelte
Normal file
@ -0,0 +1,69 @@
|
||||
<script context="module">
|
||||
export async function load({ page, fetch, session, context }) {
|
||||
return {
|
||||
props: {
|
||||
tag: page.params.tag,
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import {
|
||||
Alert,
|
||||
Card,
|
||||
CardBody,
|
||||
CardTitle,
|
||||
Col,
|
||||
Container,
|
||||
Icon,
|
||||
Row,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
import { themes } from '../../stores/themes.js';
|
||||
|
||||
import CardTheme from '../../components/CardTheme.svelte';
|
||||
|
||||
export let tag = "";
|
||||
|
||||
let exercices = [];
|
||||
$: {
|
||||
let tmp_exercices = [];
|
||||
|
||||
for (let th in $themes) {
|
||||
for (let ex in $themes[th].exercices) {
|
||||
if ($themes[th].exercices[ex].tags.indexOf(tag) >= 0) {
|
||||
tmp_exercices.push({theme: $themes[th], exercice: $themes[th].exercices[ex], index: th + "," + ex});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exercices = tmp_exercices;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Container class="mt-3">
|
||||
<h1>
|
||||
Challenges <em>{tag}</em>
|
||||
</h1>
|
||||
|
||||
{#if exercices.length}
|
||||
<Row cols="3">
|
||||
{#each exercices as {theme, exercice, index} (index)}
|
||||
<Col class="mb-3">
|
||||
<CardTheme
|
||||
theme={theme}
|
||||
exercice={exercice}
|
||||
on:click={goto(`/${theme.urlid}/${exercice.urlid}`)}
|
||||
/>
|
||||
</Col>
|
||||
{/each}
|
||||
</Row>
|
||||
{:else}
|
||||
<p class="lead">
|
||||
Il n'y a aucun défi sur ce thème.
|
||||
</p>
|
||||
{/if}
|
||||
</Container>
|
55
frontend/ui/src/stores/issues.js
Normal file
@ -0,0 +1,55 @@
|
||||
import { derived, writable } from 'svelte/store';
|
||||
|
||||
function createIssuesStore() {
|
||||
const { subscribe, set, update } = writable({issues: [], issues_idx: {}, issues_nb_responses: 0, issues_need_info: 0});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
update: (res_issues, cb=null) => {
|
||||
if (res_issues.status === 200) {
|
||||
res_issues.json().then((issues) => {
|
||||
const issues_idx = {};
|
||||
let issues_nb_responses = 0;
|
||||
let issues_need_info = 0;
|
||||
issues.forEach(function(issue, k) {
|
||||
issues_idx[issue.id] = issue;
|
||||
issues_nb_responses += issue.texts.length;
|
||||
if (issue.state == 'need-info') issues_need_info++;
|
||||
issues[k].texts.reverse();
|
||||
})
|
||||
update((i) => (Object.assign(i, {issues, issues_idx, issues_nb_responses, issues_need_info})));
|
||||
|
||||
if (cb) {
|
||||
cb(issues, issues_idx, issues_nb_responses, issues_need_info);
|
||||
}
|
||||
});
|
||||
} else if (res_issues.status === 404) {
|
||||
update((i) => ({issues: [], issues_idx: {}, issues_nb_responses: 0, issues_need_info: 0}));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const issuesStore = createIssuesStore();
|
||||
|
||||
export const issues = derived(
|
||||
issuesStore,
|
||||
($issuesStore) => ($issuesStore.issues),
|
||||
);
|
||||
|
||||
export const issues_idx = derived(
|
||||
issuesStore,
|
||||
($issuesStore) => ($issuesStore.issues_idx),
|
||||
);
|
||||
|
||||
export const issues_nb_responses = derived(
|
||||
issuesStore,
|
||||
($issuesStore) => ($issuesStore.issues_nb_responses),
|
||||
);
|
||||
|
||||
export const issues_need_info = derived(
|
||||
issuesStore,
|
||||
($issuesStore) => ($issuesStore.issues_need_info),
|
||||
);
|
||||
|
||||
export const issues_known_responses = writable(0);
|
28
frontend/ui/src/stores/my.js
Normal file
@ -0,0 +1,28 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
function createMyStore() {
|
||||
const { subscribe, set, update } = writable(null);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
update: (res_my, cb=null) => {
|
||||
if (res_my.status === 200) {
|
||||
res_my.json().then((my) => {
|
||||
for (let k in my.exercices) {
|
||||
my.exercices[k].id = k;
|
||||
}
|
||||
|
||||
update((m) => (Object.assign(m?m:{}, my)));
|
||||
|
||||
if (cb) {
|
||||
cb(my);
|
||||
}
|
||||
});
|
||||
} else if (res_my.status === 404) {
|
||||
update((m) => (null));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const my = createMyStore();
|
42
frontend/ui/src/stores/mythemes.js
Normal file
@ -0,0 +1,42 @@
|
||||
import { derived } from 'svelte/store';
|
||||
|
||||
import { my } from './my.js';
|
||||
import { themesStore } from './themes.js';
|
||||
|
||||
export const myThemes = derived([my, themesStore], ([$my, $themesStore]) => {
|
||||
const themes = {};
|
||||
|
||||
for (let key in $themesStore.themes) {
|
||||
themes[key] = {exercice_solved: 0};
|
||||
|
||||
if ($my && $my.exercices) {
|
||||
for (let k in $themesStore.themes[key].exercices) {
|
||||
if ($my.exercices[k] && $my.exercices[k].solved) {
|
||||
themes[key].exercice_solved++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return themes;
|
||||
});
|
||||
|
||||
export const tags = derived([my, themesStore], ([$my, $themesStore]) => {
|
||||
const tags = {};
|
||||
|
||||
for (let key in $themesStore.themes) {
|
||||
for (let k in $themesStore.themes[key].exercices) {
|
||||
$themesStore.themes[key].exercices[k].tags.forEach((tag) => {
|
||||
if (!tags[tag])
|
||||
tags[tag] = {count: 1, solved: 0};
|
||||
else
|
||||
tags[tag].count += 1;
|
||||
|
||||
if ($my && $my.exercices && $my.exercices[k] && $my.exercices[k].solved)
|
||||
tags[tag].solved += 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tags;
|
||||
});
|
108
frontend/ui/src/stores/settings.js
Normal file
@ -0,0 +1,108 @@
|
||||
import { readable, writable } from 'svelte/store';
|
||||
|
||||
function createSettingsStore() {
|
||||
const { subscribe, set, update } = writable({});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
update: (res_settings, cb) => {
|
||||
const recvTime = (new Date()).getTime();
|
||||
|
||||
if (res_settings.status === 200) {
|
||||
res_settings.json().then((settings) => {
|
||||
if (settings.start)
|
||||
settings.start = new Date(settings.start);
|
||||
if (settings.end)
|
||||
settings.end = new Date(settings.end);
|
||||
if (settings.generation)
|
||||
settings.generation = new Date(settings.generation);
|
||||
if (settings.activateTime)
|
||||
settings.activateTime = new Date(settings.activateTime);
|
||||
|
||||
settings.recvTime = recvTime;
|
||||
const x_fic_time = res_settings.headers["x-fic-time"];
|
||||
if (x_fic_time) {
|
||||
settings.currentTime = Math.floor(x_fic_time * 1000);
|
||||
} else {
|
||||
settings.currentTime = settings.recvTime;
|
||||
}
|
||||
|
||||
update((s) => (Object.assign(s, settings)));
|
||||
|
||||
if (cb) {
|
||||
cb(settings);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const settings = createSettingsStore();
|
||||
|
||||
function updateTime(settings) {
|
||||
const time = {};
|
||||
|
||||
const srv_cur = new Date(Date.now() + (settings.currentTime - settings.recvTime));
|
||||
|
||||
let remain = 0;
|
||||
if (settings.start === undefined || settings.start == 0) {
|
||||
return time;
|
||||
} else if (settings.start > srv_cur) {
|
||||
time.startIn = Math.floor((settings.start - srv_cur) / 1000);
|
||||
remain = settings.end - settings.start;
|
||||
} else if (settings.end > srv_cur) {
|
||||
time.startIn = 0;
|
||||
remain = settings.end - srv_cur;
|
||||
}
|
||||
|
||||
time.progression = 1 - remain / (settings.end - settings.start);
|
||||
|
||||
remain = remain / 1000;
|
||||
|
||||
if (remain < 0) {
|
||||
remain = 0;
|
||||
time.end = true;
|
||||
time.expired = true;
|
||||
} else if (remain < 60) {
|
||||
time.end = false;
|
||||
time.expired = true;
|
||||
} else {
|
||||
time.end = false;
|
||||
time.expired = false;
|
||||
}
|
||||
|
||||
time.remaining = remain;
|
||||
time.hours = Math.floor(remain / 3600);
|
||||
time.minutes = Math.floor((remain % 3600) / 60);
|
||||
time.seconds = Math.floor(remain % 60);
|
||||
|
||||
if (time.hours <= 9) {
|
||||
time.hours = "0" + time.hours;
|
||||
}
|
||||
if (time.minutes <= 9) {
|
||||
time.minutes = "0" + time.minutes;
|
||||
}
|
||||
if (time.seconds <= 9) {
|
||||
time.seconds = "0" + time.seconds;
|
||||
}
|
||||
|
||||
return time;
|
||||
}
|
||||
|
||||
export const time = readable({}, function start(set) {
|
||||
let _settings = {};
|
||||
|
||||
const unsubscribe = settings.subscribe((settings) => {
|
||||
_settings = settings;
|
||||
});
|
||||
|
||||
const interval = setInterval(() => {
|
||||
set(updateTime(_settings));
|
||||
}, 1000);
|
||||
|
||||
return function stop() {
|
||||
clearInterval(interval);
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
45
frontend/ui/src/stores/teams.js
Normal file
@ -0,0 +1,45 @@
|
||||
import { derived, writable } from 'svelte/store';
|
||||
|
||||
function createTeamsStore() {
|
||||
const { subscribe, set, update } = writable({teams:{}, teams_count: 0, rank: []});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
update: (res_teams, cb=null) => {
|
||||
if (res_teams.status === 200) {
|
||||
res_teams.json().then((teams) => {
|
||||
const teams_count = Object.keys(teams).length
|
||||
|
||||
const rank = [];
|
||||
for (const tid in teams) {
|
||||
teams[tid].id = Number(tid);
|
||||
rank.push(teams[tid]);
|
||||
}
|
||||
|
||||
update((t) => (Object.assign(t, {teams, teams_count, rank})));
|
||||
|
||||
if (cb) {
|
||||
cb(teams, teams_count, rank);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const teamsStore = createTeamsStore();
|
||||
|
||||
export const teams = derived(
|
||||
teamsStore,
|
||||
($teamsStore) => ($teamsStore.teams)
|
||||
);
|
||||
|
||||
export const teams_count = derived(
|
||||
teamsStore,
|
||||
($teamsStore) => ($teamsStore.teams_count)
|
||||
);
|
||||
|
||||
export const rank = derived(
|
||||
teamsStore,
|
||||
($teamsStore) => ($teamsStore.rank)
|
||||
);
|
68
frontend/ui/src/stores/themes.js
Normal file
@ -0,0 +1,68 @@
|
||||
import { derived, writable } from 'svelte/store';
|
||||
|
||||
function createThemesStore() {
|
||||
const { subscribe, set, update } = writable({themes: {}, exercices_idx: {}, max_solved: 0});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
update: (res_themes, cb=null) => {
|
||||
if (res_themes.status === 200) {
|
||||
res_themes.json().then((themes) => {
|
||||
let max_solved = 0;
|
||||
const exercices_idx = {};
|
||||
|
||||
for (let key in themes) {
|
||||
const theme = themes[key];
|
||||
|
||||
if (theme.solved > max_solved) {
|
||||
max_solved = theme.solved;
|
||||
}
|
||||
|
||||
themes[key].exercice_count = Object.keys(theme.exercices).length;
|
||||
themes[key].exercice_coeff_max = 0;
|
||||
themes[key].max_gain = 0;
|
||||
let last_exercice = null;
|
||||
for (let k in theme.exercices) {
|
||||
const exercice = theme.exercices[k];
|
||||
|
||||
themes[key].max_gain += exercice.gain;
|
||||
if (themes[key].exercice_coeff_max < exercice.curcoeff) {
|
||||
themes[key].exercice_coeff_max = exercice.curcoeff;
|
||||
}
|
||||
|
||||
if (last_exercice != null)
|
||||
themes[key].exercices[last_exercice].next = k;
|
||||
last_exercice = k;
|
||||
|
||||
exercice.id = k;
|
||||
exercices_idx[k] = exercice;
|
||||
}
|
||||
}
|
||||
|
||||
update((t) => (Object.assign(t, {themes, exercices_idx, max_solved})));
|
||||
|
||||
if (cb) {
|
||||
cb(themes, exercices_idx, max_solved);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const themesStore = createThemesStore();
|
||||
|
||||
export const themes = derived(
|
||||
themesStore,
|
||||
($themesStore) => ($themesStore.themes),
|
||||
);
|
||||
|
||||
export const exercices_idx = derived(
|
||||
themesStore,
|
||||
($themesStore) => ($themesStore.exercices_idx),
|
||||
);
|
||||
|
||||
export const max_solved = derived(
|
||||
themesStore,
|
||||
($themesStore) => ($themesStore.max_solved),
|
||||
);
|
BIN
frontend/ui/static/favicon.ico
Normal file
After Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 1.5 KiB |
BIN
frontend/ui/static/img/comcyber.png
Normal file
After Width: | Height: | Size: 152 KiB |
BIN
frontend/ui/static/img/epita.png
Normal file
After Width: | Height: | Size: 39 KiB |
BIN
frontend/ui/static/img/fic.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
frontend/ui/static/img/icon-danger.ico
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
frontend/ui/static/img/icon-dark.ico
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
frontend/ui/static/img/icon-info.ico
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
frontend/ui/static/img/icon-light.ico
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
frontend/ui/static/img/icon-primary.ico
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
frontend/ui/static/img/icon-secondary.ico
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
frontend/ui/static/img/icon-success.ico
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
frontend/ui/static/img/icon-warning.ico
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
frontend/ui/static/img/srs.png
Normal file
After Width: | Height: | Size: 157 KiB |
@ -1,6 +1,12 @@
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
import adapt from '@sveltejs/adapter-static';
|
||||
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapt({
|
||||
fallback: 'index.html'
|
||||
}),
|
||||
ssr: false,
|
||||
// hydrate the <div id="svelte"> element in src/app.html
|
||||
target: '#svelte'
|
||||
}
|
||||
|