diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 81e4d1610340248da3684241ffa0cd615141b3b7..a3f2401435f5b1cdc824f469ea3ede34585def42 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 c7a91d6c7c28278a78992a084996423e3a8c2900..ae7894b38d5ec79342d25944a2a8d62d4e779333 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 90d2c9e41fa766624399fe7501fe90f1c0afe668..3f6bfd1becc127c99ee67577ef60f711e8e8be6e 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 a155f1427b905fff3437a515ea8c3c08bf53554f..67d52828df8260824c37ea8a06abf51121cb00ec 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 15d612b74550511d60b4326d37bba00a0aedc671..a91851dbae551d895e518bd4c4e96912daedf0dc 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 5018ac39e23ea5de6d5966946b848371f10078e5..e2b94dc0925ebe5390f0185595ade2e64ecd3951 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 8a456cf4f08ac486acf4fbe98edd86f4aa348812..2f0662c9b6c4f12cf39c67bc376c736e755ecbfa 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 76927cfcb4ec28c686f3660ea1a3e6d3cd4218e3..a7b01ab45c7420d22deb0074a035228c69288c08 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 0000000000000000000000000000000000000000..7d58e7cc850ce84cdbceb8056c4b369bcc95b077 --- /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 ea558722df195999a3a4cb430bc03a0a53fac3d0..15550f6b766c207f151d3ad7c43ca0cd687dcb85 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 65e3c5105f7d2bcc01088e74673efa829719e781..525307b236ca9c2074211d8ba7d7b5c398eaee89 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 5b041b98a088b33ceab1b96149611183051c2fdf..efb2d739e72577dab0cdf6bc8194722833a13887 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 f7b4a86bd2311ad8f5749bb55e5281a16b0fe0e1..b183b45d783b07efec8c4d77413a4e925eeacc4c 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 8fb3542ba8186ae28566fd332f222108bf0e409d..17d94eb771a6a3f6bc502f45d21b9c19073d5046 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 3ae171200f7888c5b8b75bfde7afedaffa47eb92..f3bc4954d352d230058b13e6fe996ca4a0d54f67 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 96c894a25c16c7d422a75cd0cb0625b22483f6d2..9898ad305f854b763f3755a33ec6ce13c8c255d7 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 8dea90f84a54e2f29e51a20ea6bcc562e41617c4..4b31f68a3e3ee0fcbe6b286c35acdfaa21597e93 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 d994b314ddfcd1db5f3dac457f2154e1e843fee2..941e3ba0a2a14f8343408cff1a67cac06aaac98f 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 c9a6593756a50d54e22aeccbdd473ee2a901b55d..19c5f99df58552a1341308717072f5e5216bc3f7 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 848e1487357c17257946b48f1d3da61b7d727d17..5dfdc458c73f5a115eda60b6fb8246d04c1ec443 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 595739f9414ffacf031bf00d31565fb8b8d85d95..963edc5d0d527c9d520514ef158b17bf0c4dcb0d 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 0000000000000000000000000000000000000000..5f9e4e9a001ae26f6df033e127c148afa10fe3a5 --- /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 0000000000000000000000000000000000000000..c353e8e2accccb18cfe48065b88135b16b0d75ef --- /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 b7b72962d2a53ced6a0a1e729496e5cf8eabee27..6a391dfe05355693c392b424f898befd4ef39a17 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 26c9dc52dbfcaeeb9d16ea3d7eb920167dcd084a..b2a2877c9a730f81ee1658acf9bd974197e21279 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 338a8ceb9c9ea95e53388ab617f2aabcc9a34891..65bbe0020d226cac8d31600654ead49674f5d969 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 913059badcb368d10913b67981cd8f85cf809e1e..cd5f526b370e267e3dea5e86d2c60f3fdc29a118 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 98231a69f8c631fb08fcb773f104092a4c24e752..2c547ce22d253d91663842704d5563e55ef12aff 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 3602675b41698be31d64e654412e24a3012a9ac9..01360e2b6e17b2f3f0eebd2202e4bc613363ba61 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 fbcd077c616b984fd34e4fad3f3ac56372c0fcef..ef28c75856322756d7b5b20f964066f3176a0ede 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 90ce48a0c89b4772d39511af32b09756f10f7b1d..b5fcd242a0c1241fa10ec3d41e90444b6915f67e 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 fbe5b8f7aee5aa0bb1258d414e3c0ef359587e96..2d268311a5c22987d163962ae8214ee01b6f43d1 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 8874e8a12bcea3625300f5c204c80b2b91bfc159..0cda9689251c360a86e6053a899c8bd44f304435 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 725df11a311c83bcc08684473335ea5cbcf37d2e..c017ec9485717bd30e4cd9cba0acef6582c907e0 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 ad382bd5163c72c4253fecc5811ae98aa927dfdd..97de98291e1ecf28622341ab70c4ea97cfdbd5a3 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 60d1e6a58290ae15536fe0bedd118c2675e75a71..8e594eb444384797279518729650290255b67411 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 cdf02a5c56c1f8b1a5f5dad50300052fc95d02fb..c34d1d52ae5dcf707435372ec98128244d5e5166 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 6a1036497ecfaa6fc7feb146456e326395e689d8..9490ee10eb83f269a9dfb2894323291fe5b54c12 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 3604c4a35fa1b3e4433584133bc4047b4e35053a..a53fbea949082494e91eba4c39112b5910e3d665 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 7d78937032fe3cf9103cf472287c48baa0377be6..fe86ec0bd599c640dda4e4ef0e5fcf4496a34fb7 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 7d91f70b4406e7464c7e588e9834eb6b8916a741..b446aa459f29eac9d8bcb62af3b51999ed24b61a 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 0000000000000000000000000000000000000000..770225654fd7b712713bfb238f4ea6afa04a00c2 --- /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 829ac2e153d10177de4a7fc949f3f857c6956b0a..3f5c131df5f177a733fd879c8592766413779a9e 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 0e2f6360335f37f156f6d09fbdcd79a63b1587e6..415d74e46717a21ef18f22f7510ea500c2f7b0b4 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 0000000000000000000000000000000000000000..ce847c8d17950977ce84e25551660491581623b5 --- /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 0000000000000000000000000000000000000000..a893988c8e83e0e8abc3e013d1e1f7dfdda92944 --- /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 4e5a30b970956fd54bfc2cb0090beb384e7717de..5a6bbe98c347818204687c209758d2378b077910 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