init backend, added admin page in front

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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