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
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue