ui: Use $lib instead of ../../../../ mess
This commit is contained in:
parent
3cf92b4798
commit
3a6daa3d04
48 changed files with 81 additions and 81 deletions
86
frontend/ui/src/lib/components/CardTheme.svelte
Normal file
86
frontend/ui/src/lib/components/CardTheme.svelte
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<script>
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Card,
|
||||
CardBody,
|
||||
CardTitle,
|
||||
Col,
|
||||
Icon,
|
||||
Row,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { my } from '$lib/stores/my.js';
|
||||
import { max_solved } from '$lib/stores/themes.js';
|
||||
import { myThemes } from '$lib/stores/mythemes.js';
|
||||
|
||||
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 $my && $my.team_id}
|
||||
<div class="float-end">
|
||||
{#if $max_solved > 1 && theme.solved == $max_solved}
|
||||
<Badge color="danger">
|
||||
<Icon name="heart-fill" />
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if theme.exercice_coeff_max > 1 || (exercice && exercice.curcoeff > 1)}
|
||||
<Badge color="success">
|
||||
<Icon name="gift-fill" />
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if exercice && $my && $my.exercices[exercice.id] && $my.exercices[exercice.id].solved_rank}
|
||||
<Badge color="light" class="text-success fw-bold">
|
||||
<Icon name="check" />
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if !exercice && $myThemes[theme.id].exercice_solved > 0}
|
||||
<Badge color="light">
|
||||
{$myThemes[theme.id].exercice_solved}/{theme.exercice_count}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#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>
|
||||
17
frontend/ui/src/lib/components/DateFormat.svelte
Normal file
17
frontend/ui/src/lib/components/DateFormat.svelte
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script>
|
||||
export let date;
|
||||
export let dateStyle = "long";
|
||||
export let timeStyle = "long";
|
||||
|
||||
function formatDate(input, dateStyle, timeStyle) {
|
||||
if (typeof input === 'string') {
|
||||
input = new Date(input);
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle,
|
||||
timeStyle,
|
||||
}).format(input);
|
||||
}
|
||||
</script>
|
||||
|
||||
{formatDate(date, dateStyle, timeStyle)}
|
||||
49
frontend/ui/src/lib/components/ExerciceDownloads.svelte
Normal file
49
frontend/ui/src/lib/components/ExerciceDownloads.svelte
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<script>
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
CardText,
|
||||
Icon,
|
||||
ListGroup,
|
||||
ListGroupItem,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import FileSize from './FileSize.svelte';
|
||||
|
||||
export let files = [];
|
||||
</script>
|
||||
|
||||
{#if files.length}
|
||||
<Card class="mb-4">
|
||||
<CardHeader class="text-light">
|
||||
<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 class="border-dark">
|
||||
{#each files as file, index}
|
||||
<ListGroupItem tag="a" href={file.path} target={(file.name.endsWith(".txt") || file.name.endsWith(".jpg") || file.name.endsWith(".png") || file.name.endsWith(".pdf"))?"_blank":"_self"} class="d-flex align-items-center">
|
||||
<h1 class="me-3">
|
||||
<Icon name="arrow-down-circle" />
|
||||
</h1>
|
||||
<div style="min-width: 0">
|
||||
<h4 class="fw-bold"><samp>{file.name}</samp></h4>
|
||||
<nobr>
|
||||
Taille :
|
||||
<FileSize size={file.size} />
|
||||
</nobr>
|
||||
<nobr class="d-block text-truncate">
|
||||
<span title="blake2.net">b2sum</span> :
|
||||
<samp class="cksum" title="{file.checksum}">{file.checksum}</samp>
|
||||
</nobr>
|
||||
</div>
|
||||
</ListGroupItem>
|
||||
{/each}
|
||||
</ListGroup>
|
||||
</Card>
|
||||
{/if}
|
||||
240
frontend/ui/src/lib/components/ExerciceFlags.svelte
Normal file
240
frontend/ui/src/lib/components/ExerciceFlags.svelte
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
<script>
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
CardText,
|
||||
Icon,
|
||||
ListGroup,
|
||||
ListGroupItem,
|
||||
Progress,
|
||||
Spinner,
|
||||
Tooltip,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { blake2b } from 'hash-wasm';
|
||||
|
||||
import { my } from '$lib/stores/my.js';
|
||||
import { teams } from '$lib/stores/teams.js';
|
||||
import { settings } from '$lib/stores/settings.js';
|
||||
|
||||
import DateFormat from './DateFormat.svelte';
|
||||
import FlagKey from './FlagKey.svelte';
|
||||
import FlagMCQ from './FlagMCQ.svelte';
|
||||
|
||||
export let exercice = { };
|
||||
export let flags = [];
|
||||
|
||||
function waitDiff(i) {
|
||||
my.refresh((my) => {
|
||||
if (my && (my.exercices[exercice.id].tries != exercice.tries || my.exercices[exercice.id].solved_rank != exercice.solved_rank || my.exercices[exercice.id].solved_time != exercice.solved_time)) {
|
||||
submitInProgress = false;
|
||||
teams.refresh();
|
||||
} else if (i > 0) {
|
||||
setTimeout(waitDiff, (12-i)*50+440, i-1);
|
||||
} else {
|
||||
timeouted = true;
|
||||
submitInProgress = false;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export let forcesolved = false;
|
||||
let responses = { };
|
||||
async function submitFlags() {
|
||||
submitInProgress = true;
|
||||
sberr = "";
|
||||
message = "";
|
||||
|
||||
if ($my && $my.team_id === 0) {
|
||||
let allGoodResponse = true;
|
||||
for (const f in flags) {
|
||||
const flag = flags[f];
|
||||
|
||||
let soluce = "";
|
||||
if (flag.type == "mcq") {
|
||||
for (const c in flag.choices) {
|
||||
soluce += responses.mcqs[c] ? "t" : "f";
|
||||
}
|
||||
} else {
|
||||
soluce = responses.flags[flag.id];
|
||||
}
|
||||
|
||||
if (flag.ignore_case) {
|
||||
soluce = soluce.toLowerCase();
|
||||
}
|
||||
|
||||
if (flag.validator_regexp) {
|
||||
let re = new RegExp(flag.validator_regexp, flag.ignore_case?'i':'');
|
||||
soluce = soluce.match(re).slice(1).join("+");
|
||||
}
|
||||
|
||||
if (await blake2b(soluce) == flag.soluce) {
|
||||
flags[f].found = new Date();
|
||||
} else if (!flag.found) {
|
||||
allGoodResponse = false;
|
||||
}
|
||||
flags = flags;
|
||||
}
|
||||
|
||||
if (allGoodResponse) {
|
||||
forcesolved = true;
|
||||
}
|
||||
|
||||
if (exercice.tries) {
|
||||
exercice.tries += 1;
|
||||
} else {
|
||||
exercice.tries = 1;
|
||||
}
|
||||
exercice.solved_time = new Date();
|
||||
exercice = exercice;
|
||||
|
||||
submitInProgress = false;
|
||||
} else {
|
||||
const response = await fetch(
|
||||
"submit/" + exercice.id,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {'Accept': 'application/json'},
|
||||
body: JSON.stringify(responses),
|
||||
}
|
||||
)
|
||||
|
||||
if (response.status < 300) {
|
||||
const data = await response.json();
|
||||
messageClass = 'text-success';
|
||||
message = data.errmsg;
|
||||
waitDiff(13);
|
||||
} else {
|
||||
submitInProgress = false;
|
||||
|
||||
messageClass = 'text-danger';
|
||||
|
||||
let data = "";
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch(e) {
|
||||
data = null;
|
||||
}
|
||||
|
||||
if (data && data.errmsg)
|
||||
message = data.errmsg;
|
||||
if (response.statys != 402)
|
||||
sberr = "Oups !";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetResponses() {
|
||||
responses = {
|
||||
flags: { },
|
||||
mcqs: { },
|
||||
justifications: { },
|
||||
};
|
||||
}
|
||||
|
||||
let last_exercice = null;
|
||||
$: {
|
||||
if (!last_exercice || last_exercice != exercice.id) {
|
||||
last_exercice = exercice.id;
|
||||
resetResponses()
|
||||
}
|
||||
}
|
||||
|
||||
let sberr = "";
|
||||
let message = "";
|
||||
let messageClass = "text-danger";
|
||||
let timeouted = false;
|
||||
let submitInProgress = false;
|
||||
</script>
|
||||
|
||||
<Card class="border-danger mb-2">
|
||||
<CardHeader class="bg-danger text-light">
|
||||
<Icon name="flag-fill" />
|
||||
Faire son rapport
|
||||
</CardHeader>
|
||||
{#if exercice.flags.length != exercice.nb_flags}
|
||||
<Progress
|
||||
value={exercice.flags.length}
|
||||
max={exercice.nb_flags}
|
||||
class="rounded-0"
|
||||
barClassName="text-light"
|
||||
>
|
||||
{exercice.flags.length}/{exercice.nb_flags}
|
||||
</Progress>
|
||||
{/if}
|
||||
{#if exercice.tries || exercice.solved_time || exercice.submitted || sberr || timeouted}
|
||||
<ListGroup class="border-dark">
|
||||
{#if exercice.solved_time || exercice.tries}
|
||||
<ListGroupItem class="text-warning rounded-0">
|
||||
{#if exercice.tries > 0}{exercice.tries} {exercice.tries==1?"tentative effectuée":"tentatives effectuées"}.{/if}
|
||||
Dernière solution envoyée à <DateFormat date={exercice.solved_time} />.
|
||||
</ListGroupItem>
|
||||
{/if}
|
||||
{#if exercice.solve_dist}
|
||||
<ListGroupItem class="rounded-0">
|
||||
{exercice.solve_dist} {exercice.solve_dist == 1?"réponse erronée":"réponses erronées"}.
|
||||
</ListGroupItem>
|
||||
{/if}
|
||||
{#if exercice.submitted || sberr}
|
||||
<ListGroupItem class="{messageClass} rounded-0">
|
||||
{#if !sberr}
|
||||
<strong>Votre solution a bien été envoyée !</strong>
|
||||
{:else}
|
||||
<strong>{sberr}</strong> {message}
|
||||
{/if}
|
||||
</ListGroupItem>
|
||||
{/if}
|
||||
{#if timeouted}
|
||||
<ListGroupItem class="text-danger rounded-0">
|
||||
<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}>
|
||||
{#each flags as flag ((flag.type?flag.type:"i") + flag.id)}
|
||||
{#if !flag.type && !flag.id}
|
||||
<div class="form-group mb-3">
|
||||
<label class="{flag.variant?('text-'+flag.variant):''}">{flag.label}</label>
|
||||
</div>
|
||||
{:else if flag.type == "mcq"}
|
||||
<FlagMCQ
|
||||
exercice_id={exercice.id}
|
||||
{flag}
|
||||
bind:values={responses.mcqs}
|
||||
bind:justifications={responses.justifications}
|
||||
/>
|
||||
{:else}
|
||||
<FlagKey
|
||||
exercice_id={exercice.id}
|
||||
{flag}
|
||||
bind:value={responses.flags[flag.id]}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<div class="form-group mt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
color="danger"
|
||||
id="submission-{exercice.id}"
|
||||
disabled={submitInProgress || $settings.disablesubmitbutton}
|
||||
>
|
||||
{#if submitInProgress}
|
||||
<Spinner size="sm" class="me-2" />
|
||||
{/if}
|
||||
Soumettre
|
||||
</Button>
|
||||
{#if $settings.disablesubmitbutton}
|
||||
<span class="text-muted">{$settings.disablesubmitbutton}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
{/if}
|
||||
</Card>
|
||||
140
frontend/ui/src/lib/components/ExerciceHints.svelte
Normal file
140
frontend/ui/src/lib/components/ExerciceHints.svelte
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
<script>
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
CardText,
|
||||
Icon,
|
||||
ListGroup,
|
||||
ListGroupItem,
|
||||
Spinner,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { settings } from '$lib/stores/settings.js';
|
||||
|
||||
export let hints = [];
|
||||
export let exercice = {};
|
||||
|
||||
let hints_submitted = {};
|
||||
|
||||
function waitDiff(i, hint) {
|
||||
my.refresh((my) => {
|
||||
let openedHint = false;
|
||||
|
||||
if (my && my.exercices[exercice.id].hints) {
|
||||
my.exercices[exercice.id].hints.forEach((h) => {
|
||||
if (h.id == hint.id && (h.content || h.file)) {
|
||||
openedHint = true;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (openedHint) {
|
||||
hints_submitted[hint.id] = false;
|
||||
hinterror = "";
|
||||
} else if (i > 0) {
|
||||
setTimeout(waitDiff, 650, i-1, hint);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function showHint(hint) {
|
||||
hint.hidden = false;
|
||||
hints = hints; // Force Svelte update
|
||||
}
|
||||
|
||||
async function openHint(hint) {
|
||||
hints_submitted[hint.id] = true;
|
||||
hinterror = "";
|
||||
|
||||
const response = await fetch(
|
||||
"openhint/" + exercice.id,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {'Accept': 'application/json'},
|
||||
body: JSON.stringify({ id: hint.id }),
|
||||
}
|
||||
)
|
||||
|
||||
if (response.status < 300) {
|
||||
waitDiff(15, hint);
|
||||
} else {
|
||||
hints_submitted[hint.id] = false;
|
||||
|
||||
let data = "";
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch(e) {
|
||||
data = null;
|
||||
}
|
||||
|
||||
if (data && data.errmsg)
|
||||
hinterror = data.errmsg;
|
||||
}
|
||||
}
|
||||
|
||||
let hinterror = "";
|
||||
</script>
|
||||
|
||||
{#if hints.length}
|
||||
<Card class="mb-2">
|
||||
<CardHeader class="bg-info text-light">
|
||||
<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 align-items-center">
|
||||
{#if hint.file}
|
||||
<h1 class="me-3">
|
||||
<Icon name="arrow-down-circle" />
|
||||
</h1>
|
||||
{/if}
|
||||
<div class="flex-fill" style="min-width:0">
|
||||
<h4 class="fw-bold">{hint.title}</h4>
|
||||
{#if hint.file}
|
||||
<p style="overflow-x: auto">
|
||||
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>
|
||||
{#if !(hint.content || hint.file) || (!hint.file && hint.hidden)}
|
||||
<div>
|
||||
{#if !(hint.content || hint.file)}
|
||||
<button type="button" on:click={openHint(hint)} class="btn btn-info" class:disabled={hints_submitted[hint.id]} disabled={$settings.disablesubmitbutton}>
|
||||
{#if hints_submitted[hint.id]}
|
||||
<Spinner size="sm" class="me-2" />
|
||||
{:else}
|
||||
<Icon name="lock" aria-hidden="true" />
|
||||
{/if}
|
||||
Débloquer
|
||||
</button>
|
||||
{/if}
|
||||
{#if !hint.file && hint.hidden}
|
||||
<button type="button" on:click={() => showHint(hint)} class="btn btn-info">
|
||||
<Icon name="lock" aria-hidden="true" />
|
||||
Afficher
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</ListGroupItem>
|
||||
{/each}
|
||||
</ListGroup>
|
||||
</Card>
|
||||
{/if}
|
||||
43
frontend/ui/src/lib/components/ExerciceSolved.svelte
Normal file
43
frontend/ui/src/lib/components/ExerciceSolved.svelte
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<script>
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
CardText,
|
||||
Icon,
|
||||
ListGroup,
|
||||
ListGroupItem,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import DateFormat from './DateFormat.svelte';
|
||||
|
||||
export let theme = {};
|
||||
export let exercice = {};
|
||||
</script>
|
||||
|
||||
<Card class="border-success mb-2">
|
||||
<CardHeader class="bg-success text-light">
|
||||
<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 à <DateFormat date={exercice.solved_time} />.
|
||||
{:else}
|
||||
Bravo, vous avez résolu ce défi à <DateFormat date={exercice.solved_time} />{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>
|
||||
20
frontend/ui/src/lib/components/ExerciceVideo.svelte
Normal file
20
frontend/ui/src/lib/components/ExerciceVideo.svelte
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<script>
|
||||
import { base } from '$app/paths';
|
||||
|
||||
import {
|
||||
CardBody,
|
||||
Icon,
|
||||
} from 'sveltestrap';
|
||||
|
||||
export let uri = "";
|
||||
</script>
|
||||
|
||||
<CardBody class="text-indent ratio ratio-16x9">
|
||||
{#if uri.length > 0 && uri[0] === '/'}
|
||||
<video controls src={uri} />
|
||||
{:else}
|
||||
<iframe type="text/html" src="{uri.replace('$RFILES$', base+'/resolution')}" class="embed-responsive-item" title="Vidéo de résolution">
|
||||
Regardez la vidéo de résolution de ce défi : <a href="{uri.replace('$RFILES$',base+'/resolution')}">{uri.replace('$RFILES$',base+'/resolution')}</a>.
|
||||
</iframe>
|
||||
{/if}
|
||||
</CardBody>
|
||||
28
frontend/ui/src/lib/components/FileSize.svelte
Normal file
28
frontend/ui/src/lib/components/FileSize.svelte
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<script>
|
||||
export let size;
|
||||
|
||||
const units = [
|
||||
"o",
|
||||
"kio",
|
||||
"Mio",
|
||||
"Gio",
|
||||
"Tio",
|
||||
"Pio",
|
||||
"Eio",
|
||||
"Zio",
|
||||
"Yio",
|
||||
]
|
||||
function formatSize(input) {
|
||||
var res = input;
|
||||
var unit = 0;
|
||||
while (res > 1024) {
|
||||
unit += 1;
|
||||
res = res / 1024;
|
||||
}
|
||||
return (Math.round(res * 100) / 100) + " " + units[unit];
|
||||
}
|
||||
</script>
|
||||
|
||||
<span title="{size} octets">
|
||||
{formatSize(size)}
|
||||
</span>
|
||||
219
frontend/ui/src/lib/components/FlagKey.svelte
Normal file
219
frontend/ui/src/lib/components/FlagKey.svelte
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
<script>
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
Spinner,
|
||||
} from 'sveltestrap';
|
||||
import { tick } from 'svelte';
|
||||
|
||||
import { my } from '$lib/stores/my.js';
|
||||
import { settings } from '$lib/stores/settings.js';
|
||||
|
||||
export let exercice_id = 0;
|
||||
export let flag = { };
|
||||
export let value = "";
|
||||
let values = [""];
|
||||
|
||||
function waitChoices(i) {
|
||||
my.refresh((my) => {
|
||||
let haveChoices = false;
|
||||
|
||||
if (my && my.exercices[exercice_id].flags) {
|
||||
my.exercices[exercice_id].flags.forEach((f) => {
|
||||
if (f.id == flag.id && f.choices) {
|
||||
haveChoices = true;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (haveChoices) {
|
||||
wcsubmitted = false;
|
||||
} else if (i > 0) {
|
||||
setTimeout(waitChoices, 450, i-1);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let wcsubmitted = false;
|
||||
async function wantchoices() {
|
||||
if (!confirm("Êtes-vous sûr de vouloir utiliser " + (flag.choices_cost * $settings.wchoiceCurrentCoefficient) + " points pour avoir une liste de propositions à la place de ce champ de texte à compléter ?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
wcsubmitted = true;
|
||||
|
||||
const response = await fetch(
|
||||
"wantchoices/" + exercice_id,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {'Accept': 'application/json'},
|
||||
body: JSON.stringify({ id: Number(flag.id) }),
|
||||
}
|
||||
)
|
||||
|
||||
if (response.status < 300) {
|
||||
waitChoices(15);
|
||||
} else {
|
||||
wcsubmitted = false;
|
||||
}
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
values.push("");
|
||||
values = values;
|
||||
}
|
||||
|
||||
$: {
|
||||
let v = values.slice();
|
||||
|
||||
// Remove empty cells
|
||||
if (!flag.nb_lines) {
|
||||
for (let i = v.length - 1; i > 0; i--) {
|
||||
if (!v[i].length) {
|
||||
v.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort cells
|
||||
if (flag.ignore_order) {
|
||||
v = v.sort();
|
||||
}
|
||||
|
||||
value = v.join(flag.separator ? flag.separator : ',');
|
||||
if (flag.separator) {
|
||||
value += flag.separator;
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
if (flag.nb_lines) {
|
||||
while (values.length != flag.nb_lines) {
|
||||
if (values.length > flag.nb_lines) {
|
||||
values.pop();
|
||||
} else {
|
||||
values.push("");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="form-group mb-3">
|
||||
{#if flag.bonus_gain}
|
||||
<div class={'float-end badge bg-' + (flag.found?'success':'danger')} title={'Ce flag est optionnel, si vous le complétez il vous rapportera ' + flag.bonus_gain + ' points supplémentaires'}>
|
||||
optionnel | {#if flag.bonus_gain > 0}+{/if}{flag.bonus_gain} pts
|
||||
</div>
|
||||
{/if}
|
||||
<label for="sol_{flag.type}{flag.id}_0">{flag.label} :</label>
|
||||
{#if flag.found && flag.value}
|
||||
<span>{flag.value}</span>
|
||||
{/if}
|
||||
{#if !flag.found}
|
||||
{#each values as v, index}
|
||||
{#if !flag.choices}
|
||||
<div class="input-group" class:mt-1={index != 0}>
|
||||
{#if flag.type == 'number'}
|
||||
<input
|
||||
type="number"
|
||||
class="form-control flag"
|
||||
id="sol_{flag.type}{flag.id}_{index}"
|
||||
autocomplete="off"
|
||||
bind:value={values[index]}
|
||||
placeholder={flag.placeholder}
|
||||
title={flag.placeholder}
|
||||
min={flag.min}
|
||||
max={flag.max}
|
||||
step={flag.step}
|
||||
>
|
||||
{:else if !flag.multiline}
|
||||
<input
|
||||
type="text"
|
||||
class="form-control flag"
|
||||
id="sol_{flag.type}{flag.id}_{index}"
|
||||
autocomplete="off"
|
||||
bind:value={values[index]}
|
||||
placeholder={flag.placeholder}
|
||||
title={flag.placeholder}
|
||||
on:keydown={(e) => {if (flag.separator && e.keyCode === 13) { e.preventDefault(); addItem(); tick().then(() => { document.getElementById('sol_' + flag.type + '' + flag.id + '_' + (values.length - 1)).focus(); }); return false;}}}
|
||||
>
|
||||
{:else}
|
||||
<textarea
|
||||
class="form-control flag"
|
||||
id="sol_{flag.type}{flag.id}_{index}"
|
||||
autocomplete="off"
|
||||
bind:value={values[index]}
|
||||
placeholder="{flag.placeholder}"
|
||||
title="{flag.placeholder}"
|
||||
></textarea>
|
||||
{/if}
|
||||
{#if flag.unit}
|
||||
<span class="input-group-text">{flag.unit}</span>
|
||||
{/if}
|
||||
{#if flag.choices_cost > 0}
|
||||
<Button
|
||||
color="success"
|
||||
type="button"
|
||||
on:click={wantchoices}
|
||||
disabled={wcsubmitted || $settings.disablesubmitbutton}
|
||||
title="Cliquez pour échanger ce champ de texte par une liste de choix. L'opération vous coûtera {flag.choices_cost * $settings.wchoiceCurrentCoefficient} points."
|
||||
>
|
||||
{#if wcsubmitted}
|
||||
<Spinner size="sm" class="me-2" />
|
||||
{/if}
|
||||
<Icon name="list-task" />
|
||||
Liste de propositions ({flag.choices_cost * $settings.wchoiceCurrentCoefficient} {flag.choices_cost * $settings.wchoiceCurrentCoefficient===1?"point":"points"})
|
||||
</Button>
|
||||
{:else if flag.separator && !flag.nb_lines && index == values.length - 1}
|
||||
<Button
|
||||
color="success"
|
||||
type="button"
|
||||
title="Ajouter un élément."
|
||||
on:click={addItem}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if flag.type == 'radio'}
|
||||
{#each Object.keys(flag.choices) as l, i}
|
||||
<div class="form-check">
|
||||
<input
|
||||
id="sol_{flag.type}{flag.id}_{index}_{i}"
|
||||
type="radio"
|
||||
value={l}
|
||||
bind:group={values[index]}
|
||||
class="form-check-input"
|
||||
>
|
||||
<label
|
||||
class="form-check-label"
|
||||
for="sol_{flag.type}{flag.id}_{index}_{i}"
|
||||
>
|
||||
{flag.choices[l]}
|
||||
</label>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<select
|
||||
class="form-select"
|
||||
id="sol_{flag.type}{flag.id}_{index}"
|
||||
bind:value={values[index]}
|
||||
>
|
||||
{#each Object.keys(flag.choices) as l}
|
||||
<option value={l}>{flag.choices[l]}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if flag.help}
|
||||
<small class="form-text text-muted">{@html flag.help}</small>
|
||||
{/if}
|
||||
{:else}
|
||||
<Icon
|
||||
name="check"
|
||||
class="form-control-feedback text-success"
|
||||
aria-hidden="true"
|
||||
title="Flag trouvé à {flag.found}"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
47
frontend/ui/src/lib/components/FlagMCQ.svelte
Normal file
47
frontend/ui/src/lib/components/FlagMCQ.svelte
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<script>
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import FlagKey from './FlagKey.svelte';
|
||||
|
||||
export let exercice_id = 0;
|
||||
export let flag = { };
|
||||
export let values = { };
|
||||
export let justifications = { };
|
||||
</script>
|
||||
|
||||
{#if flag.label}
|
||||
<p class="mb-1">
|
||||
{flag.label} :
|
||||
{#if flag.found}
|
||||
<Icon name="check" class="form-control-feedback text-success" aria-hidden="true" title="QCM réussi à {flag.solved}" />
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{#if !flag.found || flag.justify}
|
||||
{#each Object.keys(flag.choices) as cid, index}
|
||||
<div class="form-check ms-3">
|
||||
<input class="form-check-input" type="checkbox" id="mcq_{flag.id}_{cid}" bind:checked={values[Number(cid)]} disabled={flag.found}>
|
||||
<label class="form-check-label" for="mcq_{flag.id}_{cid}">
|
||||
{#if typeof flag.choices[cid] == "Object"}
|
||||
{flag.choices[cid].label}
|
||||
{:else}
|
||||
{flag.choices[cid]}
|
||||
{/if}
|
||||
</label>
|
||||
{#if values[Number(cid)] && flag.justify && (!flag.choices[cid].justification || !flag.choices[cid].justification.solved)}
|
||||
<FlagKey
|
||||
{exercice_id}
|
||||
flag={flag.choices[cid].justification}
|
||||
bind:values={justifications[flag.choices[cid].justification.id]}
|
||||
/>
|
||||
{/if}
|
||||
{#if flag.choices[cid].justification && flag.choices[cid].justification.solved}
|
||||
<Icon name="check" class="form-control-feedback text-success" aria-hidden="true" title="Flag trouvé !" />
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
<hr>
|
||||
48
frontend/ui/src/lib/components/FormIssue.svelte
Normal file
48
frontend/ui/src/lib/components/FormIssue.svelte
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<script>
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { issues, issues_idx } from '$lib/stores/issues.js';
|
||||
import { settings } from '$lib/stores/settings.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" disabled={$settings.disablesubmitbutton}>
|
||||
Envoyer le rapport
|
||||
</Button>
|
||||
</form>
|
||||
123
frontend/ui/src/lib/components/Header.svelte
Normal file
123
frontend/ui/src/lib/components/Header.svelte
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<script>
|
||||
import { base } from '$app/paths';
|
||||
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Col,
|
||||
Collapse,
|
||||
Container,
|
||||
Icon,
|
||||
Navbar,
|
||||
NavbarToggler,
|
||||
NavbarBrand,
|
||||
Nav,
|
||||
NavItem,
|
||||
NavLink,
|
||||
Progress,
|
||||
Row,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { challengeInfo } from '$lib/stores/challengeinfo.js';
|
||||
import { my } from '$lib/stores/my.js';
|
||||
import { teams } from '$lib/stores/teams.js';
|
||||
import { settings, time } from '$lib/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="." style="max-width: 50%">
|
||||
{#if $challengeInfo && $challengeInfo.main_logo}
|
||||
{#each $challengeInfo.main_logo as logo, i}
|
||||
<img src={logo.replace('$FILES$', base + '/files/')} alt={'Logo principal #' + i} class={'h-100' + (i > 0?' d-none d-md-inline ms-2':'')}>
|
||||
{/each}
|
||||
{/if}
|
||||
</a>
|
||||
<HeaderPartners />
|
||||
</Container>
|
||||
</div>
|
||||
<div class="sticky-top">
|
||||
<Navbar 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 && $teams && Object.keys($teams).length}
|
||||
<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>
|
||||
{Math.round($my.score*100)/100} {$my.score === 1 ? 'point' : 'points'}
|
||||
{#if $teams && $teams[$my.team_id] && $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}
|
||||
{#if $teams && $teams[$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>
|
||||
{:else}
|
||||
<Badge href="edit" color="info">
|
||||
<span class="teamname">{$my.name}</span>
|
||||
</Badge>
|
||||
{/if}
|
||||
{/if}
|
||||
</NavItem>
|
||||
</Nav>
|
||||
</Collapse>
|
||||
</Navbar>
|
||||
<Progress value={$time.progression * 100} color="info" style="height: 5px; border-radius: 0;" />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.teamname {
|
||||
-webkit-filter: invert(100%);
|
||||
filter: invert(100%);
|
||||
}
|
||||
</style>
|
||||
111
frontend/ui/src/lib/components/HeaderClock.svelte
Normal file
111
frontend/ui/src/lib/components/HeaderClock.svelte
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<script>
|
||||
import {
|
||||
ButtonGroup,
|
||||
Icon,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { challengeInfo } from '$lib/stores/challengeinfo.js';
|
||||
import { settings, time } from '$lib/stores/settings.js';
|
||||
</script>
|
||||
|
||||
{#if $settings}
|
||||
{#if $settings.end - $settings.start > 0}
|
||||
<div
|
||||
class="clock display-2 text-end text-md-center"
|
||||
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="{$challengeInfo.main_link}"
|
||||
class="btn btn-light"
|
||||
class:disabled={$challengeInfo.main_link === ''}
|
||||
>
|
||||
<Icon name="ui-checks-grid" />
|
||||
Accueil
|
||||
</a>
|
||||
<a
|
||||
href="rank"
|
||||
class="btn btn-light"
|
||||
>
|
||||
<Icon name="sort-down" />
|
||||
Classement
|
||||
</a>
|
||||
<a
|
||||
href="{$challengeInfo.videoslink}"
|
||||
class="btn btn-light"
|
||||
class:disabled={$challengeInfo.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/lib/components/HeaderIssues.svelte
Normal file
34
frontend/ui/src/lib/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 '$lib/stores/issues.js';
|
||||
import { settings } from '$lib/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}
|
||||
30
frontend/ui/src/lib/components/HeaderPartners.svelte
Normal file
30
frontend/ui/src/lib/components/HeaderPartners.svelte
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<script>
|
||||
import { base } from '$app/paths';
|
||||
|
||||
import {
|
||||
Carousel,
|
||||
CarouselItem,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { challengeInfo } from '$lib/stores/challengeinfo.js';
|
||||
|
||||
let activePartner = 0;
|
||||
</script>
|
||||
|
||||
{#if $challengeInfo && $challengeInfo.partners}
|
||||
<Carousel items={$challengeInfo.partners} bind:activeIndex={activePartner} ride="carousel" pause="hover" interval={25000}>
|
||||
<div class="carousel-inner h-100">
|
||||
{#each $challengeInfo.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.replace('$FILES$', base + '/files')} class="h-100" alt={partner.alt}>
|
||||
</a>
|
||||
{:else}
|
||||
<img src={partner.img.replace('$FILES$', base + '/files')} class="h-100" alt={partner.alt}>
|
||||
{/if}
|
||||
</CarouselItem>
|
||||
{/each}
|
||||
</div>
|
||||
</Carousel>
|
||||
{/if}
|
||||
49
frontend/ui/src/lib/components/NavTags.svelte
Normal file
49
frontend/ui/src/lib/components/NavTags.svelte
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<script>
|
||||
import {
|
||||
Badge,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownToggle,
|
||||
Icon,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { my } from '$lib/stores/my.js';
|
||||
import { tags } from '$lib/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(function (a, b) { return a.toLowerCase().localeCompare(b.toLowerCase()); }) 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>
|
||||
50
frontend/ui/src/lib/components/NavThemes.svelte
Normal file
50
frontend/ui/src/lib/components/NavThemes.svelte
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<script>
|
||||
import {
|
||||
Badge,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownToggle,
|
||||
Icon,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { my } from '$lib/stores/my.js';
|
||||
import { max_solved } from '$lib/stores/themes.js';
|
||||
import { myThemes, themes } from '$lib/stores/mythemes.js';
|
||||
</script>
|
||||
|
||||
<Dropdown nav inNavbar>
|
||||
<DropdownToggle nav caret>
|
||||
<Icon name="tv" />
|
||||
Scenarii
|
||||
</DropdownToggle>
|
||||
<DropdownMenu class="niceborder">
|
||||
<div>
|
||||
{#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[$themes[th].id].exercice_solved}/{/if}{$themes[th].exercice_count}
|
||||
</Badge>
|
||||
</DropdownItem>
|
||||
{/each}
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
|
||||
<style>
|
||||
div {
|
||||
overflow-y: auto;
|
||||
max-height: calc(80vh - 100px);
|
||||
}
|
||||
</style>
|
||||
112
frontend/ui/src/lib/components/RegistrationFormCreateTeam.svelte
Normal file
112
frontend/ui/src/lib/components/RegistrationFormCreateTeam.svelte
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Container,
|
||||
Icon,
|
||||
Row,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { settings } from '$lib/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 {
|
||||
// Ensure jTeam, defined by RegistrationFormJoinTeam is not defined
|
||||
delete value.jTeam;
|
||||
|
||||
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>
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Container,
|
||||
Icon,
|
||||
Row,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { settings } from '$lib/stores/settings.js';
|
||||
import { teams } from '$lib/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/lib/components/RegistrationRowMember.svelte
Normal file
36
frontend/ui/src/lib/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>
|
||||
84
frontend/ui/src/lib/components/ScoreGrid.svelte
Normal file
84
frontend/ui/src/lib/components/ScoreGrid.svelte
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<script>
|
||||
import {
|
||||
Badge,
|
||||
CardBody,
|
||||
Column,
|
||||
Icon,
|
||||
Table,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import DateFormat from '$lib/components/DateFormat.svelte';
|
||||
|
||||
import { my } from '$lib/stores/my.js';
|
||||
import { themes } from '$lib/stores/themes.js';
|
||||
|
||||
let req = null;
|
||||
function refresh_scores() {
|
||||
req = fetch('scores.json', {headers: {'Accept': 'application/json'}}).then((data) => data.json());
|
||||
}
|
||||
refresh_scores();
|
||||
|
||||
export { className as class };
|
||||
let className = '';
|
||||
</script>
|
||||
|
||||
{#await req}
|
||||
<CardBody>
|
||||
Veuillez patienter &hellips;
|
||||
</CardBody>
|
||||
{:then scores}
|
||||
{#if scores}
|
||||
<Table class="mb-0" hover striped rows={scores} let:row>
|
||||
<Column header="Heure">
|
||||
<DateFormat date={new Date(row.time)} />
|
||||
</Column>
|
||||
<Column header="Raison">
|
||||
{#if row.reason == "Validation"}
|
||||
<Badge color="success"><Icon name="check" /></Badge>
|
||||
Étape validée
|
||||
{:else if row.reason == "First blood"}
|
||||
<Badge color="light"><Icon name="trophy" /></Badge>
|
||||
Bonus premier sang
|
||||
{:else if row.reason == "Bonus flag"}
|
||||
<Badge color="danger"><Icon name="flag-fill" /></Badge>
|
||||
Flag bonus complété
|
||||
{:else if row.reason == "Tries"}
|
||||
<Badge color="warning"><Icon name="backspace" /></Badge>
|
||||
Malus nombre de tentatives
|
||||
{:else if row.reason == "Hint"}
|
||||
<Badge color="info"><Icon name="lightbulb" /></Badge>
|
||||
Indice dévoilé
|
||||
{:else if row.reason == "Display choices"}
|
||||
<Badge color="secondary"><Icon name="info-square" /></Badge>
|
||||
Échange champ de texte contre liste de choix
|
||||
{:else}
|
||||
<Badge color="primary"><Icon name="question" /></Badge>
|
||||
{row.reason}
|
||||
{/if}
|
||||
{#if row.id_exercice && $my.exercices[row.id_exercice]}
|
||||
sur <a href="/{$themes[$my.exercices[row.id_exercice].theme_id].urlid}/{$themes[$my.exercices[row.id_exercice].theme_id].exercices[row.id_exercice].urlid}">
|
||||
{$themes[$my.exercices[row.id_exercice].theme_id].exercices[row.id_exercice].title}
|
||||
</a>
|
||||
{/if}
|
||||
</Column>
|
||||
<Column header="Détail">
|
||||
<span title="Valeur initiale (cette valeur est fixe)">{Math.trunc(10*row.points)/10}</span> × <span title="Coefficient multiplicateur (il varie selon les événements en cours sur la plateforme)">{row.coeff}</span>
|
||||
</Column>
|
||||
<Column header="Points">
|
||||
{Math.trunc(10*row.points * row.coeff)/10}
|
||||
</Column>
|
||||
</Table>
|
||||
{:else}
|
||||
Vous n'avez fait aucune action vous faisant gagner ou perdre des points.
|
||||
{/if}
|
||||
<button class="btn btn-primary" on:click={refresh_scores}>
|
||||
<Icon name="arrow-clockwise" />
|
||||
</button>
|
||||
{:catch error}
|
||||
<CardBody>
|
||||
Une erreur s'est produite: {JSON.stringify(error)}
|
||||
</CardBody>
|
||||
<button class="btn btn-primary" on:click={refresh_scores}>
|
||||
<Icon name="arrow-clockwise" />
|
||||
</button>
|
||||
{/await}
|
||||
109
frontend/ui/src/lib/components/TeamChangeName.svelte
Normal file
109
frontend/ui/src/lib/components/TeamChangeName.svelte
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<script>
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Icon,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { my } from '$lib/stores/my.js';
|
||||
import { settings } from '$lib/stores/settings.js';
|
||||
|
||||
let newTeamName = "";
|
||||
|
||||
function gotoHomeOnDiff(i) {
|
||||
my.refresh((my) => {
|
||||
if (my && my.name == newTeamName) {
|
||||
newTeamName = "";
|
||||
messageClass = "info";
|
||||
sberr = "Votre nom d'équipe a été changé avec succès.";
|
||||
message = "";
|
||||
} else if (i > 0) {
|
||||
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",
|
||||
headers: {'Accept': 'application/json'},
|
||||
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" disabled={$settings.disablesubmitbutton}>Valider</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
37
frontend/ui/src/lib/components/TeamMembers.svelte
Normal file
37
frontend/ui/src/lib/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 && 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>
|
||||
57
frontend/ui/src/lib/components/ThemeNav.svelte
Normal file
57
frontend/ui/src/lib/components/ThemeNav.svelte
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<script>
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
Card,
|
||||
Icon,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { my } from '$lib/stores/my.js';
|
||||
|
||||
export let theme = {};
|
||||
export let exercice = {};
|
||||
</script>
|
||||
|
||||
<Breadcrumb listClassName="mb-0 px-3 py-2">
|
||||
{#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>
|
||||
|
||||
<style>
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
a[href]:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
Reference in a new issue