init backend, added admin page in front

Signed-off-by: Nicolas Froger <nicolas@kektus.xyz>
This commit is contained in:
Nicolas Froger 2024-07-25 03:00:51 +02:00
commit ddc6c64f0f
No known key found for this signature in database
89 changed files with 5083 additions and 9 deletions

View file

@ -0,0 +1 @@
VUE_APP_API_BASEURL=http://localhost:8080

View file

@ -8,18 +8,23 @@
"name": "summer2024-frontend",
"version": "0.0.0",
"dependencies": {
"@vee-validate/zod": "^4.13.2",
"@vueuse/core": "^10.11.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"embla-carousel-vue": "^8.1.7",
"lucide-vue-next": "^0.412.0",
"marked": "^13.0.2",
"pinia": "^2.1.7",
"radix-vue": "^1.9.2",
"tailwind-merge": "^2.4.0",
"tailwindcss-animate": "^1.0.7",
"vee-validate": "^4.13.2",
"vue": "^3.4.29",
"vue-fullpage.js": "^0.2.17",
"vue-router": "^4.3.3"
"vue-router": "^4.3.3",
"zod": "^3.23.8"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.8.0",
@ -1655,6 +1660,27 @@
"integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==",
"dev": true
},
"node_modules/@vee-validate/zod": {
"version": "4.13.2",
"resolved": "https://registry.npmjs.org/@vee-validate/zod/-/zod-4.13.2.tgz",
"integrity": "sha512-y1aqelutD6btX2ayZk3w8udClAykptphlGLwu0vlCqY2r9mrJqMn1EKqtcwXAvEom+ceTcHJOwGr2FUA7i+ZEg==",
"dependencies": {
"type-fest": "^4.8.3",
"vee-validate": "4.13.2",
"zod": "^3.22.4"
}
},
"node_modules/@vee-validate/zod/node_modules/type-fest": {
"version": "4.23.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.23.0.tgz",
"integrity": "sha512-ZiBujro2ohr5+Z/hZWHESLz3g08BBdrdLMieYFULJO+tWc437sn8kQsWLJoZErY8alNhxre9K4p3GURAG11n+w==",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@vitejs/plugin-vue": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.0.5.tgz",
@ -2600,6 +2626,31 @@
"integrity": "sha512-cTen3SB0H2SGU7x467NRe1eVcQgcuS6jckKfWJHia2eo0cHIGOqHoAxevIYZD4eRHcWjkvFzo93bi3vJ9W+1lA==",
"dev": true
},
"node_modules/embla-carousel": {
"version": "8.1.7",
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.1.7.tgz",
"integrity": "sha512-b3kBr2H+S1gx4neki0P+aqN6cA5Ibjqy4CR3Ufi3X+Q3JpoNXJgOmJMSPkoP9DKcDREwADN6UWZzRwF2oo0y9Q=="
},
"node_modules/embla-carousel-reactive-utils": {
"version": "8.1.7",
"resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.1.7.tgz",
"integrity": "sha512-FDPcWjNtW04KSuvSfGbVeoB8yl5no3E0++HikO/uW12cNkMnWt68C4OBOakZQZlpUdRQSA9KCYoBuQzfpVGvZQ==",
"peerDependencies": {
"embla-carousel": "8.1.7"
}
},
"node_modules/embla-carousel-vue": {
"version": "8.1.7",
"resolved": "https://registry.npmjs.org/embla-carousel-vue/-/embla-carousel-vue-8.1.7.tgz",
"integrity": "sha512-cYTIGghkKOeMPI154mz1L60yCW6QMnsgKssEaHHfQ7aYo8KHKlvaY47ZWr5zVpBfSoKfSbB1mgPGvZxrj6Mvpg==",
"dependencies": {
"embla-carousel": "8.1.7",
"embla-carousel-reactive-utils": "8.1.7"
},
"peerDependencies": {
"vue": "^3.2.37"
}
},
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@ -5823,6 +5874,29 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/vee-validate": {
"version": "4.13.2",
"resolved": "https://registry.npmjs.org/vee-validate/-/vee-validate-4.13.2.tgz",
"integrity": "sha512-HlpR/6MJ92TW9f135umMZKUqdd/tFQTxLNSf2ImbU4Y/MlLVAUpF1l64VdjTOhbClAqPjCb5p/SqHDxLpUHXrw==",
"dependencies": {
"@vue/devtools-api": "^6.6.1",
"type-fest": "^4.8.3"
},
"peerDependencies": {
"vue": "^3.4.26"
}
},
"node_modules/vee-validate/node_modules/type-fest": {
"version": "4.23.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.23.0.tgz",
"integrity": "sha512-ZiBujro2ohr5+Z/hZWHESLz3g08BBdrdLMieYFULJO+tWc437sn8kQsWLJoZErY8alNhxre9K4p3GURAG11n+w==",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/vite": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.3.4.tgz",
@ -6145,7 +6219,6 @@
"version": "3.23.8",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
"integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View file

@ -11,18 +11,23 @@
"format": "prettier --write src/"
},
"dependencies": {
"@vee-validate/zod": "^4.13.2",
"@vueuse/core": "^10.11.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"embla-carousel-vue": "^8.1.7",
"lucide-vue-next": "^0.412.0",
"marked": "^13.0.2",
"pinia": "^2.1.7",
"radix-vue": "^1.9.2",
"tailwind-merge": "^2.4.0",
"tailwindcss-animate": "^1.0.7",
"vee-validate": "^4.13.2",
"vue": "^3.4.29",
"vue-fullpage.js": "^0.2.17",
"vue-router": "^4.3.3"
"vue-router": "^4.3.3",
"zod": "^3.23.8"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.8.0",

View file

@ -0,0 +1,134 @@
[
{
"id": 4,
"date": "2022-07-01T22:20:43.000Z",
"location": {
"lat": 48.93071193154141,
"lon": 2.1544736306563346,
"city": "Sartrouville",
"country": "France"
},
"description": "je suis en train de me prendre en photo dans la rue",
"assets": [
"https://www.kektus.fr/img/DSC09764.jpg"
]
},
{
"id": 5,
"date": "2023-12-28T23:18:43.511Z",
"location": {
"lat": 45.8307086344589862,
"lon": -1.107245822424634,
"city": "Marennes-Hiers-Brouage",
"country": "France"
},
"description": "ce mec est très dangereux",
"assets": [
"https://www.kektus.fr/img/DSC00671.jpg"
]
},
{
"id": 6,
"date": "2024-07-21T18:25:43.511Z",
"location": {
"lat": 45.98963904301394,
"lon": -1.3010006132332887,
"city": "Saint-Georges-d'Oléron",
"country": "France"
},
"description": "ceci est une **plage** avec un très long texte.\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et viverra lacus, vitae ultrices urna. Nunc purus nulla, iaculis at turpis ac, pretium vestibulum odio. Nullam posuere, magna nec ornare interdum, purus sem malesuada lectus, eget mattis lacus turpis et diam. Quisque orci sapien, elementum vitae pretium a, varius vitae ante. Morbi elementum elementum dignissim. Nulla venenatis est et velit pharetra eleifend. Nunc eu eros et augue convallis laoreet feugiat nec purus.\n\n Nunc mi justo, tempus sed aliquet ac, vulputate non sem. Phasellus vel massa aliquam, dapibus neque vitae, convallis odio. Duis in massa bibendum, lacinia arcu vitae, varius enim. Aenean posuere hendrerit neque vel rhoncus. Morbi rutrum nunc vitae fringilla luctus. Phasellus sapien risus, tincidunt in finibus non, mattis ut est. Fusce vehicula efficitur nisi a ultricies. Mauris cursus ut sem vitae fringilla.",
"assets": [
"https://www.kektus.fr/img/DSC00684.jpg",
"https://www.kektus.fr/img/DSC00723.jpg",
"https://www.kektus.fr/img/DSC00739.jpg"
]
},
{
"id": 7,
"date": "2022-07-01T22:20:43.000Z",
"location": {
"lat": 48.93071193154141,
"lon": 2.1544736306563346,
"city": "Sartrouville",
"country": "France"
},
"description": "je suis en train de me prendre en photo dans la rue",
"assets": [
"https://www.kektus.fr/img/DSC09764.jpg"
]
},
{
"id": 8,
"date": "2023-12-28T23:18:43.511Z",
"location": {
"lat": 45.8307086344589862,
"lon": -1.107245822424634,
"city": "Marennes-Hiers-Brouage",
"country": "France"
},
"description": "ce mec est très dangereux",
"assets": [
"https://www.kektus.fr/img/DSC00671.jpg"
]
},
{
"id": 9,
"date": "2024-07-21T18:25:43.511Z",
"location": {
"lat": 45.98963904301394,
"lon": -1.3010006132332887,
"city": "Saint-Georges-d'Oléron",
"country": "France"
},
"description": "ceci est une **plage** avec un très long texte.\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et viverra lacus, vitae ultrices urna. Nunc purus nulla, iaculis at turpis ac, pretium vestibulum odio. Nullam posuere, magna nec ornare interdum, purus sem malesuada lectus, eget mattis lacus turpis et diam. Quisque orci sapien, elementum vitae pretium a, varius vitae ante. Morbi elementum elementum dignissim. Nulla venenatis est et velit pharetra eleifend. Nunc eu eros et augue convallis laoreet feugiat nec purus.\n\n Nunc mi justo, tempus sed aliquet ac, vulputate non sem. Phasellus vel massa aliquam, dapibus neque vitae, convallis odio. Duis in massa bibendum, lacinia arcu vitae, varius enim. Aenean posuere hendrerit neque vel rhoncus. Morbi rutrum nunc vitae fringilla luctus. Phasellus sapien risus, tincidunt in finibus non, mattis ut est. Fusce vehicula efficitur nisi a ultricies. Mauris cursus ut sem vitae fringilla.",
"assets": [
"https://www.kektus.fr/img/DSC00684.jpg",
"https://www.kektus.fr/img/DSC00723.jpg",
"https://www.kektus.fr/img/DSC00739.jpg"
]
},
{
"id": 10,
"date": "2022-07-01T22:20:43.000Z",
"location": {
"lat": 48.93071193154141,
"lon": 2.1544736306563346,
"city": "Sartrouville",
"country": "France"
},
"description": "je suis en train de me prendre en photo dans la rue",
"assets": [
"https://www.kektus.fr/img/DSC09764.jpg"
]
},
{
"id": 11,
"date": "2023-12-28T23:18:43.511Z",
"location": {
"lat": 45.8307086344589862,
"lon": -1.107245822424634,
"city": "Marennes-Hiers-Brouage",
"country": "France"
},
"description": "ce mec est très dangereux",
"assets": [
"https://www.kektus.fr/img/DSC00671.jpg"
]
},
{
"id": 12,
"date": "2024-07-21T18:25:43.511Z",
"location": {
"lat": 45.98963904301394,
"lon": -1.3010006132332887,
"city": "Saint-Georges-d'Oléron",
"country": "France"
},
"description": "ceci est une **plage** avec un très long texte.\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et viverra lacus, vitae ultrices urna. Nunc purus nulla, iaculis at turpis ac, pretium vestibulum odio. Nullam posuere, magna nec ornare interdum, purus sem malesuada lectus, eget mattis lacus turpis et diam. Quisque orci sapien, elementum vitae pretium a, varius vitae ante. Morbi elementum elementum dignissim. Nulla venenatis est et velit pharetra eleifend. Nunc eu eros et augue convallis laoreet feugiat nec purus.\n\n Nunc mi justo, tempus sed aliquet ac, vulputate non sem. Phasellus vel massa aliquam, dapibus neque vitae, convallis odio. Duis in massa bibendum, lacinia arcu vitae, varius enim. Aenean posuere hendrerit neque vel rhoncus. Morbi rutrum nunc vitae fringilla luctus. Phasellus sapien risus, tincidunt in finibus non, mattis ut est. Fusce vehicula efficitur nisi a ultricies. Mauris cursus ut sem vitae fringilla.",
"assets": [
"https://www.kektus.fr/img/DSC00684.jpg",
"https://www.kektus.fr/img/DSC00723.jpg",
"https://www.kektus.fr/img/DSC00739.jpg"
]
}
]

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,15 @@
<script setup>
import { alertVariants } from ".";
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
variant: { type: null, required: false },
});
</script>
<template>
<div :class="cn(alertVariants({ variant }), props.class)" role="alert">
<slot />
</div>
</template>

