From 23bcdacbd36be1cc07ed6d04c7350962d8ffa29d Mon Sep 17 00:00:00 2001
From: Pierre-Alexandre Martin <pierre-alexandre.martin@imt-atlantique.net>
Date: Wed, 19 Feb 2025 17:50:53 +0100
Subject: [PATCH] =?UTF-8?q?Ajout=20du=20choix=20des=20exercices=20et=20des?=
 =?UTF-8?q?=20=C3=A9tapes?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 components/Parcours/ExerciceItem.vue          |  32 ++-
 .../Parcours/{Exercices.vue => Step.vue}      | 184 ++++++++++--------
 pages/parcours.vue                            |  10 +-
 server/utils/mapper/StepMapper.ts             |   6 +-
 types/Exercise.ts                             |   1 +
 5 files changed, 141 insertions(+), 92 deletions(-)
 rename components/Parcours/{Exercices.vue => Step.vue} (68%)

diff --git a/components/Parcours/ExerciceItem.vue b/components/Parcours/ExerciceItem.vue
index 8134feb..76927cf 100644
--- a/components/Parcours/ExerciceItem.vue
+++ b/components/Parcours/ExerciceItem.vue
@@ -5,13 +5,28 @@ import {getExerciseCategoryIcon, translateExerciseCategoryToFrench} from "~/type
 const props = defineProps<{
     exercise: Exercise
 }>()
+
+function parseExerciseType(): string {
+    if (props.exercise.type === 'specific') {
+        return 'Exercice spécifique'
+    } else {
+        return 'Terrain ' + (props.exercise.type?.replace('generic', '').replace(/\s+/g, ' ') ?? '')
+    }
+}
 </script>
 
 <template>
     <div class="exercise-item" :class="{ 'exercise-selected' : exercise.selected }">
         <UIcon :name="getExerciseCategoryIcon(exercise.category)" class="h-9 w-9 m-1.5"/>
-        <div class="grid gap-2 w-full">
+        <div class="grid grid-cols-2 gap-2 w-full">
             <p>{{ props.exercise.name }}</p>
+            <UBadge
+                variant="subtle"
+                class="w-fit h-fit place-self-center justify-self-end row-span-2"
+                :class="props.exercise.type === 'specific' ? 'gold-badge': 'silver-badge'"
+            >
+                {{parseExerciseType()}}
+            </UBadge>
             <small>
                 {{ translateExerciseCategoryToFrench(props.exercise.category) }}
                 {{ props.exercise.subCategory ? '> ' + props.exercise.subCategory : '' }}
