refactor, add post edit and delete, merge APIs, add not found view

Signed-off-by: Nicolas Froger <nicolas@kektus.xyz>
This commit is contained in:
Nicolas Froger 2024-07-27 02:20:08 +02:00
commit efde8738a8
No known key found for this signature in database
23 changed files with 803 additions and 343 deletions

View file

@ -58,11 +58,23 @@ function postDataToggle() {
<template>
<div class="section relative overflow-hidden" ref="section" :data-anchor="'post-' + post.id">
<div class="slide relative overflow-hidden" v-for="asset in post.assets" :key="asset">
<img class="absolute top-0 left-0 w-full h-full object-cover -z-10" :data-src="asset" />
<div class="grid grid-cols-3 grid-rows-5 absolute w-full h-full top-0 left-0 pointer-events-none">
<div ref="postData" class="col-start-1 col-span-3 lg:col-span-1 row-start-4 row-span-2 self-end place-self-stretch m-6 p-2 bg-gray-400/10 backdrop-blur-sm rounded-lg pointer-events-auto transition-colors duration-500">
<div class="absolute top-0 right-0 m-2 p-2 backdrop-blur-sm rounded-sm bg-black/10 hover:bg-black/20 cursor-pointer transition-colors duration-200 opacity-100" ref="postActionBtn" @click="postDataToggle">
<div class="slide relative overflow-hidden" v-for="asset in post.assets" :key="asset.id">
<img
class="absolute top-0 left-0 w-full h-full object-cover -z-10"
:data-src="asset.presignedUrl"
/>
<div
class="grid grid-cols-3 grid-rows-5 absolute w-full h-full top-0 left-0 pointer-events-none"
>
<div
ref="postData"
class="col-start-1 col-span-3 lg:col-span-1 row-start-4 row-span-2 self-end place-self-stretch m-6 p-2 bg-gray-400/10 backdrop-blur-sm rounded-lg pointer-events-auto transition-colors duration-500"
>
<div
class="absolute top-0 right-0 m-2 p-2 backdrop-blur-sm rounded-sm bg-black/10 hover:bg-black/20 cursor-pointer transition-colors duration-200 opacity-100"
ref="postActionBtn"
@click="postDataToggle"
>
<EyeOff v-if="postDataVisible" :size="20" />
<Info :size="20" v-else />
</div>
@ -70,7 +82,7 @@ function postDataToggle() {
ref="postDataTitle"
class="mx-2 scroll-m-20 pb-2 text-3xl font-semibold tracking-tight transition-opacity duration-500 mt-1"
>
{{ post.location.city }}, {{ post.location.country }}
{{ post.city }}, {{ post.country }}
</h2>
<div
ref="postDataDescription"

View file

@ -4,3 +4,29 @@ import { twMerge } from 'tailwind-merge'
export function cn(...inputs) {
return twMerge(clsx(inputs))
}
export function getCityAndCountry(lat, lon) {
return fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=jsonv2`)
.then((resp) => resp.json())
.then((resp) => {
const country = resp.address.country
const cityFieldOrder = [
'city',
'town',
'borough',
'village',
'suburb',
'municipality',
'county',
'state'
]
for (const field of cityFieldOrder) {
if (Object.hasOwn(resp.address, field)) {
return [resp.address[field], country]
}
}
return ['endroit perdu', country]
})
}

View file

@ -3,10 +3,12 @@ import PostsView from '@/views/PostsView.vue'
import { useAuthStore } from '@/stores/auth.js'
const CreatePostView = () => import('@/views/CreatePostView.vue')
const EditPostView = () => import('@/views/EditPostView.vue')
const AdminView = () => import('@/views/AdminView.vue')
const LoginView = () => import('@/views/LoginView.vue')
const MapView = () => import('@/views/MapView.vue')
const SendLocationView = () => import('@/views/SendLocationView.vue')
const NotFoundView = () => import('@/views/NotFoundView.vue')
function authenticatedRoute(to) {
const authStore = useAuthStore()
@ -48,12 +50,19 @@ const router = createRouter({
component: CreatePostView,
beforeEnter: [authenticatedRoute]
},
{
path: '/admin/post/:id',
name: 'admin_edit_post',
component: EditPostView,
beforeEnter: [authenticatedRoute]
},
{
path: '/admin/send-location',
name: 'admin_send_location',
component: SendLocationView,
beforeEnter: [authenticatedRoute]
}
},
{ path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundView }
]
})

View file

@ -1,23 +1,203 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useAuthStore } from '@/stores/auth.js'
import { API_BASE_URL } from '@/config.js'
import { API_BASE_URL, S3_BUCKET, S3_ENDPOINT } from '@/config.js'
export const useAdminPostsStore = defineStore("adminPosts", () => {
const authStore = useAuthStore();
export const useAdminPostsStore = defineStore('adminPosts', () => {
const authStore = useAuthStore()
const posts = ref([])
function fetchPosts() {
if (!authStore.isAuth)
return
if (!authStore.isAuth) return
fetch(API_BASE_URL + "/admin/posts", {
fetch(API_BASE_URL + '/posts', {
headers: {
"X-admin-token": authStore.adminToken
'X-admin-token': authStore.adminToken
}
}).then(r => r.json()).then(r => posts.value = r)
})
.then((r) => r.json())
.then((r) => (posts.value = r))
}
return { posts, fetchPosts }
})
function createAsset(file) {
console.log('Contact API asset')
return fetch(API_BASE_URL + '/assets', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-admin-token': authStore.adminToken
},
body: JSON.stringify({ filename: file.file.name })
})
.catch((e) => {
console.log('Contact API asset failed: ' + e)
throw new Error("Erreur à la préparation de l'envoi d'un média : " + e)
})
.then((response) => {
if (!response.ok) {
console.log('Contact API asset failed: ' + response.statusText + '\n\n' + response.body)
throw new Error("Erreur à la préparation de l'envoi d'un média : " + response.statusText)
}
return response.json()
})
.catch((e) => {
console.log('Parse json API asset failed: ' + e)
throw new Error("Erreur à la préparation de l'envoi d'un média : " + e)
})
}
function uploadAsset(apiAsset, file) {
const mediaUploadFormData = new FormData()
for (const [key, value] of Object.entries(apiAsset.formData)) {
mediaUploadFormData.append(key, value)
}
mediaUploadFormData.append('key', '${filename}')
mediaUploadFormData.append('Content-Type', file.file.type)
mediaUploadFormData.append('file', file.file, apiAsset.filename)
console.log('Envoi image sur s3')
return fetch(`${S3_ENDPOINT}/${S3_BUCKET}/`, {
method: 'POST',
body: mediaUploadFormData
})
.catch((e) => {
console.log('Erreur envoi S3: ' + e)
throw new Error("Une erreur est survenue pendant l'envoi d'un média : " + e)
})
.then((resp) => {
if (!resp.ok) {
console.log('Envoi media S3 failed: ' + resp.statusText + '\n\n' + resp.body)
throw new Error("Une erreur est survenue pendant l'envoi d'un média : " + resp.statusText)
}
})
}
function createApiPost(values, assets) {
return fetch(API_BASE_URL + '/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
})
})
.catch((e) => {
console.log('Erreur envoi post : ' + e)
throw new Error("Une erreur est survenue pendant l'envoi du post : " + e)
})
.then((resp) => {
if (!resp.ok) {
console.log('POST post API failed: ' + resp.statusText + '\n\n' + resp.body)
throw new Error("Une erreur est survenue lors de l'envoi du poste : " + resp.statusText)
}
return true
})
}
function putApiPost(id, values, assets) {
return fetch(API_BASE_URL + `/posts/${id}`, {
method: 'PUT',
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
})
})
.catch((e) => {
console.log('Erreur envoi post : ' + e)
throw new Error("Une erreur est survenue pendant l'envoi du post : " + e)
})
.then((resp) => {
if (!resp.ok) {
console.log('POST post API failed: ' + resp.statusText + '\n\n' + resp.body)
throw new Error("Une erreur est survenue lors de l'envoi du poste : " + resp.statusText)
}
return true
})
}
async function createPost(values, selectedFiles) {
if (!authStore.isAuth) return
console.log('Envoi du post...')
if (selectedFiles.length === 0) {
throw new Error('Post has no asset')
}
const assets = []
for (const file of selectedFiles) {
const createdAsset = await createAsset(file)
await uploadAsset(createdAsset, file)
assets.push(createdAsset.id)
}
return createApiPost(values, assets)
}
async function updatePost(id, values, existingAssets, selectedFiles) {
if (!authStore.isAuth) return
console.log('Envoi du post...')
if (selectedFiles.length + existingAssets.length === 0) {
throw new Error('Post has no asset')
}
const assets = existingAssets.map((asset) => asset.id)
for (const file of selectedFiles) {
const createdAsset = await createAsset(file)
await uploadAsset(createdAsset, file)
assets.push(createdAsset.id)
}
return putApiPost(id, values, assets)
}
function getPost(id) {
if (!authStore.isAuth) return
return fetch(API_BASE_URL + `/posts/${id}`, {
headers: {
'X-admin-token': authStore.adminToken
}
}).then((resp) => {
if (resp.ok) {
return resp.json()
}
})
}
function deletePost(id) {
if (!authStore.isAuth) return
fetch(API_BASE_URL + `/posts/${id}`, {
method: 'DELETE',
headers: {
'X-admin-token': authStore.adminToken
}
}).then((resp) => {
if (resp.ok) {
fetchPosts()
}
})
}
return { posts, fetchPosts, createPost, deletePost, getPost, updatePost }
})

View file

@ -12,7 +12,7 @@ export const useAuthStore = defineStore('auth', () => {
isAuth.value = false
error.value = false
return fetch(API_BASE_URL + '/admin/auth/check', {
return fetch(API_BASE_URL + '/auth/check', {
headers: {
'X-admin-token': token
}

View file

@ -22,7 +22,7 @@ export const usePostsStore = defineStore('posts', () => {
const postDate = new Date(post.date)
post.formatedDate = formatRelative(postDate, new Date(), { locale: fr })
post.formatedDescription = marked.parse(post.description)
post.projectedCoordinates = fromLonLat([post.location.lon, post.location.lat]);
post.projectedCoordinates = fromLonLat([post.longitude, post.latitude])
}
return posts.value

View file

@ -13,27 +13,35 @@ import {
TableRow
} from '@/components/ui/table/index.js'
const adminPostsStore = useAdminPostsStore();
const adminPostsStore = useAdminPostsStore()
onMounted(() => {
adminPostsStore.fetchPosts();
adminPostsStore.fetchPosts()
})
</script>
<template>
<div class="grid grid-cols-3 grid-rows-1 mt-28 sm: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>
<h1 class="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl mb-6">Admin</h1>
<h2
class="scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0"
>
Localisation
</h2>
<RouterLink to="/admin/send-location">
<Button class="mb-6 mr-2">
<Button class="my-4">
<MapPin class="mr-2" />
Envoyer localisation
</Button>
</RouterLink>
<h2
class="scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0"
>
Posts
</h2>
<RouterLink to="/admin/post/create">
<Button class="mb-6">
<Button class="my-4">
<CirclePlus class="mr-2" />
Créer
</Button>
@ -56,11 +64,13 @@ onMounted(() => {
<TableCell>{{ post.country }}</TableCell>
<TableCell>
<div class="flex gap-2">
<Button>
<Pencil size="16"/>
</Button>
<Button>
<Trash size="16"/>
<RouterLink :to="{ name: 'admin_edit_post', params: { id: post.id } }">
<Button>
<Pencil size="16" />
</Button>
</RouterLink>
<Button @click="adminPostsStore.deletePost(post.id)">
<Trash size="16" />
</Button>
</div>
</TableCell>

View file

@ -25,18 +25,20 @@ import {
} 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'
import { API_BASE_URL, S3_BUCKET, S3_ENDPOINT } from '@/config.js'
import { getCityAndCountry } from '@/lib/utils.js'
import { useAdminPostsStore } from '@/stores/adminPosts.js'
const authStore = useAuthStore()
const adminPostStore = useAdminPostsStore()
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 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
})
@ -53,96 +55,18 @@ const formStatus = ref({
errorMsg: ''
})
function formError(message) {
formStatus.value.sending = false
formStatus.value.error = true
formStatus.value.errorMsg = message
formContainer.value.classList.remove('invisible')
}
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('hidden')
formStatus.value.sending = true
try {
await adminPostStore.createPost(values, selectedFiles.value)
} catch (e) {
formStatus.value.sending = false
formStatus.value.error = true
formStatus.value.errorMsg = e.message
formContainer.value.classList.remove('invisible')
const assets = []
for (const file of selectedFiles.value) {
console.log('Contact API asset')
const response = await fetch(API_BASE_URL + '/admin/assets', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-admin-token': authStore.adminToken
},
body: JSON.stringify({ filename: file.file.name })
}).catch((e) => {
console.log('Contact API asset failed: ' + e)
formError('Erreur à la préparation de l\'envoi d\'un média : ' + e)
})
if (!response)
return
if (!response.ok) {
console.log('Contact API asset failed: ' + response.statusText + '\n\n' + response.body)
formError('Erreur à la préparation de l\'envoi d\'un média : ' + response.statusText)
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(`${S3_ENDPOINT}/${S3_BUCKET}/`, {
method: 'POST',
body: mediaUploadFormData
}).catch((e) => {
console.log('Erreur envoi S3: ' + e)
formError('Une erreur est survenue pendant l\'envoi d\'un média : ' + e)
})
if (!s3Response)
return
if (!s3Response.ok) {
console.log('Envoi media S3 failed: ' + s3Response.statusText + '\n\n' + s3Response.body)
formError('Une erreur est survenue pendant l\'envoi d\'un média : ' + s3Response.statusText)
return
}
assets.push(responseBody.id)
}
const response = await fetch(API_BASE_URL + '/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
})
}).catch((e) => {
console.log('Erreur envoi post : ' + e)
formError('Une erreur est survenue pendant l\'envoi du post : ' + e)
})
if (!response)
return
if (!response.ok) {
console.log('POST post API failed: ' + response.statusText + '\n\n' + response.body)
formError('Une erreur est survenue lors de l\'envoi du poste : ' + response.statusText)
return
throw e
}
formStatus.value.sending = false
@ -179,31 +103,17 @@ function mediaReorder(index, diff) {
selectedFiles.value[index + diff] = tmp
}
function getCityAndCountry() {
function updateCityAndCountry() {
const lat = latitudeInput.value.value
const lon = longitudeInput.value.value
if (!lat || !lon)
return
fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=jsonv2`)
.then((resp) => resp.json()).then((resp) => {
const cityFieldOrder = ['city', 'town', 'borough', 'village', 'suburb', 'municipality', 'county', 'state']
let found = false
for (const field of cityFieldOrder) {
if (Object.hasOwn(resp.address, field)) {
found = true
form.setFieldValue('city', resp.address[field])
break
}
}
if (!found) {
form.setFieldValue('city', 'endroit perdu')
}
form.setFieldValue('country', resp.address.country)
if (!lat || !lon) return
getCityAndCountry(lat, lon).then((cityAndCountry) => {
form.setFieldValue('city', cityAndCountry[0])
form.setFieldValue('country', cityAndCountry[1])
})
}
</script>
<template>
@ -273,24 +183,34 @@ function getCityAndCountry() {
</FormControl>
</FormItem>
</FormField>
<Button class="place-self-end" @click="getCityAndCountry">
<Button class="place-self-end" @click="updateCityAndCountry">
<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" />
<Input
id="medias"
type="file"
accept="image/*,video/*"
multiple
@change="onFilesSelected"
/>
</form>
<Carousel
class="w-full mt-10"
:opts="{
align: 'start',
}"
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">
<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">
@ -320,5 +240,4 @@ function getCityAndCountry() {
</div>
</template>
<style scoped>
</style>
<style scoped></style>

View file

@ -0,0 +1,272 @@
<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, ref } from 'vue'
import {
AlertCircle,
ArrowLeft,
ArrowRight,
CircleCheckBig,
Save,
RefreshCcw,
Trash
} 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 { getCityAndCountry } from '@/lib/utils.js'
import { useAdminPostsStore } from '@/stores/adminPosts.js'
import { onBeforeRouteUpdate, useRoute } from 'vue-router'
const adminPostStore = useAdminPostsStore()
const route = useRoute()
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 postData = ref({})
const currentAssets = ref([])
onMounted(async () => {
postData.value = await adminPostStore.getPost(route.params.id)
form.setValues(postData.value)
currentAssets.value = postData.value.assets
})
onBeforeRouteUpdate(async (to, from) => {
postData.value = await adminPostStore.getPost(to.params.id)
form.setValues(postData.value)
currentAssets.value = postData.value.assets
})
const onSubmit = form.handleSubmit(async (values) => {
formContainer.value.classList.add('hidden')
formStatus.value.sending = true
try {
await adminPostStore.updatePost(
route.params.id,
values,
currentAssets.value,
selectedFiles.value
)
} catch (e) {
formStatus.value.sending = false
formStatus.value.error = true
formStatus.value.errorMsg = e.message
formContainer.value.classList.remove('invisible')
throw e
}
formStatus.value.sending = false
formStatus.value.sent = true
})
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
}
function updateCityAndCountry() {
const lat = latitudeInput.value.value
const lon = longitudeInput.value.value
if (!lat || !lon) return
getCityAndCountry(lat, lon).then((cityAndCountry) => {
form.setFieldValue('city', cityAndCountry[0])
form.setFieldValue('country', cityAndCountry[1])
})
}
function removeAsset(id) {
currentAssets.value = currentAssets.value.filter((e) => e.id !== id)
}
</script>
<template>
<div class="grid grid-cols-3 grid-rows-1 mt-28 sm: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">
Modifier le poste #{{ $route.params.id }}
</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 modifié !</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 v-bind="componentField"></Input>
</FormControl>
</FormItem>
</FormField>
<FormField ref="longitudeInput" v-slot="{ componentField }" name="longitude">
<FormItem>
<FormLabel>Longitude</FormLabel>
<FormControl>
<Input 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" @click="updateCityAndCountry">
<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 + currentAssets.length > 0"
>
<CarouselContent>
<CarouselItem
v-for="(asset, index) in currentAssets"
: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="asset.presignedUrl" />
</CardContent>
</Card>
<div class="grid justify-items-center mt-2">
<Button @click="removeAsset(asset.id)">
<Trash />
</Button>
</div>
</div>
</CarouselItem>
<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">
<Save />
</Button>
</div>
</div>
</div>
</template>
<style scoped></style>

View file

@ -0,0 +1,13 @@
<script setup></script>
<template>
<div class="fixed w-full h-full flex justify-center items-center">
<div>
<h1 class="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">
Cette page n'existe pas.
</h1>
</div>
</div>
</template>
<style scoped></style>

View file

@ -41,7 +41,7 @@ const onSubmit = form.handleSubmit(async (values) => {
formContainer.value.classList.add('hidden')
formStatus.value.sending = true
const response = await fetch(API_BASE_URL + '/admin/location', {
const response = await fetch(API_BASE_URL + '/location', {
method: 'POST',
headers: {
'Content-Type': 'application/json',