View file

@ -0,0 +1,13 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div :class="cn('text-sm [&_p]:leading-relaxed', props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,13 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<h5 :class="cn('mb-1 font-medium leading-none tracking-tight', props.class)">
<slot />
</h5>
</template>

View file

@ -0,0 +1,21 @@
import { cva } from "class-variance-authority";
export { default as Alert } from "./Alert.vue";
export { default as AlertTitle } from "./AlertTitle.vue";
export { default as AlertDescription } from "./AlertDescription.vue";
export const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
);

View file

@ -0,0 +1,20 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
:class="
cn(
'rounded-lg border bg-card text-card-foreground shadow-sm',
props.class,
)
"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,13 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div :class="cn('p-6 pt-0', props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,13 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<p :class="cn('text-sm text-muted-foreground', props.class)">
<slot />
</p>
</template>

View file

@ -0,0 +1,13 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div :class="cn('flex items-center p-6 pt-0', props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,13 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div :class="cn('flex flex-col gap-y-1.5 p-6', props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<h3
:class="
cn('text-2xl font-semibold leading-none tracking-tight', props.class)
"
>
<slot />
</h3>
</template>

View file

@ -0,0 +1,6 @@
export { default as Card } from "./Card.vue";
export { default as CardHeader } from "./CardHeader.vue";
export { default as CardTitle } from "./CardTitle.vue";
export { default as CardDescription } from "./CardDescription.vue";
export { default as CardContent } from "./CardContent.vue";
export { default as CardFooter } from "./CardFooter.vue";

View file

@ -0,0 +1,46 @@
<script setup>
import { useProvideCarousel } from "./useCarousel";
import { cn } from "@/lib/utils";
const props = defineProps({
opts: { type: null, required: false },
plugins: { type: null, required: false },
orientation: { type: String, required: false, default: "horizontal" },
class: { type: null, required: false },
});
const emits = defineEmits(["init-api"]);
const carouselArgs = useProvideCarousel(props, emits);
defineExpose(carouselArgs);
function onKeyDown(event) {
const prevKey = props.orientation === "vertical" ? "ArrowUp" : "ArrowLeft";
const nextKey = props.orientation === "vertical" ? "ArrowDown" : "ArrowRight";
if (event.key === prevKey) {
event.preventDefault();
carouselArgs.scrollPrev();
return;
}
if (event.key === nextKey) {
event.preventDefault();
carouselArgs.scrollNext();
}
}
</script>
<template>
<div
:class="cn('relative', props.class)"
role="region"
aria-roledescription="carousel"
tabindex="0"
@keydown="onKeyDown"
>
<slot v-bind="carouselArgs" />
</div>
</template>

View file

@ -0,0 +1,31 @@
<script setup>
import { useCarousel } from "./useCarousel";
import { cn } from "@/lib/utils";
defineOptions({
inheritAttrs: false,
});
const props = defineProps({
class: { type: null, required: false },
});
const { carouselRef, orientation } = useCarousel();
</script>
<template>
<div ref="carouselRef" class="overflow-hidden">
<div
:class="
cn(
'flex',
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
props.class,
)
"
v-bind="$attrs"
>
<slot />
</div>
</div>
</template>

View file

@ -0,0 +1,26 @@
<script setup>
import { useCarousel } from "./useCarousel";
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
const { orientation } = useCarousel();
</script>
<template>
<div
role="group"
aria-roledescription="slide"
:class="
cn(
'min-w-0 shrink-0 grow-0 basis-full',
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
props.class,
)
"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,34 @@
<script setup>
import { ArrowRight } from "lucide-vue-next";
import { useCarousel } from "./useCarousel";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
const props = defineProps({
class: { type: null, required: false },
});
const { orientation, canScrollNext, scrollNext } = useCarousel();
</script>
<template>
<Button
:disabled="!canScrollNext"
:class="
cn(
'touch-manipulation absolute h-8 w-8 rounded-full p-0',
orientation === 'horizontal'
? '-right-12 top-1/2 -translate-y-1/2'
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
props.class,
)
"
variant="outline"
@click="scrollNext"
>
<slot>
<ArrowRight class="h-4 w-4 text-current" />
<span class="sr-only">Next Slide</span>
</slot>
</Button>
</template>

View file

@ -0,0 +1,34 @@
<script setup>
import { ArrowLeft } from "lucide-vue-next";
import { useCarousel } from "./useCarousel";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
const props = defineProps({
class: { type: null, required: false },
});
const { orientation, canScrollPrev, scrollPrev } = useCarousel();
</script>
<template>
<Button
:disabled="!canScrollPrev"
:class="
cn(
'touch-manipulation absolute h-8 w-8 rounded-full p-0',
orientation === 'horizontal'
? '-left-12 top-1/2 -translate-y-1/2'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
props.class,
)
"
variant="outline"
@click="scrollPrev"
>
<slot>
<ArrowLeft class="h-4 w-4 text-current" />
<span class="sr-only">Previous Slide</span>
</slot>
</Button>
</template>

View file

@ -0,0 +1,6 @@
export { default as Carousel } from "./Carousel.vue";
export { default as CarouselContent } from "./CarouselContent.vue";
export { default as CarouselItem } from "./CarouselItem.vue";
export { default as CarouselPrevious } from "./CarouselPrevious.vue";
export { default as CarouselNext } from "./CarouselNext.vue";
export { useCarousel } from "./useCarousel";

View file

@ -0,0 +1 @@
export {};

View file

@ -0,0 +1,61 @@
import { createInjectionState } from "@vueuse/core";
import emblaCarouselVue from "embla-carousel-vue";
import { onMounted, ref } from "vue";
const [useProvideCarousel, useInjectCarousel] = createInjectionState(
({ opts, orientation, plugins }, emits) => {
const [emblaNode, emblaApi] = emblaCarouselVue(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
function scrollPrev() {
emblaApi.value?.scrollPrev();
}
function scrollNext() {
emblaApi.value?.scrollNext();
}
const canScrollNext = ref(false);
const canScrollPrev = ref(false);
function onSelect(api) {
canScrollNext.value = api?.canScrollNext() || false;
canScrollPrev.value = api?.canScrollPrev() || false;
}
onMounted(() => {
if (!emblaApi.value) return;
emblaApi.value?.on("init", onSelect);
emblaApi.value?.on("reInit", onSelect);
emblaApi.value?.on("select", onSelect);
emits("init-api", emblaApi.value);
});
return {
carouselRef: emblaNode,
carouselApi: emblaApi,
canScrollPrev,
canScrollNext,
scrollPrev,
scrollNext,
orientation,
};
},
);
function useCarousel() {
const carouselState = useInjectCarousel();
if (!carouselState)
throw new Error("useCarousel must be used within a <Carousel />");
return carouselState;
}
export { useCarousel, useProvideCarousel };

View file

@ -0,0 +1,18 @@
<script setup>
import { Slot } from "radix-vue";
import { useFormField } from "./useFormField";
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
</script>
<template>
<Slot
:id="formItemId"
:aria-describedby="
!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`
"
:aria-invalid="!!error"
>
<slot />
</Slot>
</template>

View file

@ -0,0 +1,19 @@
<script setup>
import { useFormField } from "./useFormField";
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
const { formDescriptionId } = useFormField();
</script>
<template>
<p
:id="formDescriptionId"
:class="cn('text-sm text-muted-foreground', props.class)"
>
<slot />
</p>
</template>

View file

@ -0,0 +1,19 @@
<script setup>
import { provide } from "vue";
import { useId } from "radix-vue";
import { FORM_ITEM_INJECTION_KEY } from "./injectionKeys";
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
const id = useId();
provide(FORM_ITEM_INJECTION_KEY, id);
</script>
<template>
<div :class="cn('space-y-2', props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,23 @@
<script setup>
import { useFormField } from "./useFormField";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
const props = defineProps({
for: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const { error, formItemId } = useFormField();
</script>
<template>
<Label
:class="cn(error && 'text-destructive', props.class)"
:for="formItemId"
>
<slot />
</Label>
</template>

View file

@ -0,0 +1,16 @@
<script setup>
import { ErrorMessage } from "vee-validate";
import { toValue } from "vue";
import { useFormField } from "./useFormField";
const { name, formMessageId } = useFormField();
</script>
<template>
<ErrorMessage
:id="formMessageId"
as="p"
:name="toValue(name)"
class="text-sm font-medium text-destructive"
/>
</template>

View file

@ -0,0 +1,7 @@
export { Form, Field as FormField } from "vee-validate";
export { default as FormItem } from "./FormItem.vue";
export { default as FormLabel } from "./FormLabel.vue";
export { default as FormControl } from "./FormControl.vue";
export { default as FormMessage } from "./FormMessage.vue";
export { default as FormDescription } from "./FormDescription.vue";
export { FORM_ITEM_INJECTION_KEY } from "./injectionKeys";

View file

@ -0,0 +1 @@
export const FORM_ITEM_INJECTION_KEY = Symbol();

View file

@ -0,0 +1,36 @@
import {
FieldContextKey,
useFieldError,
useIsFieldDirty,
useIsFieldTouched,
useIsFieldValid,
} from "vee-validate";
import { inject } from "vue";
import { FORM_ITEM_INJECTION_KEY } from "./injectionKeys";
export function useFormField() {
const fieldContext = inject(FieldContextKey);
const fieldItemContext = inject(FORM_ITEM_INJECTION_KEY);
if (!fieldContext)
throw new Error("useFormField should be used within <FormField>");
const { name } = fieldContext;
const id = fieldItemContext;
const fieldState = {
valid: useIsFieldValid(name),
isDirty: useIsFieldDirty(name),
isTouched: useIsFieldTouched(name),
error: useFieldError(name),
};
return {
id,
name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
}

View file

@ -0,0 +1,29 @@
<script setup>
import { useVModel } from "@vueuse/core";
import { cn } from "@/lib/utils";
const props = defineProps({
defaultValue: { type: [String, Number], required: false },
modelValue: { type: [String, Number], required: false },
class: { type: null, required: false },
});
const emits = defineEmits(["update:modelValue"]);
const modelValue = useVModel(props, "modelValue", emits, {
passive: true,
defaultValue: props.defaultValue,
});
</script>
<template>
<input
v-model="modelValue"
:class="
cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
/>
</template>

View file

@ -0,0 +1 @@
export { default as Input } from "./Input.vue";

View file

@ -0,0 +1,32 @@
<script setup>
import { computed } from "vue";
import { Label } from "radix-vue";
import { cn } from "@/lib/utils";
const props = defineProps({
for: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<Label
v-bind="delegatedProps"
:class="
cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
props.class,
)
"
>
<slot />
</Label>
</template>

View file

@ -0,0 +1 @@
export { default as Label } from "./Label.vue";

View file

@ -0,0 +1,15 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div class="relative w-full overflow-auto">
<table :class="cn('w-full caption-bottom text-sm', props.class)">
<slot />
</table>
</div>
</template>

View file

@ -0,0 +1,13 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<tbody :class="cn('[&_tr:last-child]:border-0', props.class)">
<slot />
</tbody>
</template>

View file

@ -0,0 +1,13 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<caption :class="cn('mt-4 text-sm text-muted-foreground', props.class)">
<slot />
</caption>
</template>

View file

@ -0,0 +1,15 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<td
:class="cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', props.class)"
>
<slot />
</td>
</template>

View file

@ -0,0 +1,34 @@
<script setup>
import { computed } from "vue";
import TableRow from "./TableRow.vue";
import TableCell from "./TableCell.vue";
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
colspan: { type: Number, required: false, default: 1 },
});
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<TableRow>
<TableCell
:class="
cn(
'p-4 whitespace-nowrap align-middle text-sm text-foreground',
props.class
)"
v-bind="delegatedProps"
>
<div class="flex items-center justify-center py-10">
<slot />
</div>
</TableCell>
</TableRow>
</template>

View file

@ -0,0 +1,17 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<tfoot
:class="
cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', props.class)
"
>
<slot />
</tfoot>
</template>

View file

@ -0,0 +1,20 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<th
:class="
cn(
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
props.class,
)
"
>
<slot />
</th>
</template>

View file

@ -0,0 +1,13 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<thead :class="cn('[&_tr]:border-b', props.class)">
<slot />
</thead>
</template>

View file

@ -0,0 +1,20 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<tr
:class="
cn(
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
props.class,
)
"
>
<slot />
</tr>
</template>

View file

@ -0,0 +1,9 @@
export { default as Table } from "./Table.vue";
export { default as TableBody } from "./TableBody.vue";
export { default as TableCell } from "./TableCell.vue";
export { default as TableHead } from "./TableHead.vue";
export { default as TableHeader } from "./TableHeader.vue";
export { default as TableFooter } from "./TableFooter.vue";
export { default as TableRow } from "./TableRow.vue";
export { default as TableCaption } from "./TableCaption.vue";
export { default as TableEmpty } from "./TableEmpty.vue";

View file

@ -0,0 +1,29 @@
<script setup>
import { useVModel } from "@vueuse/core";
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
defaultValue: { type: [String, Number], required: false },
modelValue: { type: [String, Number], required: false },
});
const emits = defineEmits(["update:modelValue"]);
const modelValue = useVModel(props, "modelValue", emits, {
passive: true,
defaultValue: props.defaultValue,
});
</script>
<template>
<textarea
v-model="modelValue"
:class="
cn(
'flex min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
/>
</template>

View file

@ -0,0 +1 @@
export { default as Textarea } from "./Textarea.vue";

View file

@ -1,6 +1,9 @@
import { createRouter, createWebHistory } from 'vue-router'
import PostsView from '@/views/PostsView.vue'
import CreatePostView from '@/views/CreatePostView.vue'
import AdminView from '@/views/AdminView.vue'
import LoginView from '@/views/LoginView.vue'
import { useAuthStore } from '@/stores/auth.js'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -10,12 +13,32 @@ const router = createRouter({
name: 'home',
component: PostsView
},
{
path: '/admin/login',
name: 'admin_login',
component: LoginView
},
{
path: '/admin',
name: 'admin',
component: AdminView
},
{
path: '/admin/post/create',
name: 'admin_create_post',
component: CreatePostView
}
]
})
router.beforeEach(async (to, from) => {
const authStore = useAuthStore()
if (
!authStore.isAuth &&
to.name.startsWith('admin_') && to.name !== 'admin_login'
) {
return { name: 'admin_login' }
}
})
export default router

View file

@ -0,0 +1,22 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useAuthStore } from '@/stores/auth.js'
export const useAdminPostsStore = defineStore("adminPosts", () => {
const authStore = useAuthStore();
const posts = ref([])
function fetchPosts() {
if (!authStore.isAuth)
return
fetch("http://localhost:8080/admin/posts", {
headers: {
"X-admin-token": authStore.adminToken
}
}).then(r => r.json()).then(r => posts.value = r)
}
return { posts, fetchPosts }
})

View file

@ -0,0 +1,36 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useAuthStore = defineStore("auth", () => {
const adminToken = ref("");
const isAuth = ref(false);
const error = ref(false);
function login(token) {
adminToken.value = "";
isAuth.value = false;
error.value = false;
return fetch("http://localhost:8080/admin/auth/check", {
headers: {
"X-admin-token": token
}
}).then(resp => {
if (resp.ok) {
adminToken.value = token;
isAuth.value = true;
localStorage.setItem("kektus-summer-admin-token", token)
} else {
error.value = true;
}
})
}
const storedToken = localStorage.getItem("kektus-summer-admin-token")
if (storedToken) {
login(storedToken)
}
return { adminToken, login, isAuth, error }
})

View file

@ -5,7 +5,7 @@ import { fr } from 'date-fns/locale'
import { marked } from 'marked'
export const usePostsStore = defineStore('posts', () => {
const postsApiPath = '/posts.json'
const postsApiPath = 'http://localhost:8080/posts'
const posts = ref([])

View file

@ -1,14 +1,72 @@
<script setup>
import { Button } from '@/components/ui/button/index.js'
import { CirclePlus, Pencil, Trash } from 'lucide-vue-next'
import { useAdminPostsStore } from '@/stores/adminPosts.js'
import { onMounted } from 'vue'
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table/index.js'
import { useAuthStore } from '@/stores/auth.js'
const adminPostsStore = useAdminPostsStore();
onMounted(() => {
adminPostsStore.fetchPosts();
})
</script>
<template>
<div class="flex flex-col mt-20 w-full justify-center items-center gap-4">
<Button>test</Button>
<Button>test</Button>
<Button>test</Button>
<div class="grid grid-cols-3 grid-rows-1 mt-20 w-full justify-center items-center gap-4">
<div class="col-span-3 col-start-1 lg:col-span-1 lg:col-start-2 mx-5 lg:mx-0">
<h1 class="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl mb-6">
Admin
</h1>
<RouterLink to="/admin/post/create">
<Button class="mb-6">
<CirclePlus class="mr-2" />
Créer
</Button>
</RouterLink>
<Table>
<TableCaption>List of posts</TableCaption>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Date</TableHead>
<TableHead>City</TableHead>
<TableHead>Country</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="post in adminPostsStore.posts" :key="post.id">
<TableCell>{{ post.id }}</TableCell>
<TableCell>{{ post.date }}</TableCell>
<TableCell>{{ post.city }}</TableCell>
<TableCell>{{ post.country }}</TableCell>
<TableCell>
<div class="flex gap-2">
<Button>
<Pencil size="16"/>
</Button>
<Button>
<Trash size="16"/>
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
</template>
<style scoped>
</style>
</style>

View file

@ -0,0 +1,278 @@
<script setup>
import { Button } from '@/components/ui/button/index.js'
import { useForm } from 'vee-validate'
import { FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form/index.js'
import { Textarea } from '@/components/ui/textarea/index.js'
import { Input } from '@/components/ui/input/index.js'
import { Label } from '@/components/ui/label/index.js'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'
import { onMounted, onUnmounted, ref } from 'vue'
import { AlertCircle, ArrowLeft, ArrowRight, CircleCheckBig, CirclePlus, RefreshCcw } from 'lucide-vue-next'
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious
} from '@/components/ui/carousel/index.js'
import { Card, CardContent } from '@/components/ui/card/index.js'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert/index.js'
import { useAuthStore } from '@/stores/auth.js'
const authStore = useAuthStore();
const formSchema = toTypedSchema(z.object({
description: z.string().min(1),
latitude: z.number(),
longitude: z.number(),
city: z.string().min(1),
country: z.string().min(1)
}))
const form = useForm({
validationSchema: formSchema
})
const formContainer = ref(null)
const latitudeInput = ref(null)
const longitudeInput = ref(null)
const selectedFiles = ref([])
const formStatus = ref({
sending: false,
sent: false,
error: false,
errorMsg: ''
})
const onSubmit = form.handleSubmit(async (values) => {
if (!authStore.isAuth)
return
console.log('Envoi du post...')
if (selectedFiles.value.length === 0)
return
formContainer.value.classList.add('invisible')
formStatus.value.sending = true
const assets = []
for (const file of selectedFiles.value) {
console.log('Contact API asset')
const response = await fetch('http://localhost:8080/admin/assets', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
"X-admin-token": authStore.adminToken
},
body: JSON.stringify({ filename: file.file.name })
})
if (!response.ok) {
console.log('Contact API asset failed: ' + response.statusText + '\n\n' + response.body)
formStatus.value.sending = false
formStatus.value.error = true
formStatus.value.errorMsg = 'Une erreur est survenue à la préparation de l\'envoi d\'un média : ' + response.statusText
formContainer.value.classList.remove('invisible')
return
}
const responseBody = await response.json()
const mediaUploadFormData = new FormData()
for (const [key, value] of Object.entries(responseBody.formData)) {
mediaUploadFormData.append(key, value)
}
mediaUploadFormData.append('key', '${filename}')
mediaUploadFormData.append('Content-Type', file.file.type)
mediaUploadFormData.append('file', file.file, responseBody.filename)
console.log('Envoi image sur s3')
const s3Response = await fetch('http://localhost:32795/assets/', {
method: 'POST',
body: mediaUploadFormData
})
if (!s3Response.ok) {
console.log('Envoi media S3 failed: ' + s3Response.statusText + '\n\n' + s3Response.body)
formStatus.value.sending = false
formStatus.value.error = true
formStatus.value.errorMsg = 'Une erreur est survenue pendant l\'envoi d\'un média : ' + s3Response.statusText
formContainer.value.classList.remove('invisible')
return
}
assets.push(responseBody.id)
}
const response = await fetch('http://localhost:8080/admin/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
"X-admin-token": authStore.adminToken
},
body: JSON.stringify({
description: values.description,
latitude: values.latitude,
longitude: values.longitude,
city: values.city,
country: values.country,
assets: assets
})
})
if (!response.ok) {
console.log('POST post API failed: ' + response.statusText + '\n\n' + response.body)
formStatus.value.sending = false
formStatus.value.error = true
formStatus.value.errorMsg = 'Une erreur est survenue lors de l\'envoi du poste : ' + response.statusText
formContainer.value.classList.remove('invisible')
return
}
formStatus.value.sending = false
formStatus.value.sent = true
})
let geoWatchId = null
onMounted(() => {
geoWatchId = navigator.geolocation.watchPosition((position) => {
console.log(position)
form.setFieldValue('latitude', position.coords.latitude)
form.setFieldValue('longitude', position.coords.longitude)
})
})
onUnmounted(() => {
if (geoWatchId != null) {
navigator.geolocation.clearWatch(geoWatchId)
}
})
function onFilesSelected(event) {
console.log(event.target.files)
selectedFiles.value = []
for (const file of event.target.files) {
selectedFiles.value.push({ displayUrl: URL.createObjectURL(file), file: file })
}
}
function mediaReorder(index, diff) {
const tmp = selectedFiles.value[index]
selectedFiles.value[index] = selectedFiles.value[index + diff]
selectedFiles.value[index + diff] = tmp
}
</script>
<template>
<div class="grid grid-cols-3 grid-rows-1 mt-20 w-full justify-center items-center gap-4">
<div class="col-span-3 col-start-1 lg:col-span-1 lg:col-start-2 mx-5 lg:mx-0">
<h1 class="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl mb-6">
Ajouter un post
</h1>
<RouterLink to="/admin">
<Button class="mb-6">
<ArrowLeft class="mr-2" />
Retour
</Button>
</RouterLink>
<Alert variant="destructive" class="mb-6" v-if="formStatus.error">
<AlertCircle class="w-4 h-4"></AlertCircle>
<AlertTitle>Erreur...</AlertTitle>
<AlertDescription>{{ formStatus.errorMsg }}</AlertDescription>
</Alert>
<Alert class="mb-6" v-if="formStatus.sent">
<CircleCheckBig class="w-4 h-4"></CircleCheckBig>
<AlertTitle>Post créé !</AlertTitle>
</Alert>
<p v-if="formStatus.sending">Sending...</p>
<div id="form-container" ref="formContainer">
<form id="post-form" class="space-y-6" @submit="onSubmit">
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea v-bind="componentField"></Textarea>
</FormControl>
</FormItem>
</FormField>
<div class="grid grid-cols-2 gap-4">
<FormField ref="latitudeInput" v-slot="{ componentField }" name="latitude">
<FormItem>
<FormLabel>Latitude</FormLabel>
<FormControl>
<Input disabled v-bind="componentField"></Input>
</FormControl>
</FormItem>
</FormField>
<FormField ref="longitudeInput" v-slot="{ componentField }" name="longitude">
<FormItem>
<FormLabel>Longitude</FormLabel>
<FormControl>
<Input disabled v-bind="componentField"></Input>
</FormControl>
</FormItem>
</FormField>
</div>
<div class="grid grid-cols-9 gap-4">
<FormField v-slot="{ componentField }" name="city">
<FormItem class="col-span-4">
<FormLabel>Ville</FormLabel>
<FormControl>
<Input v-bind="componentField"></Input>
</FormControl>
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="country">
<FormItem class="col-span-4">
<FormLabel>Pays</FormLabel>
<FormControl>
<Input v-bind="componentField"></Input>
</FormControl>
</FormItem>
</FormField>
<Button class="place-self-end">
<RefreshCcw />
</Button>
</div>
</form>
<form id="assets-form" class="mt-6">
<Label for="medias">Medias</Label>
<Input id="medias" type="file" accept="image/*,video/*" multiple @change="onFilesSelected" />
</form>
<Carousel
class="w-full mt-10"
:opts="{
align: 'start',
}"
v-if="selectedFiles.length > 0"
>
<CarouselContent>
<CarouselItem v-for="(file, index) in selectedFiles" :key="index" class="md:basis-1/2 lg:basis-1/3">
<div class="flex flex-col">
<Card>
<CardContent class="flex aspect-square items-center justify-center p-6">
<img :src="file.displayUrl" v-if="file.file.type.startsWith('image/')" />
</CardContent>
</Card>
<div class="grid grid-cols-2 justify-items-center mt-2">
<Button v-if="index > 0" @click="mediaReorder(index, -1)">
<ArrowLeft />
</Button>
<div v-else></div>
<Button v-if="index < selectedFiles.length - 1" @click="mediaReorder(index, 1)">
<ArrowRight />
</Button>
</div>
</div>
</CarouselItem>
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
<Button class="mt-6 w-full" type="submit" form="post-form">
<CirclePlus />
</Button>
</div>
</div>
</div>
</template>
<style scoped>
</style>

View file

@ -0,0 +1,65 @@
<script setup>
import { useAuthStore } from '@/stores/auth.js'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'
import { useForm } from 'vee-validate'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert/index.js'
import { AlertCircle } from 'lucide-vue-next'
import { ref } from 'vue'
import { FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form/index.js'
import { Input } from '@/components/ui/input/index.js'
import { Button } from '@/components/ui/button/index.js'
import router from '@/router/index.js'
const authStore = useAuthStore()
const formSchema = toTypedSchema(z.object({
token: z.string().min(1)
}))
const form = useForm({
validationSchema: formSchema
})
const onSubmit = form.handleSubmit((values) => {
authStore.login(values.token).then(_ => router.push({name: "admin"}))
})
</script>
<template>
<div class="grid grid-cols-3 grid-rows-1 mt-20 w-full justify-center items-center gap-4">
<div class="col-span-3 col-start-1 lg:col-span-1 lg:col-start-2 mx-5 lg:mx-0">
<h1 class="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl mb-6">
Login to admin
</h1>
<Alert variant="destructive" class="mb-6" v-if="authStore.error">
<AlertCircle class="w-4 h-4"></AlertCircle>
<AlertTitle>Erreur...</AlertTitle>
<AlertDescription>Token invalide</AlertDescription>
</Alert>
<div id="form-container" ref="formContainer">
<form id="login-form" class="space-y-6" @submit="onSubmit">
<FormField v-slot="{ componentField }" name="token">
<FormItem>
<FormLabel>Token</FormLabel>
<FormControl>
<Input v-bind="componentField"></Input>
</FormControl>
</FormItem>
</FormField>
<Button class="mt-6 w-full" type="submit">
Login
</Button>
</form>
</div>
</div>
</div>
</template>
<style scoped>
</style>

View file

@ -22,5 +22,8 @@ export default defineConfig({
//'@': fileURLToPath(new URL('./src', import.meta.url))
'@': path.resolve(__dirname, './src'),
}
},
server: {
cors: false
}
})