@@ -26,8 +41,8 @@ div.exercise-item {
 }
 
 div.exercise-item:hover {
-    background-color: var(--color-background-800);
-    border-radius: 25px;
+    background-color: color-mix(in oklab, var(--color-background-500) 50%, transparent);
+    border-radius: 5px;
 }
 
 .exercise-item > span {
@@ -37,4 +52,15 @@ div.exercise-item:hover {
 .exercise-selected > span {
     color: var(--color-3spa_success-500)
 }
+
+.gold-badge {
+    background-color: color-mix(in oklab, #DAA520 10%, transparent);
+    --tw-ring-color: color-mix(in oklab, #DAA520 50%, transparent);
+    color: #DAA520;
+}
+.silver-badge {
+    background-color: color-mix(in oklab, #C0C0C0 10%, transparent);
+    --tw-ring-color: color-mix(in oklab, #C0C0C0 50%, transparent);
+    color: #C0C0C0;
+}
 </style>
\ No newline at end of file
diff --git a/components/Parcours/Exercices.vue b/components/Parcours/Step.vue
similarity index 68%
rename from components/Parcours/Exercices.vue
rename to components/Parcours/Step.vue
index 1734ac1..ea55872 100644
--- a/components/Parcours/Exercices.vue
+++ b/components/Parcours/Step.vue
@@ -1,6 +1,6 @@
 <script setup lang="ts">
-import L, {type LeafletMouseEvent, Marker, type MarkerOptions, 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";
 
@@ -9,7 +9,6 @@ interface MarkerCluster {
     markerCluster: L.LayerGroup;
     markers: Marker[];
 }
-
 interface MarkerProps {
     name?: string,
     lat: number,
@@ -19,6 +18,7 @@ interface MarkerProps {
 }
 
 const {$api} = useNuxtApp();
+const toast = useToast();
 
 // Map utilities
 const exerciseSearch = ref('');
@@ -29,14 +29,26 @@ let markerCluster: MarkerCluster;
 let createCluster: MarkerCluster;
 
 const exercises = ref<Exercise[]>([]);
-const selectedExercises: Exercise[] = [];
+const selectedExercises: {step: Step, exercise: Exercise}[] = [];
 const steps = ref<Step[]>([]);
 
 // TODO : Make it dynamic
 const zoom = ref(17)
 const center: PointTuple = [47.287, -1.524]
 
-async function updateLocations() {
+// TODO : maybe move to a separate file
+const contextMenuItems = ref([
+    [
+        {
+            label: 'Delete the point',
+            onSelect() {
+                deleteMarker();
+            }
+        },
+    ],
+])
+
+async function updateLocations(): Promise<void> {
     locations.value = steps.value.map((step) => {
         return {
             name: step.id.toString(),
@@ -51,49 +63,29 @@ async function updateLocations() {
         }
     })
     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;
+        });
+        step.terrainType.genericExercises.map((exo) => {
+            exo.selected = 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', () => {
-            marker.setIcon(L.icon({
-                iconUrl: '/svg/pin.svg',
-                iconSize: [45, 45],
-            }));
-        });
-        marker.on('mouseout', () => {
-            marker.setIcon(L.icon({
-                iconUrl: '/svg/pin.svg',
-                iconSize: [30, 30],
-            }));
-        });
+        marker.on('mouseover', onMouseoverMarker);
+        marker.on('mouseout', onMouseoutMarker);
     })
 }
 
-const items = ref([
-    [
-        {
-            label: 'Delete the point',
-            onSelect() {
-                deleteMarker();
-            }
-        },
-    ],
-])
-
-async function onMapReady() {
-    createCluster = await useLMarkerCluster({
-        leafletObject: map?.value?.leafletObject,
-        markers: [],
-    });
-    steps.value = (await $api.step.postArea(map.value.leafletObject.getBounds())).message as Step[];
-    await updateLocations();
-
-    map.value.leafletObject.on('moveend', async () => {
-        steps.value = (await $api.step.postArea(map.value.leafletObject.getBounds())).message as Step[];
-        await updateLocations();
+function filterExercise(): Exercise[] {
+    return exercises.value.filter((exo: Exercise) => {
+        return exo.name.toLowerCase().includes(exerciseSearch.value.toLowerCase());
     });
 }
 
@@ -103,46 +95,50 @@ function deleteMarker(): void {
     inCreation.value = false;
 }
 
-function MapEvents(e: LeafletMouseEvent): void {
-    if (inCreation.value) return;
-    inCreation.value = true;
-    const newMarker: Marker = new Marker(e.latlng, {
-        title: 'New marker',
-        draggable: true,
-    });
-    newMarker.setIcon(L.icon({
-        iconUrl: '/svg/pinadd.svg',
-        iconSize: [30, 30],
-    }));
-    createCluster.markerCluster.addLayer(newMarker);
-}
-
-// TODO : reimplement these ? => see git history
-function mouseoverExercise(_: Exercise): void {
-    return
+// Events - Markers
+function onMarkerSelect(e: LeafletMouseEvent): void {
+    const stepId = (e.target as Marker).options.title;
+    exercises.value = steps.value
+        .filter((step: Step) => step.id.toString() === stepId)
+        .map((step: Step) => {
+            return step.specificExercises
+                .concat(step.terrainType.genericExercises);
+        }).flat();
 }
-function mouseoutExercise(_: Exercise): void {
-    return
+function onMouseoverMarker(e: LeafletMouseEvent): void {
+    e.target.setIcon(L.icon({
+        iconUrl: '/svg/pin.svg',
+        iconSize: [45, 45],
+    }));
 }
-
-function filterExercise(): Exercise[] {
-    return exercises.value.filter((exo: Exercise) => {
-        return exo.name.toLowerCase().includes(exerciseSearch.value.toLowerCase());
-    });
+function onMouseoutMarker(e: LeafletMouseEvent): void {
+    e.target.setIcon(L.icon({
+        iconUrl: '/svg/pin.svg',
+        iconSize: [30, 30],
+    }));
 }
 
-function onExerciceClick(exo: Exercise): void {
+// Events - Exercises
+function onExerciceSelect(exo: Exercise): void {
     if (exo.selected) {
-        selectedExercises.splice(selectedExercises.indexOf(exo), 1);
-        document.getElementById(`exercise-item-${exo.id}`)?.classList.remove('exercise-selected');
+        selectedExercises.find((se) => se.step.id === exo.relatedStep && se.exercise.id === exo.id);
+        document.getElementById(`exercise-item-${exo.relatedStep}-${exo.id}`)?.classList.remove('exercise-selected');
         exercises.value.find((exercise: Exercise) => {
             if (exercise.id === exo.id) {
                 exercise.selected = false;
             }
         });
     } else {
-        selectedExercises.push(exo);
-        document.getElementById(`exercise-item-${exo.id}`)?.classList.add('exercise-selected');
+        // 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;
+        }
+        selectedExercises.push({
+            step: steps.value.find((step: Step) => step.id === exo.relatedStep) as Step,
+            exercise: exo
+        });
+        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;
@@ -150,23 +146,47 @@ function onExerciceClick(exo: Exercise): void {
         })
     }
 }
+// TODO : reimplement these ? => see git history
+function onMouseoverExercise(_: Exercise): void {
+    return
+}
+function onMouseoutExercise(_: Exercise): void {
+    return
+}
 
-function onMarkerSelect(e: PointerEvent): void {
-    if (e.target instanceof Marker) {
-        const stepId = (e.target as Marker).options.title;
-        exercises.value = steps.value
-            .filter((step: Step) => step.id.toString() === stepId)
-            .map((step: Step) => {
-                return step.specificExercises
-                    .concat(step.terrainType.genericExercises);
-            }).flat();
-    }
+// Events - Map
+function onMapClick(e: LeafletMouseEvent): void {
+    if (inCreation.value) return;
+    inCreation.value = true;
+    const newMarker: Marker = new Marker(e.latlng, {
+        title: 'New marker',
+        draggable: true,
+    });
+    newMarker.setIcon(L.icon({
+        iconUrl: '/svg/pinadd.svg',
+        iconSize: [30, 30],
+    }));
+    createCluster.markerCluster.addLayer(newMarker);
+}
+async function onMapReady(): Promise<void> {
+    createCluster = await useLMarkerCluster({
+        leafletObject: map?.value?.leafletObject,
+        markers: [],
+    });
+    steps.value = (await $api.step.postArea(map.value.leafletObject.getBounds())).message as Step[];
+    await updateLocations();
+
+    map.value.leafletObject.on('moveend', async () => {
+        steps.value = (await $api.step.postArea(map.value.leafletObject.getBounds())).message as Step[];
+        await updateLocations();
+    });
 }
+
 </script>
 
 <template>
     <div id="exercices">
-        <UContextMenu :items="items" :ui="{ content: 'w-48' }">
+        <UContextMenu :items="contextMenuItems" :ui="{ content: 'w-48' }">
             <div>
                 <LMap
                     ref="map"
@@ -176,7 +196,7 @@ function onMarkerSelect(e: PointerEvent): void {
                     :use-global-leaflet="true"
                     :options="{ zoomSnap: 0.75 }"
                     @ready="onMapReady"
-                    @click="MapEvents"
+                    @click="onMapClick"
                 >
                     <LTileLayer
                         url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
@@ -205,12 +225,12 @@ function onMarkerSelect(e: PointerEvent): void {
             <USeparator class="m-1 w-full"/>
             <div id="exercices-list" class="flex flex-col w-full h-full overflow-auto gap-1">
                 <ParcoursExerciceItem
-                    v-for="exercise in filterExercise()" :id="'exercise-item-'+exercise.id"
+                    v-for="exercise in filterExercise()" :id="'exercise-item-'+exercise.relatedStep+'-'+exercise.id"
                     :key="exercise.id" ref="list"
                     :exercise="exercise as Exercise"
                     class="skeletton gap-4"
-                    @click="onExerciceClick(exercise)"
-                    @mouseover="mouseoverExercise(exercise as Exercise)" @mouseleave="mouseoutExercise(exercise as Exercise)"/>
+                    @click="onExerciceSelect(exercise)"
+                    @mouseover="onMouseoverExercise(exercise as Exercise)" @mouseleave="onMouseoutExercise(exercise as Exercise)"/>
                 <div class="skeletton gap-4">
                     <USkeleton class="h-12 w-12 rounded-full"/>
                     <div class="grid gap-2 w-full">
diff --git a/pages/parcours.vue b/pages/parcours.vue
index 10aacf3..595739f 100644
--- a/pages/parcours.vue
+++ b/pages/parcours.vue
@@ -2,9 +2,9 @@
 
 const items = [
     {
-        slot: 'exercises',
-        title: 'Exercises',
-        description: 'Choix des exercices',
+        slot: 'step',
+        title: 'Étapes',
+        description: 'Choix des étapes',
         icon: 'material-symbols:exercise-outline'
     }, {
         slot: 'parcours',
@@ -25,8 +25,8 @@ const stepper = useTemplateRef('stepper')
 <template>
     <div id="parcours" class="w-full flex flex-col justify-between">
         <UStepper ref="stepper" :items="items">
-            <template #exercises>
-                <ParcoursExercices/>
+            <template #step>
+                <ParcoursStep/>
             </template>
 
             <template #parcours>
diff --git a/server/utils/mapper/StepMapper.ts b/server/utils/mapper/StepMapper.ts
index 0a5c44a..3604c4a 100644
--- a/server/utils/mapper/StepMapper.ts
+++ b/server/utils/mapper/StepMapper.ts
@@ -11,7 +11,8 @@ export function StepMapperFrom(iStep: IStep): Step {
         specificExercises: iStep.specificExercise.map((se) => {
             return {
                 ...ExerciseMapperFrom(se.exercise),
-                type: 'specific'
+                type: 'specific',
+                relatedStep: iStep.id
             }
         }),
         terrainType: {
@@ -21,7 +22,8 @@ export function StepMapperFrom(iStep: IStep): Step {
             genericExercises: iStep.terrainType.genericExercises.map((ge) => {
                 return {
                     ...ExerciseMapperFrom(ge.exercise),
-                    type: 'generic ' + iStep.terrainType.name
+                    type: 'generic ' + iStep.terrainType.name,
+                    relatedStep: iStep.id
                 }
             })
         }
diff --git a/types/Exercise.ts b/types/Exercise.ts
index 8cf060d..46d8099 100644
--- a/types/Exercise.ts
+++ b/types/Exercise.ts
@@ -12,6 +12,7 @@ export type Exercise = {
     video?: string;
 
     type?: string;
+    relatedStep?: number
     selected?: boolean;
 };
 
-- 
GitLab