From e260ab122dd7dcf3eb63bba1f8f3b210c65bd416 Mon Sep 17 00:00:00 2001 From: Pierre-Alexandre Martin <pierre-alexandre.martin@imt-atlantique.net> Date: Tue, 25 Feb 2025 16:02:30 +0100 Subject: [PATCH] =?UTF-8?q?Ajout=20des=20;=20au=20linter.=20Ajout=20d'une?= =?UTF-8?q?=20premi=C3=A8re=20version=20d'arrangement=20des=20=C3=A9tapes?= =?UTF-8?q?=20Mise=20=C3=A0=20jour=20des=20mod=C3=A8les=20de=20l'API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitlab-ci.yml | 7 + app.config.ts | 2 +- app.vue | 2 +- components/AccountPlaceholder.vue | 1 + components/AppHeader.vue | 4 +- components/ColorModeButton.vue | 8 +- components/Parcours/Details.vue | 87 ++++++--- components/Parcours/ExerciceItem.vue | 6 +- components/Parcours/Order.vue | 174 ++++++++++++++++++ components/Parcours/Step.vue | 93 ++++++---- eslint.config.mjs | 7 +- middleware/auth.ts | 7 +- nuxt.config.ts | 2 +- package-lock.json | 21 ++- package.json | 3 +- pages/about.vue | 4 +- pages/exercise/[id].vue | 24 +-- pages/exercises.vue | 36 ++-- pages/index.vue | 4 +- pages/login.vue | 14 +- pages/parcours.vue | 32 +++- plugins/vue3-smooth-dnd.client.ts | 7 + public/svg/pinfull.svg | 10 + repository/module/exercise.ts | 13 +- repository/module/step.ts | 4 +- repository/module/user.ts | 2 +- server/model/IExercise.ts | 8 +- server/model/IStep.ts | 16 +- server/routes/auth/login.post.ts | 6 +- server/routes/exercise/.delete.ts | 8 +- server/routes/exercise/.get.ts | 4 +- server/routes/exercise/.patch.ts | 17 +- server/routes/exercise/.post.ts | 12 +- .../exercise/fetch-sub-categories.get.ts | 4 +- server/routes/step/fetch-in-area.post.ts | 8 +- server/utils/mapper/ExerciseMapper.test.ts | 64 +++---- server/utils/mapper/ExerciseMapper.ts | 17 +- server/utils/mapper/StepMapper.test.ts | 72 ++++---- server/utils/mapper/StepMapper.ts | 28 +-- server/utils/mapper/UserMapper.test.ts | 4 +- server/utils/mapper/UserMapper.ts | 6 +- types/Leaflet.ts | 15 ++ types/Step.ts | 1 + types/enum/ExerciseCategory.ts | 14 +- types/vue3-smooth-dnd.d.ts | 42 +++++ utils/MapUtils.ts | 17 ++ vitest.config.ts | 4 +- 47 files changed, 644 insertions(+), 297 deletions(-) create mode 100644 components/Parcours/Order.vue create mode 100644 plugins/vue3-smooth-dnd.client.ts create mode 100644 public/svg/pinfull.svg create mode 100644 types/Leaflet.ts create mode 100644 types/vue3-smooth-dnd.d.ts create mode 100644 utils/MapUtils.ts diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 81e4d16..a3f2401 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -72,6 +72,13 @@ lint: script: - npm run lint +check-updates: + stage: test + script: + - UPDATES=$(npx npm-check-updates | tail -n +2) # Ignorer la première ligne de sortie + - if [ ! -z "$UPDATES" ]; then exit 1; fi # Retourner une erreur si des mises à jour sont disponibles + allow_failure: true # Permet de remonter un warning et non une erreur + build-image: stage: docker rules: diff --git a/app.config.ts b/app.config.ts index c7a91d6..ae7894b 100644 --- a/app.config.ts +++ b/app.config.ts @@ -43,5 +43,5 @@ export default defineAppConfig({ accent: 'accent', } } -}) +}); diff --git a/app.vue b/app.vue index 90d2c9e..3f6bfd1 100644 --- a/app.vue +++ b/app.vue @@ -5,7 +5,7 @@ useHead({ titleTemplate: (titleChunk) => { return titleChunk ? `3SPA | ${titleChunk}` : '3SPA'; } -}) +}); </script> <template> diff --git a/components/AccountPlaceholder.vue b/components/AccountPlaceholder.vue index a155f14..67d5282 100644 --- a/components/AccountPlaceholder.vue +++ b/components/AccountPlaceholder.vue @@ -2,6 +2,7 @@ import userData from '~/data/mock/user.json'; import {UserMapper} from "~/server/utils/mapper/UserMapper"; +// FIXME : The APi should be able to return these data, currently only mocked const user = ref<User>(UserMapper.from(userData)); function getStatus(): "error" | "success" | "warning" | "neutral" | "primary" | "secondary" | "info" | undefined { diff --git a/components/AppHeader.vue b/components/AppHeader.vue index 15d612b..a91851d 100644 --- a/components/AppHeader.vue +++ b/components/AppHeader.vue @@ -1,6 +1,6 @@ <script setup lang="ts"> -const appConfig = useAppConfig() -const open = ref(false) +const appConfig = useAppConfig(); +const open = ref(false); </script> <template> diff --git a/components/ColorModeButton.vue b/components/ColorModeButton.vue index 5018ac3..e2b94dc 100644 --- a/components/ColorModeButton.vue +++ b/components/ColorModeButton.vue @@ -1,13 +1,13 @@ <script setup> -const colorMode = useColorMode() +const colorMode = useColorMode(); const isDark = computed({ get() { - return colorMode.value === 'dark' + return colorMode.value === 'dark'; }, set() { - colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark' + colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'; } -}) +}); </script> <template> diff --git a/components/Parcours/Details.vue b/components/Parcours/Details.vue index 8a456cf..2f0662c 100644 --- a/components/Parcours/Details.vue +++ b/components/Parcours/Details.vue @@ -1,42 +1,81 @@ <script setup lang="ts"> -import type {PointTuple} from "leaflet"; -import * as v from 'valibot' -import type {FormSubmitEvent} from '@nuxt/ui' +import _L, {type PointTuple} from "leaflet"; +import 'leaflet.markercluster'; +import * as v from 'valibot'; +import {setNumberedIcon} from "~/utils/MapUtils"; +import type {FormSubmitEvent} from '@nuxt/ui'; +import type {Exercise} from "~/types/Exercise"; +import type {Step} from "~/types/Step"; +import type {MarkerProps} from "~/types/Leaflet"; -const toast = useToast() +const props = defineProps<{ + selectedExercises: {step: Step, exercise: Exercise}[] +}>(); + +const toast = useToast(); const schema = v.object({ parcoursTitle: v.pipe(v.string(), v.nonEmpty('Titre obligatoire')), localization: v.pipe(v.string(), v.nonEmpty('Localisation obligatoire')), duration: v.pipe(v.string(), v.isoTime('Invalid time format')) -}) +}); type Schema = v.InferOutput<typeof schema> -const map = ref(null) as any; +const map = ref<any>(null); const state = reactive({ parcoursTitle: '', localization: '', duration: '00:30' -}) -// TODO : calculate these datas from the data -const zoom = ref(16.5) -const maxBounds = [[47.283, -1.53], [47.29, -1.52]] -const center: PointTuple = [47.287, -1.524] +}); + +const zoom = ref(17); +// get min max bounds from all the selected exercises +const maxBounds = props.selectedExercises.reduce((acc, se) => { + const step = se.step; + return [ + [Math.min(acc[0][0], step.latitude), Math.min(acc[0][1], step.longitude)], + [Math.max(acc[1][0], step.latitude), Math.max(acc[1][1], step.longitude)] + ]; +}, [[Infinity, Infinity], [-Infinity, -Infinity]]); +const center: PointTuple = props.selectedExercises.reduce((acc, se) => { + const step = se.step; + return [acc[0] + step.latitude, acc[1] + step.longitude]; +}, [0, 0]).map(c => c / props.selectedExercises.length) as PointTuple; async function onSubmit(event: FormSubmitEvent<Schema>) { - toast.add({title: 'Success', description: 'The form has been submitted.', color: 'success'}) + toast.add({title: 'Success', description: 'The form has been submitted.', color: 'success'}); state.parcoursTitle = ''; state.localization = ''; - state.duration = '00:30' + state.duration = '00:30'; // TODO : Send the data to the API // $api.parcours.createParcours(state) - console.log(event.data) + console.log(event.data); +} + +async function onMapReady() { + await useLMarkerCluster({ + leafletObject: map?.value?.leafletObject, + options: { + disableClusteringAtZoom: zoom, + }, + markers: props.selectedExercises.map(se => { + return { + name: `exercise-item-${se.step.id}-${se.exercise.id}`, + popup: se.exercise.name, + lat: se.step.latitude, + lng: se.step.longitude, + options: { + icon: setNumberedIcon((se.step.stepOrder + 1).toString(), [30, 30]) + } + }; + }) as MarkerProps[] + }); } </script> <template> - <UForm :schema="v.safeParser(schema)" :state="state" class="space-y-4 grid-container" @submit="onSubmit"> + <UForm id="details-grid" :schema="v.safeParser(schema)" :state="state" class="space-y-4" @submit="onSubmit"> <UFormField label="Titre du parcours" name="parcoursTitle" class="col-span-2"> <UInput v-model="state.parcoursTitle" placeholder="Titre du parcours" class="w-full" @@ -53,7 +92,7 @@ async function onSubmit(event: FormSubmitEvent<Schema>) { :center="center" :max-bounds="maxBounds" :use-global-leaflet="true" - :options="{ zoomSnap: 0.75 }" + @ready="onMapReady" > <LTileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" @@ -73,9 +112,12 @@ async function onSubmit(event: FormSubmitEvent<Schema>) { <UInput v-model="state.duration" type="time" class="w-full" style="margin-right: 20px"/> </UFormField> - <div class="grid-item col-span-2"> - <a href="https://github.com/kutlugsahin/vue-smooth-dnd?tab=readme-ov-file">Implements drag and drop from - this library</a> + <div class="flex flex-col h-full overflow-auto gap-1 col-span-2 p-1"> + <ParcoursExerciceItem + v-for="exercise in props.selectedExercises.map(se => se.exercise)" :id="'exercise-item-'+exercise.relatedStep+'-'+exercise.id" + :key="exercise.id" ref="list" + :exercise="exercise as Exercise" + class="skeletton gap-4"/> </div> <UButton type="submit" class="col-span-3 w-fit justify-self-center"> @@ -85,16 +127,11 @@ async function onSubmit(event: FormSubmitEvent<Schema>) { </template> <style> -.grid-container { +#details-grid { display: grid; grid-template-columns: 3fr 1fr 4fr; grid-template-rows: 1fr 1fr 6fr auto; height: 65vh; gap: 1px; } - -.grid-item { - background-color: lightgray; - border: 1px solid gray; -} </style> \ No newline at end of file diff --git a/components/Parcours/ExerciceItem.vue b/components/Parcours/ExerciceItem.vue index 76927cf..a7b01ab 100644 --- a/components/Parcours/ExerciceItem.vue +++ b/components/Parcours/ExerciceItem.vue @@ -4,13 +4,13 @@ import {getExerciseCategoryIcon, translateExerciseCategoryToFrench} from "~/type const props = defineProps<{ exercise: Exercise -}>() +}>(); function parseExerciseType(): string { if (props.exercise.type === 'specific') { - return 'Exercice spécifique' + return 'Exercice spécifique'; } else { - return 'Terrain ' + (props.exercise.type?.replace('generic', '').replace(/\s+/g, ' ') ?? '') + return 'Terrain ' + (props.exercise.type?.replace('generic', '').replace(/\s+/g, ' ') ?? ''); } } </script> diff --git a/components/Parcours/Order.vue b/components/Parcours/Order.vue new file mode 100644 index 0000000..7d58e7c --- /dev/null +++ b/components/Parcours/Order.vue @@ -0,0 +1,174 @@ +<script setup lang="ts"> +import type {LeafletMouseEvent, Marker, PointTuple} from "leaflet"; +import type {DropResult} from "vue3-smooth-dnd"; +import {setNumberedIcon} from "~/utils/MapUtils"; +import type {Exercise} from "~/types/Exercise"; +import type {Step} from "~/types/Step"; +import type {MarkerCluster, MarkerProps} from "~/types/Leaflet"; + +const props = defineProps<{ + selectedExercises: {step: Step, exercise: Exercise}[] +}>(); +const emit = defineEmits(['update:selectedExercises']); + +const map = ref<any>(null); +const exerciseSearch = ref(''); +const zoom = ref(17); +// get min max bounds from all the selected exercises +const maxBounds = props.selectedExercises.reduce((acc, se) => { + const step = se.step; + return [ + [Math.min(acc[0][0], step.latitude), Math.min(acc[0][1], step.longitude)], + [Math.max(acc[1][0], step.latitude), Math.max(acc[1][1], step.longitude)] + ]; +}, [[Infinity, Infinity], [-Infinity, -Infinity]]); +const center: PointTuple = props.selectedExercises.reduce((acc, se) => { + const step = se.step; + return [acc[0] + step.latitude, acc[1] + step.longitude]; +}, [0, 0]).map(c => c / props.selectedExercises.length) as PointTuple; +let markerCluster: MarkerCluster; + + +// Map utilities +async function onDrop(dropResult: DropResult) { + const { removedIndex, addedIndex, payload } = dropResult; + if (removedIndex === null && addedIndex === null) return; + + const updatedExercises = [...props.selectedExercises]; + const itemToMove = removedIndex !== null ? updatedExercises.splice(removedIndex, 1)[0] : payload; + + if (addedIndex !== null) { + updatedExercises.splice(addedIndex, 0, itemToMove); + updatedExercises.forEach((se, index) => se.step.stepOrder = index); + } + + emit('update:selectedExercises', updatedExercises); + await createCluster(); +} +async function createCluster() { + markerCluster?.markerCluster.clearLayers(); + markerCluster = await useLMarkerCluster({ + leafletObject: map?.value?.leafletObject, + options: { + disableClusteringAtZoom: zoom, + }, + markers: props.selectedExercises.map(se => { + return { + name: `exercise-item-${se.step.id}-${se.exercise.id}`, + popup: se.exercise.name, + lat: se.step.latitude, + lng: se.step.longitude, + options: { + icon: setNumberedIcon((se.step.stepOrder + 1).toString(), [30, 30]) + } + }; + }) as MarkerProps[] + }); + + markerCluster.markers.forEach((marker: Marker) => { + marker.on('mouseover', onMouseoverMarker); + marker.on('mouseout', onMouseoutMarker); + }); +} +function filterExercise(): {step: Step, exercise: Exercise}[] { + return props.selectedExercises.filter((se) => { + return se.exercise.name.toLowerCase().includes(exerciseSearch.value.toLowerCase()); + }); +} + +// Events - Markers +function onMouseoverMarker(e: LeafletMouseEvent): void { + document.getElementById(e.target.options.title)?.classList.add('exercise-hoovered'); +} +function onMouseoutMarker(e: LeafletMouseEvent): void { + document.getElementById(e.target.options.title)?.classList.remove('exercise-hoovered'); +} + +// Events - Exercises +function onMouseoverExercise(se: {step: Step, exercise: Exercise}): void { + markerCluster.markers.forEach((marker: Marker) => { + if (marker.getLatLng().lat === se.step.latitude && marker.getLatLng().lng === se.step.longitude) { + marker.setIcon(setNumberedIcon((se.step.stepOrder + 1).toString(), [45, 45])); + } + }); +} +function onMouseoutExercise(se: {step: Step, exercise: Exercise}): void { + markerCluster.markers.forEach((marker: Marker) => { + if (marker.getLatLng().lat === se.step.latitude && marker.getLatLng().lng === se.step.longitude) { + marker.setIcon(setNumberedIcon((se.step.stepOrder + 1).toString(), [30, 30])); + } + }); +} + +async function onMapReady() { + await createCluster(); +} +</script> + +<template> + <div id="order-grid"> + <div id="side" class="flex flex-col w-full h-full p-2 overflow-hidden"> + <UInput + id="search-input" + v-model="exerciseSearch" + icon="material-symbols:search" + size="md" variant="outline" placeholder="" + :ui="{ base: 'peer' }"> + <label + for="search-input" + class="pointer-events-none absolute left-6 peer-focus:left-1 -top-2.5 text-[var(--ui-text-highlighted)] text-xs font-medium px-1.5 transition-all + peer-focus:-top-2.5 peer-focus:text-[var(--ui-text-highlighted)] peer-focus:text-xs peer-focus:font-medium + peer-placeholder-shown:text-sm peer-placeholder-shown:text-[var(--ui-text-dimmed)] peer-placeholder-shown:top-1.5 peer-placeholder-shown:font-normal"> + <span class="inline-flex bg-[var(--ui-bg)] px-1">Rechercher un exercice</span> + </label> + </UInput> + <USeparator class="m-1 w-full"/> + <div id="exercices-list" class="flex flex-col w-full h-full overflow-auto gap-3"> + <Container @drop="onDrop"> + <Draggable v-for="item in filterExercise()" :key="item.step.id *100 + item.exercise.id"> + <ParcoursExerciceItem + :id="`exercise-item-${item.step.id}-${item.exercise.id}`" + :exercise="item.exercise as Exercise" + class="skeletton gap-4 draggable-item" + @mouseover="onMouseoverExercise(item)" @mouseleave="onMouseoutExercise(item)" + /> + </Draggable> + </Container> + + </div> + </div> + <LMap + ref="map" + class="row-span-3" + style="height: auto;" + :zoom="zoom" + :min-zoom="zoom" + :max-zoom="zoom" + :center="center" + :max-bounds="maxBounds" + :use-global-leaflet="true" + @ready="onMapReady" + > + <LTileLayer + url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" + attribution="&copy; <a href="https://www.openstreetmap.org/">OpenStreetMap</a>" + layer-type="base" + name="OpenStreetMap" + /> + </LMap> + </div> +</template> + +<style scoped> +#order-grid { + display: grid; + grid-template-columns: 1fr 1fr; + height: 65vh; + gap: 1px; +} +.exercise-hoovered { + background-color: color-mix(in oklab, var(--color-background-500) 50%, transparent); + border-radius: 5px; + transform: scale(1.05); +} +</style> \ No newline at end of file diff --git a/components/Parcours/Step.vue b/components/Parcours/Step.vue index ea55872..15550f6 100644 --- a/components/Parcours/Step.vue +++ b/components/Parcours/Step.vue @@ -1,21 +1,15 @@ <script setup lang="ts"> +import L, {type LeafletMouseEvent, Marker, type PointTuple} from 'leaflet'; import 'leaflet.markercluster'; -import L, {type LeafletMouseEvent, Marker, type MarkerOptions, type PointTuple} from 'leaflet'; import type {Exercise} from "~/types/Exercise"; import type {Step} from "~/types/Step"; +import type {MarkerCluster, MarkerProps} from "~/types/Leaflet"; -// TODO : Move to a separate file -interface MarkerCluster { - markerCluster: L.LayerGroup; - markers: Marker[]; -} -interface MarkerProps { - name?: string, - lat: number, - lng: number, - options: MarkerOptions, - popup?: string -} +// Parent props +const props = defineProps<{ + selectedExercises: {step: Step, exercise: Exercise}[] +}>(); +const emit = defineEmits(['update:selectedExercises']); const {$api} = useNuxtApp(); const toast = useToast(); @@ -29,12 +23,12 @@ let markerCluster: MarkerCluster; let createCluster: MarkerCluster; const exercises = ref<Exercise[]>([]); -const selectedExercises: {step: Step, exercise: Exercise}[] = []; const steps = ref<Step[]>([]); +let counter = props.selectedExercises.length; // TODO : Make it dynamic -const zoom = ref(17) -const center: PointTuple = [47.287, -1.524] +const zoom = ref(17); +const center: PointTuple = [47.287, -1.524]; // TODO : maybe move to a separate file const contextMenuItems = ref([ @@ -46,7 +40,7 @@ const contextMenuItems = ref([ } }, ], -]) +]); async function updateLocations(): Promise<void> { locations.value = steps.value.map((step) => { @@ -60,27 +54,27 @@ async function updateLocations(): Promise<void> { iconSize: [30, 30], }) } - } - }) + }; + }); markerCluster?.markerCluster?.clearLayers(); steps.value.map((step) => { step.specificExercises.map((exo) => { - exo.selected = selectedExercises.find((se) => se.step.id === step.id && se.exercise.id === exo.id) !== undefined; + exo.selected = props.selectedExercises.find((se) => se.step.id === step.id && se.exercise.id === exo.id) !== undefined; }); step.terrainType.genericExercises.map((exo) => { - exo.selected = selectedExercises.find((se) => se.step.id === step.id && se.exercise.id === exo.id) !== undefined; + exo.selected = props.selectedExercises.find((se) => se.step.id === step.id && se.exercise.id === exo.id) !== undefined; }); }); markerCluster = await useLMarkerCluster({ leafletObject: map?.value?.leafletObject, markers: locations.value as MarkerProps[] - }) + }); markerCluster.markers.forEach((marker: Marker) => { marker.on('click', onMarkerSelect); marker.on('mouseover', onMouseoverMarker); marker.on('mouseout', onMouseoutMarker); - }) + }); } function filterExercise(): Exercise[] { @@ -121,7 +115,19 @@ function onMouseoutMarker(e: LeafletMouseEvent): void { // Events - Exercises function onExerciceSelect(exo: Exercise): void { if (exo.selected) { - selectedExercises.find((se) => se.step.id === exo.relatedStep && se.exercise.id === exo.id); + const index = props.selectedExercises.findIndex((se) => se.step.id === exo.relatedStep && se.exercise.id === exo.id); + if (index !== -1) { + const updatedExercises = [ + ...props.selectedExercises.slice(0, index), + ...props.selectedExercises.slice(index + 1) + ]; + counter = 0; + updatedExercises.forEach((se) => { + se.step.stepOrder = counter++; + }); + emit('update:selectedExercises', updatedExercises); + } + document.getElementById(`exercise-item-${exo.relatedStep}-${exo.id}`)?.classList.remove('exercise-selected'); exercises.value.find((exercise: Exercise) => { if (exercise.id === exo.id) { @@ -130,28 +136,36 @@ function onExerciceSelect(exo: Exercise): void { }); } else { // check if there is already an exercise selected for this step - if (selectedExercises.find((se) => se.step.id === exo.relatedStep) !== undefined) { - toast.add({title: 'Erreur', description: 'Un seul exercice par étape est autorisé', color:'warning'}); - return; + if (props.selectedExercises.find((se) => se.step.id === exo.relatedStep) !== undefined) { + toast.add({title: 'Erreur', description: 'Un seul exercice par étape est autorisé', color: 'warning'}); + return; } - selectedExercises.push({ - step: steps.value.find((step: Step) => step.id === exo.relatedStep) as Step, - exercise: exo - }); + const updatedExercises = [ + ...props.selectedExercises, + { + step: { + ...steps.value.find((step: Step) => step.id === exo.relatedStep) as Step, + stepOrder: counter++ + }, + exercise: exo + } + ]; + emit('update:selectedExercises', updatedExercises); + document.getElementById(`exercise-item-${exo.relatedStep}-${exo.id}`)?.classList.add('exercise-selected'); exercises.value.find((exercise: Exercise) => { if (exercise.id === exo.id) { exercise.selected = true; } - }) + }); } } // TODO : reimplement these ? => see git history function onMouseoverExercise(_: Exercise): void { - return + return; } function onMouseoutExercise(_: Exercise): void { - return + return; } // Events - Map @@ -204,6 +218,17 @@ async function onMapReady(): Promise<void> { layer-type="base" name="OpenStreetMap" /> + <LControl position="topleft"> + <UButton + icon="material-symbols:my-location" + color="neutral" + variant="outline" + style=" + border: 1px solid rgba(0, 0, 0, 0.2); + border-radius: 4px; + box-shadow: 0 1px 5px rgba(0, 0, 0, 0.65);" + @click="map.leafletObject.flyTo(center)"/> + </LControl> </LMap> </div> </UContextMenu> diff --git a/eslint.config.mjs b/eslint.config.mjs index 65e3c51..525307b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,10 +1,11 @@ // @ts-check -import withNuxt from './.nuxt/eslint.config.mjs' +import withNuxt from './.nuxt/eslint.config.mjs'; export default withNuxt( { rules: { - "@typescript-eslint/no-explicit-any": "off" + "@typescript-eslint/no-explicit-any": "off", + "semi": [1, "always"] } }, -) +); diff --git a/middleware/auth.ts b/middleware/auth.ts index 5b041b9..efb2d73 100644 --- a/middleware/auth.ts +++ b/middleware/auth.ts @@ -1,9 +1,10 @@ +// TODO : récupérer l'état de connexion de l'utilisateur function isAuthenticated(): boolean { - return true + return true; } export default defineNuxtRouteMiddleware((_, __) => { if (!isAuthenticated()) { - return navigateTo('/login') + return navigateTo('/login'); } -}) +}); diff --git a/nuxt.config.ts b/nuxt.config.ts index f7b4a86..b183b45 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -80,4 +80,4 @@ export default defineNuxtConfig({ collections: ['material-symbols'] } } -}) \ No newline at end of file +}); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 8fb3542..17d94eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "nuxt": "^3.15.4", "valibot": "^1.0.0-rc.1", "vue": "latest", - "vue-router": "latest" + "vue-router": "latest", + "vue3-smooth-dnd": "^0.0.6" }, "devDependencies": { "@iconify-json/lucide": "^1.2.26", @@ -12026,6 +12027,12 @@ "integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==", "license": "MIT" }, + "node_modules/smooth-dnd": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/smooth-dnd/-/smooth-dnd-0.12.1.tgz", + "integrity": "sha512-Dndj/MOG7VP83mvzfGCLGzV2HuK1lWachMtWl/Iuk6zV7noDycIBnflwaPuDzoaapEl3Pc4+ybJArkkx9sxPZg==", + "license": "MIT" + }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", @@ -14445,6 +14452,18 @@ "vue": "^3.2.0" } }, + "node_modules/vue3-smooth-dnd": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/vue3-smooth-dnd/-/vue3-smooth-dnd-0.0.6.tgz", + "integrity": "sha512-CH9ZZhEfE7qU1ef2rlfgBG+nZtQX8PnWlspB2HDDz1uVGU7fXM0Pr65DftBMz4X81S+edw2H+ZFG6Dyb5J81KA==", + "license": "MIT", + "dependencies": { + "smooth-dnd": "^0.12.1" + }, + "peerDependencies": { + "vue": "^3.0.11" + } + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", diff --git a/package.json b/package.json index 3ae1712..f3bc495 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "nuxt": "^3.15.4", "valibot": "^1.0.0-rc.1", "vue": "latest", - "vue-router": "latest" + "vue-router": "latest", + "vue3-smooth-dnd": "^0.0.6" }, "devDependencies": { "@iconify-json/lucide": "^1.2.26", diff --git a/pages/about.vue b/pages/about.vue index 96c894a..9898ad3 100644 --- a/pages/about.vue +++ b/pages/about.vue @@ -4,12 +4,12 @@ useHead({ meta: [ {name: 'description', content: 'Page d\'accueil de l\'interface administrateur du projet.'} ] -}) +}); definePageMeta({ pageTransition: { name: 'rotate' } -}) +}); </script> <template> diff --git a/pages/exercise/[id].vue b/pages/exercise/[id].vue index 8dea90f..4b31f68 100644 --- a/pages/exercise/[id].vue +++ b/pages/exercise/[id].vue @@ -1,5 +1,5 @@ <script setup lang="ts"> -import * as v from 'valibot' +import * as v from 'valibot'; import {ModalConfirmation} from "#components"; import {getExerciseCategoryIcon, translateExerciseCategoryToFrench} from "~/types/enum/ExerciseCategory"; import type {ResponseBody} from "~/types/ResponseBody"; @@ -20,7 +20,7 @@ const categories = Object.values(ExerciseCategory).map((category) => { label: translateExerciseCategoryToFrench(category), value: category, icon: getExerciseCategoryIcon(category) - } + }; }); const subCategories = ref<string[]>(await $api.exercises.fetchSubCategories()); @@ -35,27 +35,27 @@ const schema = v.object({ }); function onCreate(item: string) { - subCategories.value.push(item) + subCategories.value.push(item); // TODO : Need api update // Gestion d'une nouvelle sous catégorie ? // affichage d'une pop-up ? - exercise.subCategory = item + exercise.subCategory = item; } async function onSubmit() { const data = await $api.exercises.saveExercise(exercise) as ResponseBody<string | IExercise>; if (data.statusCode === 200 || data.statusCode === 201) { - toast.add({title: 'Success', description: 'Votre exercice a bien été enregistré.', color: 'success'}) + toast.add({title: 'Success', description: 'Votre exercice a bien été enregistré.', color: 'success'}); await router.push('/exercises'); } else if (data.statusCode === 401) { - toast.add({title: 'Error', description: 'Vous n\'êtes pas autorisé à effectuer cette action.' , color: 'error'}) + toast.add({title: 'Error', description: 'Vous n\'êtes pas autorisé à effectuer cette action.' , color: 'error'}); } else if (data.statusCode === 403) { - toast.add({title: 'Error', description: 'Vous devez vous identifier avant d\'effectuer cette action.', color: 'error'}) + toast.add({title: 'Error', description: 'Vous devez vous identifier avant d\'effectuer cette action.', color: 'error'}); } else { console.error('Error saving exercise', data); - toast.add({title: 'Error', description: 'Error updating exercise', color: 'error'}) + toast.add({title: 'Error', description: 'Error updating exercise', color: 'error'}); } } @@ -66,12 +66,12 @@ function onCancel() { description: 'Toutes vos modifications seront perdues.', confirmIcon: 'material-symbols:googler-travel', onSuccess() { - modal.close() - router.push('/exercises') + modal.close(); + router.push('/exercises'); } - }) + }); } else { - router.push('/exercises') + router.push('/exercises'); } } </script> diff --git a/pages/exercises.vue b/pages/exercises.vue index d994b31..941e3ba 100644 --- a/pages/exercises.vue +++ b/pages/exercises.vue @@ -38,7 +38,7 @@ const columns: TableColumn<Exercise>[] = [ icon: getSortIcon(column.getIsSorted()), class: '-mx-2.5', onClick: () => column.toggleSorting(column.getIsSorted() === 'asc') - }) + }); }, cell: ({row}) => `#${row.getValue('id')}` }, @@ -52,10 +52,10 @@ const columns: TableColumn<Exercise>[] = [ icon: getSortIcon(column.getIsSorted()), class: '-mx-2.5', onClick: () => column.toggleSorting(column.getIsSorted() === 'asc') - }) + }); }, cell: ({row}) => { - return h('div', {class: 'font-medium'}, row.getValue('name')) + return h('div', {class: 'font-medium'}, row.getValue('name')); } }, { @@ -66,7 +66,7 @@ const columns: TableColumn<Exercise>[] = [ UBadge, {class: 'capitalize justify-center', variant: 'subtle', color:'neutral', icon: getExerciseCategoryIcon(row.getValue('category'))}, () => translateExerciseCategoryToFrench(row.getValue('category')) - ) + ); } }, { @@ -91,15 +91,15 @@ const columns: TableColumn<Exercise>[] = [ class: 'ml-auto' }) ) - ) + ); } } ]; // Get the icon for the sorting direction function getSortIcon(isSorted: false | SortDirection) { - if (!isSorted) return 'i-lucide-arrow-up-down' - return isSorted === 'asc' ? 'i-lucide-arrow-up-narrow-wide' : 'i-lucide-arrow-down-wide-narrow' + if (!isSorted) return 'i-lucide-arrow-up-down'; + return isSorted === 'asc' ? 'i-lucide-arrow-up-narrow-wide' : 'i-lucide-arrow-down-wide-narrow'; } // Calculate the number of displayed elements @@ -117,12 +117,12 @@ function getDropdownActions(exercise: Exercise): DropdownMenuItem[][] { label: 'Copier l\'identifiant', icon: 'material-symbols:content-copy-outline', onSelect: () => { - navigator.clipboard.writeText(exercise.id.toString()) + navigator.clipboard.writeText(exercise.id.toString()); toast.add({ title: 'Identifiant de l\'exercice copié dans le presse-papier.', color: 'success', icon: 'material-symbols:check-circle-outline' - }) + }); } } ], @@ -131,14 +131,14 @@ function getDropdownActions(exercise: Exercise): DropdownMenuItem[][] { label: 'Afficher', icon: 'material-symbols:visibility', onSelect: () => { - router.push(`/exercise/${exercise.id}`) + router.push(`/exercise/${exercise.id}`); } }, { label: 'Modifier', icon: 'material-symbols:edit', onSelect: () => { - router.push(`/exercise/${exercise.id}?mode=edit`) + router.push(`/exercise/${exercise.id}?mode=edit`); } }, { @@ -146,23 +146,23 @@ function getDropdownActions(exercise: Exercise): DropdownMenuItem[][] { icon: 'material-symbols:delete-forever-outline', color: 'error', onSelect: async () => { - const response = await $api.exercises.deleteExercise(exercise.id) + const response = await $api.exercises.deleteExercise(exercise.id); if (response.statusCode === 200) { - data.value = await $api.exercises.getExercises() - toast.add({title: 'Success', description: 'Votre exercice a bien été enregistré.', color: 'success'}) + data.value = await $api.exercises.getExercises(); + toast.add({title: 'Success', description: 'Votre exercice a bien été enregistré.', color: 'success'}); await router.push('/exercises'); } else if (response.statusCode === 401) { - toast.add({title: 'Error', description: 'Vous n\'êtes pas autorisé à effectuer cette action.' , color: 'error'}) + toast.add({title: 'Error', description: 'Vous n\'êtes pas autorisé à effectuer cette action.' , color: 'error'}); } else if (response.statusCode === 403) { - toast.add({title: 'Error', description: 'Vous devez vous identifier avant d\'effectuer cette action.', color: 'error'}) + toast.add({title: 'Error', description: 'Vous devez vous identifier avant d\'effectuer cette action.', color: 'error'}); } else { console.error('Error saving exercise', response); - toast.add({title: 'Error', description: 'Une erreur est survenue.', color: 'error'}) + toast.add({title: 'Error', description: 'Une erreur est survenue.', color: 'error'}); } } } ] - ] + ]; } </script> diff --git a/pages/index.vue b/pages/index.vue index c9a6593..19c5f99 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -3,10 +3,10 @@ useHead({ meta: [ {name: 'description', content: 'Page d\'accueil de l\'interface administrateur du projet.'} ] -}) +}); definePageMeta({ middleware: 'auth' -}) +}); </script> <template> diff --git a/pages/login.vue b/pages/login.vue index 848e148..5dfdc45 100644 --- a/pages/login.vue +++ b/pages/login.vue @@ -28,7 +28,7 @@ async function onSubmit(event: FormSubmitEvent<Schema>) { const body: LoginBody = { username: event.data.email, password: event.data.password - } + }; const data = await $api.users.postLogin(body); if (data.message === 'Login successful') { toast.add({ @@ -37,14 +37,14 @@ async function onSubmit(event: FormSubmitEvent<Schema>) { color: 'success', duration: 2000, icon: 'material-symbols:check' - }) - await router.push('/') + }); + await router.push('/'); } else if (data.error === 'Invalid username or password') { - toast.add({title: 'Error', description: data.error, color: 'error'}) - state.password = '' - return new Promise<void>(res => setTimeout(res, 1000)) + toast.add({title: 'Error', description: data.error, color: 'error'}); + state.password = ''; + return new Promise<void>(res => setTimeout(res, 1000)); } else { - toast.add({title: 'Error', description: 'Une erreur est survenue.', color: 'error'}) + toast.add({title: 'Error', description: 'Une erreur est survenue.', color: 'error'}); } } </script> diff --git a/pages/parcours.vue b/pages/parcours.vue index 595739f..963edc5 100644 --- a/pages/parcours.vue +++ b/pages/parcours.vue @@ -1,5 +1,9 @@ <script setup lang="ts"> +import type {Step} from "~/types/Step"; +import type {Exercise} from "~/types/Exercise"; + +// TODO : move to separate file ? const items = [ { slot: 'step', @@ -17,24 +21,36 @@ const items = [ description: 'Détails du parcours', icon: 'material-symbols:data-info-alert' } -] +]; +const selectedExercises = ref<{step: Step, exercise: Exercise}[]>([]); +const stepper = useTemplateRef('stepper'); -const stepper = useTemplateRef('stepper') +function updateSelectedExercises(newExercises: {step: Step, exercise: Exercise}[]) { + selectedExercises.value = newExercises; +} </script> <template> <div id="parcours" class="w-full flex flex-col justify-between"> <UStepper ref="stepper" :items="items"> <template #step> - <ParcoursStep/> + <ParcoursStep + :selected-exercises="selectedExercises" + @update:selected-exercises="updateSelectedExercises" + /> </template> <template #parcours> - Parcours + <ParcoursOrder + :selected-exercises="selectedExercises" + @update:selected-exercises="updateSelectedExercises" + /> </template> <template #details> - <ParcoursDetails/> + <ParcoursDetails + :selected-exercises="selectedExercises" + /> </template> </UStepper> @@ -44,15 +60,15 @@ const stepper = useTemplateRef('stepper') :disabled="!stepper?.hasPrev" @click="stepper?.prev()" > - Prev + Précédent </UButton> <UButton trailing-icon="i-lucide-arrow-right" - :disabled="!stepper?.hasNext" + :disabled="!stepper?.hasNext || selectedExercises.length === 0" @click="stepper?.next()" > - Next + Suivant </UButton> </div> </div> diff --git a/plugins/vue3-smooth-dnd.client.ts b/plugins/vue3-smooth-dnd.client.ts new file mode 100644 index 0000000..5f9e4e9 --- /dev/null +++ b/plugins/vue3-smooth-dnd.client.ts @@ -0,0 +1,7 @@ +import { defineNuxtPlugin } from '#app'; +import { Container, Draggable } from 'vue3-smooth-dnd'; + +export default defineNuxtPlugin((nuxtApp) => { + nuxtApp.vueApp.component('Container', Container); + nuxtApp.vueApp.component('Draggable', Draggable); +}); diff --git a/public/svg/pinfull.svg b/public/svg/pinfull.svg new file mode 100644 index 0000000..c353e8e --- /dev/null +++ b/public/svg/pinfull.svg @@ -0,0 +1,10 @@ +<svg + xmlns="http://www.w3.org/2000/svg" + width="32" + height="32" + viewBox="0 0 24 24" +> + <path + fill="#1C3238" + d="M12 12q.825 0 1.413-.587T14 10t.588 1.413T12 12m0 10q-4.025-3.425-6.012-6.362T4 10.2q0-3.75 2.413-5.975T12 2t5.588 2.225T20 10.2q0 2.5-1.987 5.438T12 22" + /></svg> \ No newline at end of file diff --git a/repository/module/exercise.ts b/repository/module/exercise.ts index b7b7296..6a391df 100644 --- a/repository/module/exercise.ts +++ b/repository/module/exercise.ts @@ -2,7 +2,7 @@ import {type Exercise, ExerciseClass} from "~/types/Exercise"; import type {ResponseBody} from "~/types/ResponseBody"; class ExerciseModule { - private readonly RESSOURCE: string = '/exercise' + private readonly RESSOURCE: string = '/exercise'; private exercises: Exercise[] = []; /** @@ -14,7 +14,7 @@ class ExerciseModule { if (!this.exercises.length || !this.exercises.find((e) => e.id === id)) { await this.fetchExercises(); } - const ex = this.exercises.find((e) => e.id === id) + const ex = this.exercises.find((e) => e.id === id); if (!ex) { return new ExerciseClass(this.getNewId()); } @@ -37,18 +37,19 @@ class ExerciseModule { * @param exercise the exercise to save * @return either a response as a string or the saved exercise with their associated status code */ - async saveExercise(exercise: Exercise): Promise<ResponseBody<string | Exercise>> { + async saveExercise(exercise: Exercise): Promise<ResponseBody<string | number>> { const method = this.exercises.find((e) => e.id === exercise.id) ? 'PATCH' : 'POST'; - const data: ResponseBody<string | Exercise> = (await $fetch(this.RESSOURCE, { + const data: ResponseBody<string | number> = (await $fetch(this.RESSOURCE, { method: method, body: JSON.stringify(exercise) })); if (data.statusCode == 200) { - this.exercises = this.exercises.map((e) => e.id === exercise.id ? data.message as Exercise: e); + this.exercises = this.exercises.map((e) => e.id === exercise.id ? exercise : e); } else if (data.statusCode == 201) { - this.exercises.push(data.message as Exercise); + exercise.id = data.message as number; + this.exercises.push(exercise); } return data; } diff --git a/repository/module/step.ts b/repository/module/step.ts index 26c9dc5..b2a2877 100644 --- a/repository/module/step.ts +++ b/repository/module/step.ts @@ -21,7 +21,7 @@ class StepModule { return await $fetch(`${this.RESOURCE}/fetch-in-area`, { method: 'POST', body: JSON.stringify(boundsToAreaBody(body)) - }) + }); } } @@ -33,5 +33,5 @@ function boundsToAreaBody(bounds: LatLngBounds): AreaBody { latMax: bounds?.getNorthEast().lat, longMin: bounds?.getSouthWest().lng, longMax: bounds?.getNorthEast().lng - } + }; } \ No newline at end of file diff --git a/repository/module/user.ts b/repository/module/user.ts index 338a8ce..65bbe00 100644 --- a/repository/module/user.ts +++ b/repository/module/user.ts @@ -14,7 +14,7 @@ class UserModule { return await $fetch(`${this.RESOURCE}/login`, { method: 'POST', body: JSON.stringify(body) - }) + }); } } diff --git a/server/model/IExercise.ts b/server/model/IExercise.ts index 913059b..cd5f526 100644 --- a/server/model/IExercise.ts +++ b/server/model/IExercise.ts @@ -6,10 +6,6 @@ export type IExercise = { generalInstructions: string; diagram: string; video: string; - category: { - name: string - }, - subCategory: { - name: string - } + category: string, + subCategory: string } \ No newline at end of file diff --git a/server/model/IStep.ts b/server/model/IStep.ts index 98231a6..2c547ce 100644 --- a/server/model/IStep.ts +++ b/server/model/IStep.ts @@ -1,20 +1,10 @@ -import type {IExercise} from "~/repository/model/IExercise"; - -export type ISpecificExercise = { - id: number; - exercise: IExercise; -} - -export type IGenericExercise = { - id: number; - exercise: IExercise; -} +import type {IExercise} from "./IExercise"; export type ITerrainType = { id: number; name: string; description: string; - genericExercises: IGenericExercise[]; + exercises: IExercise[]; } export type IStep = { @@ -22,6 +12,6 @@ export type IStep = { latitude: number; longitude: number; radius: number; - specificExercise: ISpecificExercise[]; + specificExercise: IExercise[]; terrainType: ITerrainType; } diff --git a/server/routes/auth/login.post.ts b/server/routes/auth/login.post.ts index 3602675..01360e2 100644 --- a/server/routes/auth/login.post.ts +++ b/server/routes/auth/login.post.ts @@ -12,8 +12,8 @@ export default defineEventHandler(async (event) => { method: 'POST', headers: headers, body: JSON.stringify(await readBody(event)) - }) + }); - appendResponseHeader(event, 'set-cookie', res.headers.getSetCookie()) + appendResponseHeader(event, 'set-cookie', res.headers.getSetCookie()); return await res.json(); -}) \ No newline at end of file +}); \ No newline at end of file diff --git a/server/routes/exercise/.delete.ts b/server/routes/exercise/.delete.ts index fbcd077..ef28c75 100644 --- a/server/routes/exercise/.delete.ts +++ b/server/routes/exercise/.delete.ts @@ -13,14 +13,14 @@ export default defineEventHandler(async (event) => { const res = await fetch(`${config.public.apiBase}${event.path}`, { method: 'DELETE', headers: headers - }) + }); if (res.ok) { return { statusCode: res.status, message: await res.text() - } as ResponseBody<string> + } as ResponseBody<string>; } else { - return await res.json() as ResponseBody<string> + return await res.json() as ResponseBody<string>; } -}) \ No newline at end of file +}); \ No newline at end of file diff --git a/server/routes/exercise/.get.ts b/server/routes/exercise/.get.ts index 90ce48a..b5fcd24 100644 --- a/server/routes/exercise/.get.ts +++ b/server/routes/exercise/.get.ts @@ -15,10 +15,10 @@ export default defineEventHandler(async (event) => { const res = await fetch(`${config.public.apiBase}${event.path}`, { method: 'GET', headers: headers - }) + }); return { statusCode: res.status, message: (await res.json() as IExercise[]).map((e) => ExerciseMapperFrom(e)) } as ResponseBody<Exercise[]>; -}) \ No newline at end of file +}); \ No newline at end of file diff --git a/server/routes/exercise/.patch.ts b/server/routes/exercise/.patch.ts index fbe5b8f..2d26831 100644 --- a/server/routes/exercise/.patch.ts +++ b/server/routes/exercise/.patch.ts @@ -1,8 +1,6 @@ import {defineEventHandler, readBody} from "h3"; import {useRuntimeConfig} from "#imports"; -import {ExerciseMapperFrom} from "~/server/utils/mapper/ExerciseMapper"; -import type {IExercise} from "~/server/model/IExercise"; -import type {ResponseBody} from "~/types/ResponseBody"; +import type {ResponseBody} from "../../../types/ResponseBody"; // TODO : TU des différents retours de l'api export default defineEventHandler(async (event) => { @@ -13,21 +11,22 @@ export default defineEventHandler(async (event) => { headers.set('accept', '*/*'); headers.set('Cookie', event.headers.get('Cookie') as string); + const body = await readBody(event); const res = await fetch(`${config.public.apiBase}${event.path}`, { method: 'PATCH', headers: headers, - body: ExerciseToPostBody(await readBody(event)) - }) + body: ExerciseToPostBody(body) + }); if(res.ok) { return { statusCode: res.status, - message: ExerciseMapperFrom(await res.json() as IExercise) - } as ResponseBody<Exercise> + message: body.id + } as ResponseBody<number>; } else { - return await res.json() as ResponseBody<string> + return await res.json() as ResponseBody<string>; } -}) +}); // TODO : TU function ExerciseToPostBody(exercise: Exercise) { diff --git a/server/routes/exercise/.post.ts b/server/routes/exercise/.post.ts index 8874e8a..0cda968 100644 --- a/server/routes/exercise/.post.ts +++ b/server/routes/exercise/.post.ts @@ -1,8 +1,6 @@ import {defineEventHandler, readBody} from "h3"; import {useRuntimeConfig} from "#imports"; -import {ExerciseMapperFrom} from "~/server/utils/mapper/ExerciseMapper"; import type {Exercise} from "~/types/Exercise"; -import type {IExercise} from "~/server/model/IExercise"; import type {ResponseBody} from "~/types/ResponseBody"; // TODO : TU des différents retours de l'api @@ -18,17 +16,17 @@ export default defineEventHandler(async (event) => { method: 'POST', headers: headers, body: ExerciseToPostBody(await readBody(event)) - }) + }); if(res.ok) { return { statusCode: res.status, - message: ExerciseMapperFrom(await res.json() as IExercise) - } as ResponseBody<Exercise> + message: (await res.json()).id as number + } as ResponseBody<number>; } else { - return await res.json() as ResponseBody<string> + return await res.json() as ResponseBody<string>; } -}) +}); // TODO : TU function ExerciseToPostBody(exercise: Exercise) { diff --git a/server/routes/exercise/fetch-sub-categories.get.ts b/server/routes/exercise/fetch-sub-categories.get.ts index 725df11..c017ec9 100644 --- a/server/routes/exercise/fetch-sub-categories.get.ts +++ b/server/routes/exercise/fetch-sub-categories.get.ts @@ -12,13 +12,13 @@ export default defineEventHandler(async (event) => { const res = await fetch(`${config.public.apiBase}${event.path}`, { method: 'GET', headers: headers - }) + }); return { statusCode: res.status, message: mapSubCategoryToString(await res.json()) } as ResponseBody<string[]>; -}) +}); type ISubCategory = { name: string; diff --git a/server/routes/step/fetch-in-area.post.ts b/server/routes/step/fetch-in-area.post.ts index ad382bd..97de982 100644 --- a/server/routes/step/fetch-in-area.post.ts +++ b/server/routes/step/fetch-in-area.post.ts @@ -18,14 +18,14 @@ export default defineEventHandler(async (event) => { method: 'POST', headers: headers, body: JSON.stringify(await readBody(event)) - }) + }); if(res.ok) { return { statusCode: res.status, message: (await res.json() as IStep[]).map((s) => StepMapperFrom(s)) - } as ResponseBody<Step[]> + } as ResponseBody<Step[]>; } else { - return await res.json() as ResponseBody<string> + return await res.json() as ResponseBody<string>; } -}) \ No newline at end of file +}); \ No newline at end of file diff --git a/server/utils/mapper/ExerciseMapper.test.ts b/server/utils/mapper/ExerciseMapper.test.ts index 60d1e6a..8e594eb 100644 --- a/server/utils/mapper/ExerciseMapper.test.ts +++ b/server/utils/mapper/ExerciseMapper.test.ts @@ -1,10 +1,10 @@ -import {expect, test} from 'vitest' -import {ExerciseMapperFrom, ExerciseMapperTo} from './ExerciseMapper' +import {expect, test} from 'vitest'; +import {ExerciseMapperFrom, ExerciseMapperTo} from './ExerciseMapper'; import {ExerciseCategory} from "../../../types/enum/ExerciseCategory"; import type {Exercise} from "../../../types/Exercise"; import type {IExercise} from "../../model/IExercise"; -const iExercise: IExercise = { +export const iExercise: IExercise = { id: 1, name: 'name', description: 'description', @@ -12,15 +12,11 @@ const iExercise: IExercise = { generalInstructions: 'generalInstructions', diagram: 'diagram', video: 'video', - category: { - name: 'Cardio' - }, - subCategory: { - name: 'subCategory' - } -} + category: 'Cardio', + subCategory: 'subCategory' +}; -const exercise: Exercise = { +export const exercise: Exercise = { id: 1, name: 'name', description: 'description', @@ -31,36 +27,36 @@ const exercise: Exercise = { category: ExerciseCategory.CARDIO, subCategory: 'subCategory', selected: false -} +}; // ExerciseMapperFrom test('ExerciseMapperFrom', () => { - const mappedExercise = ExerciseMapperFrom(iExercise) + const mappedExercise = ExerciseMapperFrom(iExercise); - expect(mappedExercise).toStrictEqual(exercise) -}) + expect(mappedExercise).toStrictEqual(exercise); +}); // ExerciseMapperTo test('ExerciseMapperTo', () => { - const mappedIExercise = ExerciseMapperTo(exercise) + const mappedIExercise = ExerciseMapperTo(exercise); - expect(mappedIExercise).toStrictEqual(iExercise) -}) + expect(mappedIExercise).toStrictEqual(iExercise); +}); // ExerciseMapperFlipFlop test('ExerciseMapperFlipFlop', () => { - const mappedIExercise = ExerciseMapperTo(ExerciseMapperFrom(iExercise)) - const mappedExercise = ExerciseMapperFrom(ExerciseMapperTo(exercise)) + const mappedIExercise = ExerciseMapperTo(ExerciseMapperFrom(iExercise)); + const mappedExercise = ExerciseMapperFrom(ExerciseMapperTo(exercise)); - expect(mappedIExercise).toStrictEqual(iExercise) - expect(mappedExercise).toStrictEqual(exercise) -}) + expect(mappedIExercise).toStrictEqual(iExercise); + expect(mappedExercise).toStrictEqual(exercise); +}); // ExerciseMapperFromNull, should throw an error test('ExerciseMapperNull', () => { - expect(() => ExerciseMapperFrom(null)).toThrow(TypeError) - expect(() => ExerciseMapperTo(null)).toThrow(TypeError) -}) + expect(() => ExerciseMapperFrom(null)).toThrow(TypeError); + expect(() => ExerciseMapperTo(null)).toThrow(TypeError); +}); // ExerciseMapperFromEmptyOptional test('ExerciseMapperEmptyOptional', () => { @@ -78,11 +74,11 @@ test('ExerciseMapperEmptyOptional', () => { generalInstructions: '', diagram: '', video: '' - } + }; - const mappedExercise = ExerciseMapperFrom(toMap) - expect(mappedExercise).toStrictEqual(expected) -}) + const mappedExercise = ExerciseMapperFrom(toMap); + expect(mappedExercise).toStrictEqual(expected); +}); // ExerciseMapperToEmptyOptional test('ExerciseMapperToEmptyOptional', () => { @@ -100,8 +96,8 @@ test('ExerciseMapperToEmptyOptional', () => { generalInstructions: '', diagram: '', video: '' - } + }; - const mappedIExercise = ExerciseMapperTo(toMap) - expect(mappedIExercise).toStrictEqual(expected) -}) \ No newline at end of file + const mappedIExercise = ExerciseMapperTo(toMap); + expect(mappedIExercise).toStrictEqual(expected); +}); \ No newline at end of file diff --git a/server/utils/mapper/ExerciseMapper.ts b/server/utils/mapper/ExerciseMapper.ts index cdf02a5..c34d1d5 100644 --- a/server/utils/mapper/ExerciseMapper.ts +++ b/server/utils/mapper/ExerciseMapper.ts @@ -1,6 +1,5 @@ import type {Exercise} from "../../../types/Exercise"; import type {ExerciseCategory} from "../../../types/enum/ExerciseCategory"; -import type {ExerciseSubCategory} from "../../../types/enum/ExerciseSubCategory"; import type {IExercise} from "../../model/IExercise"; export function ExerciseMapperFrom(iExercise: IExercise): Exercise { @@ -12,10 +11,10 @@ export function ExerciseMapperFrom(iExercise: IExercise): Exercise { generalInstructions: iExercise.generalInstructions, diagram: iExercise.diagram, video: iExercise.video, - category: iExercise.category?.name as ExerciseCategory, - subCategory: iExercise.subCategory?.name as ExerciseSubCategory, + category: iExercise.category as ExerciseCategory, + subCategory: iExercise.subCategory, selected: false - } + }; } export function ExerciseMapperTo(exercise: Exercise): IExercise { @@ -27,11 +26,7 @@ export function ExerciseMapperTo(exercise: Exercise): IExercise { generalInstructions: exercise.generalInstructions ?? '', diagram: exercise.diagram ?? '', video: exercise.video ?? '', - category: { - name: exercise.category - }, - subCategory: { - name: exercise.subCategory ?? '' - } - } + category: exercise.category, + subCategory: exercise.subCategory + }; } diff --git a/server/utils/mapper/StepMapper.test.ts b/server/utils/mapper/StepMapper.test.ts index 6a10364..9490ee1 100644 --- a/server/utils/mapper/StepMapper.test.ts +++ b/server/utils/mapper/StepMapper.test.ts @@ -1,62 +1,70 @@ -import {expect, test, describe} from 'vitest' +import {expect, test} from 'vitest'; import {StepMapperFrom, StepMapperTo} from "./StepMapper"; +import {exercise, iExercise} from "./ExerciseMapper.test"; import type {IStep} from "../../model/IStep"; import type {Step} from "../../../types/Step"; const iStep: IStep = { - id: 0, - latitude: 0, - longitude: 0, - radius: 0, - specificExercise: [], + id: 15, + latitude: 1.123456789, + longitude: -987654321, + radius: 10, + specificExercise: Array(5).fill(iExercise), terrainType: { - id: 0, + id: 1, name: 'name', description: 'description', - genericExercises: [] + exercises: [iExercise] } -} +}; const step: Step = { - id: 0, - latitude: 0, - longitude: 0, - radius: 0, - specificExercises: [], + id: 15, + latitude: 1.123456789, + longitude: -987654321, + radius: 10, + specificExercises: Array(5).fill({ + ...exercise, + relatedStep: 15, + type: 'specific' + }), terrainType: { - id: 0, + id: 1, name: 'name', description: 'description', - genericExercises: [] + genericExercises: [{ + ...exercise, + relatedStep: 15, + type: 'generic name' + }] } -} +}; -describe.todo('unimplemented suite') // StepMapperFrom test('StepMapperFrom', () => { - const mappedStep = StepMapperFrom(iStep) + const mappedStep = StepMapperFrom(iStep); - expect(mappedStep).toStrictEqual(step) -}) + expect(mappedStep).toStrictEqual(step); +}); // StepMapperTo test('StepMapperTo', () => { - const mappedIStep = StepMapperTo(step) + const mappedIStep = StepMapperTo(step); - expect(mappedIStep).toStrictEqual(iStep) -}) + expect(mappedIStep).toStrictEqual(iStep); +}); // StepMapperFlipFlop test('StepMapperFlipFlop', () => { - const mappedIStep = StepMapperTo(StepMapperFrom(iStep)) - const mappedStep = StepMapperFrom(StepMapperTo(step)) + const mappedIStep = StepMapperTo(StepMapperFrom(iStep)); + const mappedStep = StepMapperFrom(StepMapperTo(step)); - expect(mappedIStep).toStrictEqual(iStep) - expect(mappedStep).toStrictEqual(step) -}) + expect(mappedIStep).toStrictEqual(iStep); + expect(mappedStep).toStrictEqual(step); +}); // StepMapperFromNull, should throw an error test('StepMapperNull', () => { - expect(() => StepMapperFrom(null)).toThrow(TypeError) - expect(() => StepMapperTo(null)).toThrow(TypeError) -}) \ No newline at end of file + expect(() => StepMapperFrom(null)).toThrow(TypeError); + expect(() => StepMapperTo(null)).toThrow(TypeError); +}); \ No newline at end of file diff --git a/server/utils/mapper/StepMapper.ts b/server/utils/mapper/StepMapper.ts index 3604c4a..a53fbea 100644 --- a/server/utils/mapper/StepMapper.ts +++ b/server/utils/mapper/StepMapper.ts @@ -10,24 +10,24 @@ export function StepMapperFrom(iStep: IStep): Step { radius: iStep.radius, specificExercises: iStep.specificExercise.map((se) => { return { - ...ExerciseMapperFrom(se.exercise), + ...ExerciseMapperFrom(se), type: 'specific', relatedStep: iStep.id - } + }; }), terrainType: { id: iStep.terrainType.id, name: iStep.terrainType.name, description: iStep.terrainType.description, - genericExercises: iStep.terrainType.genericExercises.map((ge) => { + genericExercises: iStep.terrainType.exercises.map((ge) => { return { - ...ExerciseMapperFrom(ge.exercise), + ...ExerciseMapperFrom(ge), type: 'generic ' + iStep.terrainType.name, relatedStep: iStep.id - } + }; }) } - } + }; } export function StepMapperTo(step: Step): IStep { @@ -36,22 +36,12 @@ export function StepMapperTo(step: Step): IStep { latitude: step.latitude, longitude: step.longitude, radius: step.radius, - specificExercise: step.specificExercises.map((se) => { - return { - id: se.id, - exercise: ExerciseMapperTo(se.exercise) - } - }), + specificExercise: step.specificExercises.map((se) => ExerciseMapperTo(se)), terrainType: { id: step.terrainType.id, name: step.terrainType.name, description: step.terrainType.description, - genericExercises: step.terrainType.genericExercises.map((ge) => { - return { - id: ge.id, - exercise: ExerciseMapperTo(ge.exercise) - } - }) + exercises: step.terrainType.genericExercises.map((ge) => ExerciseMapperTo(ge)) } - } + }; } diff --git a/server/utils/mapper/UserMapper.test.ts b/server/utils/mapper/UserMapper.test.ts index 7d78937..fe86ec0 100644 --- a/server/utils/mapper/UserMapper.test.ts +++ b/server/utils/mapper/UserMapper.test.ts @@ -1,5 +1,5 @@ -import {test} from 'vitest' +import {test} from 'vitest'; test('my test', () => { // ... test with Nuxt environment! -}) \ No newline at end of file +}); \ No newline at end of file diff --git a/server/utils/mapper/UserMapper.ts b/server/utils/mapper/UserMapper.ts index 7d91f70..b446aa4 100644 --- a/server/utils/mapper/UserMapper.ts +++ b/server/utils/mapper/UserMapper.ts @@ -1,7 +1,7 @@ -import {Role} from "~/types/enum/Role"; -import {Status} from "~/types/enum/Status"; +import type {User} from "../../../types/User"; +import {Role} from "../../../types/enum/Role"; +import {Status} from "../../../types/enum/Status"; -// FIXME : The APi doesn't return these data, currently only mocked interface UserDTO { id: number; lastname: string; diff --git a/types/Leaflet.ts b/types/Leaflet.ts new file mode 100644 index 0000000..7702256 --- /dev/null +++ b/types/Leaflet.ts @@ -0,0 +1,15 @@ +import type L from "leaflet"; +import type {Marker, MarkerOptions} from "leaflet"; + +export interface MarkerCluster { + markerCluster: L.LayerGroup; + markers: Marker[]; +} + +export interface MarkerProps { + name?: string, + lat: number, + lng: number, + options: MarkerOptions, + popup?: string +} \ No newline at end of file diff --git a/types/Step.ts b/types/Step.ts index 829ac2e..3f5c131 100644 --- a/types/Step.ts +++ b/types/Step.ts @@ -12,4 +12,5 @@ export type Step = { radius: number; specificExercises: Exercise[]; terrainType: TerrainType; + stepOrder: number; } diff --git a/types/enum/ExerciseCategory.ts b/types/enum/ExerciseCategory.ts index 0e2f636..415d74e 100644 --- a/types/enum/ExerciseCategory.ts +++ b/types/enum/ExerciseCategory.ts @@ -1,7 +1,7 @@ export enum ExerciseCategory { - REINFORCEMENT = 'Reinforcement', + RENFORCEMENT = 'Renforcement', CARDIO = 'Cardio', - STRENGTHENING = 'Strengthening', + ETIREMENT = 'Étirement', } /** @@ -11,11 +11,11 @@ export enum ExerciseCategory { */ export function getExerciseCategoryIcon(category: ExerciseCategory): string { switch (category) { - case ExerciseCategory.REINFORCEMENT: + case ExerciseCategory.RENFORCEMENT: return 'material-symbols:exercise-outline'; case ExerciseCategory.CARDIO: return 'material-symbols:cardiology-outline'; - case ExerciseCategory.STRENGTHENING: + case ExerciseCategory.ETIREMENT: return 'material-symbols:transfer-within-a-station'; default: return 'material-symbols:question-mark'; @@ -29,11 +29,11 @@ export function getExerciseCategoryIcon(category: ExerciseCategory): string { */ export function translateExerciseCategoryToFrench(category: ExerciseCategory): string { switch (category) { - case ExerciseCategory.REINFORCEMENT: - return 'Renforcement musculaire'; + case ExerciseCategory.RENFORCEMENT: + return 'Renforcement'; case ExerciseCategory.CARDIO: return 'Cardio'; - case ExerciseCategory.STRENGTHENING: + case ExerciseCategory.ETIREMENT: return 'Étirement'; default: return 'Inconnu'; diff --git a/types/vue3-smooth-dnd.d.ts b/types/vue3-smooth-dnd.d.ts new file mode 100644 index 0000000..ce847c8 --- /dev/null +++ b/types/vue3-smooth-dnd.d.ts @@ -0,0 +1,42 @@ +declare module 'vue3-smooth-dnd' { + import type { DefineComponent } from 'vue'; + + // Définition des props pour le Container + interface ContainerProps { + orientation?: 'vertical' | 'horizontal'; + behaviour?: 'move' | 'copy' | 'drag'; + groupName?: string; + dragHandleSelector?: string; + nonDragAreaSelector?: string; + lockAxis?: 'x' | 'y'; + dragClass?: string; + dropClass?: string; + removeOnDropOut?: boolean; + autoScrollEnabled?: boolean; + animationDuration?: number; + dragBeginDelay?: number; + getChildPayload?: (index: number) => any; + shouldAnimateDrop?: (sourceContainer: any, payload: any) => boolean; + shouldAcceptDrop?: (sourceContainer: any, payload: any) => boolean; + getGhostParent?: () => HTMLElement; + dropPlaceholder?: object | boolean; + tag?: string; + } + + // Définition des props pour Draggable (aucune prop spécifique, il agit juste comme un wrapper) + interface DraggableProps { + [key: string]: unknown; + } + + // Extracted from node_modules/smooth-dnd/dist/src/exportTypes.d.ts:14 + export interface DropResult { + removedIndex: number | null; + addedIndex: number | null; + payload?: any; + element?: HTMLElement; + } + + // Déclaration des composants Vue avec leurs props + export const Container: DefineComponent<ContainerProps>; + export const Draggable: DefineComponent<DraggableProps>; +} diff --git a/utils/MapUtils.ts b/utils/MapUtils.ts new file mode 100644 index 0000000..a893988 --- /dev/null +++ b/utils/MapUtils.ts @@ -0,0 +1,17 @@ +import L from "leaflet"; + +export function setNumberedIcon(text: string, iconSize: [number, number]): L.DivIcon { + return L.divIcon({ + iconSize: iconSize, + iconAnchor: [iconSize[0]/2, iconSize[1]], + popupAnchor: [0, -iconSize[1]], + className: '', + html: ` + <div style="position: relative; width: 100%; height: 100%;"> + <img src='/svg/pinfull.svg' class='w-full h-full' alt=""/> + <p style="position: absolute; top: 45%; left: 50%; transform: translate(-50%, -50%); + color: white; text-align: center; width: 100%;">${text}</p> + </div> + ` + }); +} diff --git a/vitest.config.ts b/vitest.config.ts index 4e5a30b..5a6bbe9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,4 +1,4 @@ -import {defineVitestConfig} from '@nuxt/test-utils/config' +import {defineVitestConfig} from '@nuxt/test-utils/config'; export default defineVitestConfig({ test: { @@ -8,4 +8,4 @@ export default defineVitestConfig({ junit: './.nuxt/tests/vitest-junit-report.xml' } } -}) \ No newline at end of file +}); \ No newline at end of file -- GitLab