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:
parent
2647ac244d
commit
efde8738a8
23 changed files with 803 additions and 343 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
]
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
272
summer2024-frontend/src/views/EditPostView.vue
Normal file
272
summer2024-frontend/src/views/EditPostView.vue
Normal 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>
|
||||
13
summer2024-frontend/src/views/NotFoundView.vue
Normal file
13
summer2024-frontend/src/views/NotFoundView.vue
Normal 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>
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue