init backend, added admin page in front
Signed-off-by: Nicolas Froger <nicolas@kektus.xyz>
This commit is contained in:
parent
5c6e641fbd
commit
ddc6c64f0f
89 changed files with 5083 additions and 9 deletions
1
summer2024-frontend/.env.development
Normal file
1
summer2024-frontend/.env.development
Normal file
|
|
@ -0,0 +1 @@
|
|||
VUE_APP_API_BASEURL=http://localhost:8080
|
||||
77
summer2024-frontend/package-lock.json
generated
77
summer2024-frontend/package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
134
summer2024-frontend/public/posts.json
Normal file
134
summer2024-frontend/public/posts.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
||||
1844
summer2024-frontend/public/third-party-licenses.txt
Normal file
1844
summer2024-frontend/public/third-party-licenses.txt
Normal file
File diff suppressed because it is too large
Load diff
15
summer2024-frontend/src/components/ui/alert/Alert.vue
Normal file
15
summer2024-frontend/src/components/ui/alert/Alert.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
13
summer2024-frontend/src/components/ui/alert/AlertTitle.vue
Normal file
13
summer2024-frontend/src/components/ui/alert/AlertTitle.vue
Normal 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>
|
||||
21
summer2024-frontend/src/components/ui/alert/index.js
Normal file
21
summer2024-frontend/src/components/ui/alert/index.js
Normal 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",
|
||||
},
|
||||
},
|
||||
);
|
||||
20
summer2024-frontend/src/components/ui/card/Card.vue
Normal file
20
summer2024-frontend/src/components/ui/card/Card.vue
Normal 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>
|
||||
13
summer2024-frontend/src/components/ui/card/CardContent.vue
Normal file
13
summer2024-frontend/src/components/ui/card/CardContent.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
13
summer2024-frontend/src/components/ui/card/CardFooter.vue
Normal file
13
summer2024-frontend/src/components/ui/card/CardFooter.vue
Normal 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>
|
||||
13
summer2024-frontend/src/components/ui/card/CardHeader.vue
Normal file
13
summer2024-frontend/src/components/ui/card/CardHeader.vue
Normal 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>
|
||||
17
summer2024-frontend/src/components/ui/card/CardTitle.vue
Normal file
17
summer2024-frontend/src/components/ui/card/CardTitle.vue
Normal 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>
|
||||
6
summer2024-frontend/src/components/ui/card/index.js
Normal file
6
summer2024-frontend/src/components/ui/card/index.js
Normal 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";
|
||||
46
summer2024-frontend/src/components/ui/carousel/Carousel.vue
Normal file
46
summer2024-frontend/src/components/ui/carousel/Carousel.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
6
summer2024-frontend/src/components/ui/carousel/index.js
Normal file
6
summer2024-frontend/src/components/ui/carousel/index.js
Normal 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";
|
||||
|
|
@ -0,0 +1 @@
|
|||
export {};
|
||||
|
|
@ -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 };
|
||||
18
summer2024-frontend/src/components/ui/form/FormControl.vue
Normal file
18
summer2024-frontend/src/components/ui/form/FormControl.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
19
summer2024-frontend/src/components/ui/form/FormItem.vue
Normal file
19
summer2024-frontend/src/components/ui/form/FormItem.vue
Normal 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>
|
||||
23
summer2024-frontend/src/components/ui/form/FormLabel.vue
Normal file
23
summer2024-frontend/src/components/ui/form/FormLabel.vue
Normal 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>
|
||||
16
summer2024-frontend/src/components/ui/form/FormMessage.vue
Normal file
16
summer2024-frontend/src/components/ui/form/FormMessage.vue
Normal 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>
|
||||
7
summer2024-frontend/src/components/ui/form/index.js
Normal file
7
summer2024-frontend/src/components/ui/form/index.js
Normal 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";
|
||||
|
|
@ -0,0 +1 @@
|
|||
export const FORM_ITEM_INJECTION_KEY = Symbol();
|
||||
36
summer2024-frontend/src/components/ui/form/useFormField.js
Normal file
36
summer2024-frontend/src/components/ui/form/useFormField.js
Normal 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,
|
||||
};
|
||||
}
|
||||
29
summer2024-frontend/src/components/ui/input/Input.vue
Normal file
29
summer2024-frontend/src/components/ui/input/Input.vue
Normal 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>
|
||||
1
summer2024-frontend/src/components/ui/input/index.js
Normal file
1
summer2024-frontend/src/components/ui/input/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as Input } from "./Input.vue";
|
||||
32
summer2024-frontend/src/components/ui/label/Label.vue
Normal file
32
summer2024-frontend/src/components/ui/label/Label.vue
Normal 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>
|
||||
1
summer2024-frontend/src/components/ui/label/index.js
Normal file
1
summer2024-frontend/src/components/ui/label/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as Label } from "./Label.vue";
|
||||
15
summer2024-frontend/src/components/ui/table/Table.vue
Normal file
15
summer2024-frontend/src/components/ui/table/Table.vue
Normal 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>
|
||||
13
summer2024-frontend/src/components/ui/table/TableBody.vue
Normal file
13
summer2024-frontend/src/components/ui/table/TableBody.vue
Normal 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>
|
||||
13
summer2024-frontend/src/components/ui/table/TableCaption.vue
Normal file
13
summer2024-frontend/src/components/ui/table/TableCaption.vue
Normal 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>
|
||||
15
summer2024-frontend/src/components/ui/table/TableCell.vue
Normal file
15
summer2024-frontend/src/components/ui/table/TableCell.vue
Normal 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>
|
||||
34
summer2024-frontend/src/components/ui/table/TableEmpty.vue
Normal file
34
summer2024-frontend/src/components/ui/table/TableEmpty.vue
Normal 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>
|
||||
17
summer2024-frontend/src/components/ui/table/TableFooter.vue
Normal file
17
summer2024-frontend/src/components/ui/table/TableFooter.vue
Normal 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>
|
||||
20
summer2024-frontend/src/components/ui/table/TableHead.vue
Normal file
20
summer2024-frontend/src/components/ui/table/TableHead.vue
Normal 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>
|
||||
13
summer2024-frontend/src/components/ui/table/TableHeader.vue
Normal file
13
summer2024-frontend/src/components/ui/table/TableHeader.vue
Normal 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>
|
||||
20
summer2024-frontend/src/components/ui/table/TableRow.vue
Normal file
20
summer2024-frontend/src/components/ui/table/TableRow.vue
Normal 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>
|
||||
9
summer2024-frontend/src/components/ui/table/index.js
Normal file
9
summer2024-frontend/src/components/ui/table/index.js
Normal 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";
|
||||
29
summer2024-frontend/src/components/ui/textarea/Textarea.vue
Normal file
29
summer2024-frontend/src/components/ui/textarea/Textarea.vue
Normal 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>
|
||||
1
summer2024-frontend/src/components/ui/textarea/index.js
Normal file
1
summer2024-frontend/src/components/ui/textarea/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as Textarea } from "./Textarea.vue";
|
||||
|
|
@ -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
|
||||
|
|
|
|||
22
summer2024-frontend/src/stores/adminPosts.js
Normal file
22
summer2024-frontend/src/stores/adminPosts.js
Normal 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 }
|
||||
})
|
||||
36
summer2024-frontend/src/stores/auth.js
Normal file
36
summer2024-frontend/src/stores/auth.js
Normal 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 }
|
||||
})
|
||||
|
|
@ -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([])
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
278
summer2024-frontend/src/views/CreatePostView.vue
Normal file
278
summer2024-frontend/src/views/CreatePostView.vue
Normal 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>
|
||||
65
summer2024-frontend/src/views/LoginView.vue
Normal file
65
summer2024-frontend/src/views/LoginView.vue
Normal 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>
|
||||
|
|
@ -22,5 +22,8 @@ export default defineConfig({
|
|||
//'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
}
|
||||
},
|
||||
server: {
|
||||
cors: false
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue