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

@ -8,9 +8,9 @@ import jakarta.enterprise.context.ApplicationScoped;
public class LocationConverter implements Converter<Location, LocationEntity> { public class LocationConverter implements Converter<Location, LocationEntity> {
@Override public Location toLeft(LocationEntity locationEntity) { @Override public Location toLeft(LocationEntity locationEntity) {
return new Location().withId(locationEntity.id) return new Location().withId(locationEntity.id)
.withLatitude(locationEntity.latitude) .withLatitude(locationEntity.latitude)
.withLongitude(locationEntity.longitude) .withLongitude(locationEntity.longitude)
.withTimestamp(locationEntity.timestamp); .withTimestamp(locationEntity.timestamp);
} }
@Override public LocationEntity toRight(Location location) { @Override public LocationEntity toRight(Location location) {

View file

@ -1,25 +0,0 @@
package fr.kektus.summer2024.converters;
import fr.kektus.summer2024.data.model.Post;
import fr.kektus.summer2024.domain.service.AssetService;
import fr.kektus.summer2024.presentation.rest.PostApi;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class PublicPostConverter {
@Inject AssetService assetService;
public PostApi.PostDto toPostDto(Post post) {
return new PostApi.PostDto().withId(post.id)
.withDate(post.date)
.withDescription(post.description)
.withAssets(post.assets.stream()
.map(assetService::getPresignedUrlForAsset)
.toList())
.withLocation(new PostApi.PostDto.Location().withCity(post.city)
.withCountry(post.country)
.withLat(post.latitude)
.withLon(post.longitude));
}
}

View file

@ -1,6 +1,5 @@
package fr.kektus.summer2024.domain.entity; package fr.kektus.summer2024.domain.entity;
import fr.kektus.summer2024.data.model.Asset;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;

View file

@ -6,6 +6,7 @@ import fr.kektus.summer2024.domain.entity.PresignedAsset;
import io.minio.GetPresignedObjectUrlArgs; import io.minio.GetPresignedObjectUrlArgs;
import io.minio.MinioClient; import io.minio.MinioClient;
import io.minio.PostPolicy; import io.minio.PostPolicy;
import io.minio.RemoveObjectArgs;
import io.minio.errors.MinioException; import io.minio.errors.MinioException;
import io.minio.http.Method; import io.minio.http.Method;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
@ -62,4 +63,15 @@ public class AssetService {
throw new InternalServerErrorException(e); throw new InternalServerErrorException(e);
} }
} }
@Transactional
public void deleteAsset(Asset asset) {
try {
minioClient.removeObject(RemoveObjectArgs.builder().bucket(bucketName).object(asset.filename).build());
} catch (MinioException | InvalidKeyException | IOException | NoSuchAlgorithmException e) {
throw new InternalServerErrorException(e);
}
assetRepository.delete(asset);
}
} }

View file

@ -6,7 +6,6 @@ import jakarta.inject.Inject;
import jakarta.ws.rs.NotAuthorizedException; import jakarta.ws.rs.NotAuthorizedException;
import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.wildfly.common.Assert;
@ApplicationScoped @ApplicationScoped
public class AuthService { public class AuthService {

View file

@ -1,6 +1,5 @@
package fr.kektus.summer2024.domain.service; package fr.kektus.summer2024.domain.service;
import fr.kektus.summer2024.data.model.Asset;
import fr.kektus.summer2024.data.model.Post; import fr.kektus.summer2024.data.model.Post;
import fr.kektus.summer2024.data.repository.AssetRepository; import fr.kektus.summer2024.data.repository.AssetRepository;
import fr.kektus.summer2024.data.repository.PostRepository; import fr.kektus.summer2024.data.repository.PostRepository;
@ -11,6 +10,8 @@ import io.quarkus.panache.common.Sort;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.transaction.Transactional; import jakarta.transaction.Transactional;
import jakarta.ws.rs.BadRequestException;
import org.jboss.logging.Logger;
import org.wildfly.common.Assert; import org.wildfly.common.Assert;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
@ -21,10 +22,11 @@ public class PostService {
@Inject PostRepository postRepository; @Inject PostRepository postRepository;
@Inject AssetRepository assetRepository; @Inject AssetRepository assetRepository;
@Inject AssetService assetService; @Inject AssetService assetService;
@Inject Logger log;
@Transactional @Transactional
public List<PostNestedEntity> getAllPosts() { public List<PostNestedEntity> getAllPosts() {
return postRepository.findAll().stream().map(post -> new PostNestedEntity() return postRepository.findAll(Sort.descending("id")).stream().map(post -> new PostNestedEntity()
.withId(post.id) .withId(post.id)
.withDescription(post.description) .withDescription(post.description)
.withDate(post.date) .withDate(post.date)
@ -38,10 +40,6 @@ public class PostService {
).toList())).toList(); ).toList())).toList();
} }
public List<Post> getAllPostsOrdered() {
return postRepository.findAll(Sort.descending("id")).stream().toList();
}
@Transactional @Transactional
public void createPost(PostEntity post) { public void createPost(PostEntity post) {
Assert.assertNotNull(post); Assert.assertNotNull(post);
@ -53,19 +51,73 @@ public class PostService {
Assert.assertNotNull(post.assets); Assert.assertNotNull(post.assets);
Assert.assertFalse(post.assets.isEmpty()); Assert.assertFalse(post.assets.isEmpty());
List<Asset> postAssets = post.assets.stream().map(assetRepository::findById).toList(); final Post model = new Post().withDescription(post.description)
Post model = new Post().withDescription(post.description) .withDate(ZonedDateTime.now())
.withDate(ZonedDateTime.now()) .withLatitude(post.latitude)
.withLatitude(post.latitude) .withLongitude(post.longitude)
.withLongitude(post.longitude) .withCity(post.city)
.withCity(post.city) .withCountry(post.country);
.withCountry(post.country)
.withAssets(postAssets);
postRepository.persist(model); postRepository.persist(model);
postAssets.forEach(asset -> asset.setPost(model));
postAssets.forEach(asset -> assetRepository.persist(asset)); final int updatedAssetCount = assetRepository.update("post.id = ?1 where post.id is null and id in ?2",
model.id,
post.assets);
if (updatedAssetCount != post.assets.size()) {
log.debug(String.format("post create error: updated %d assets, expected %d",
updatedAssetCount,
post.assets.size()));
throw new BadRequestException("one ore more asset does not exist or is already in a post");
}
post.id = model.id; post.id = model.id;
post.date = model.date; post.date = model.date;
} }
public PostNestedEntity getPost(Long id) {
final Post post = postRepository.findById(id);
return new PostNestedEntity()
.withId(post.id)
.withDescription(post.description)
.withDate(post.date)
.withLatitude(post.latitude)
.withLongitude(post.longitude)
.withCity(post.city)
.withCountry(post.country)
.withAssets(post.assets.stream().map(asset -> new AssetEntity()
.withId(asset.id)
.withPresignedUrl(assetService.getPresignedUrlForAsset(asset))
).toList());
}
@Transactional
public PostEntity updatePost(PostEntity post) {
final Post model = postRepository.findById(post.id);
assetRepository.update("post.id = null where post.id = ?1 and id not in ?2", post.id, post.assets);
final var updatedAssetCount = assetRepository.update(
"post.id = ?1 where (post.id is null or post.id = ?1) and id in ?2",
post.id,
post.assets);
if (updatedAssetCount != post.assets.size())
throw new BadRequestException("one ore more asset does not exist or is already in a post");
model.setDescription(post.description);
model.setLatitude(post.latitude);
model.setLongitude(post.longitude);
model.setCity(post.city);
model.setCountry(post.country);
postRepository.persist(model);
post.date = model.date;
return post;
}
@Transactional
public void deletePost(Long id) {
final Post post = postRepository.findById(id);
post.assets.forEach(assetService::deleteAsset);
postRepository.delete(post);
}
} }

View file

@ -17,7 +17,7 @@ import org.wildfly.common.Assert;
import java.util.Map; import java.util.Map;
@Path("/admin/assets") @Path("/assets")
@Consumes(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
public class AdminAssetApi { public class AdminAssetApi {
@ -25,7 +25,8 @@ public class AdminAssetApi {
@Inject AuthService authService; @Inject AuthService authService;
@POST @POST
public CreateAssets.Response createAsset(@RestHeader("X-admin-token") final String adminToken, CreateAssets.Request request) { public CreateAssets.Response createAsset(@RestHeader("X-admin-token") final String adminToken,
CreateAssets.Request request) {
if (!authService.isAdminTokenValid(adminToken)) if (!authService.isAdminTokenValid(adminToken))
throw new NotAuthorizedException("provided admin token is invalid"); throw new NotAuthorizedException("provided admin token is invalid");

View file

@ -1,9 +1,7 @@
package fr.kektus.summer2024.presentation.rest; package fr.kektus.summer2024.presentation.rest;
import fr.kektus.summer2024.domain.entity.PostEntity;
import fr.kektus.summer2024.domain.service.AuthService; import fr.kektus.summer2024.domain.service.AuthService;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET; import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotAuthorizedException; import jakarta.ws.rs.NotAuthorizedException;
import jakarta.ws.rs.Path; import jakarta.ws.rs.Path;
@ -14,7 +12,7 @@ import lombok.NoArgsConstructor;
import lombok.With; import lombok.With;
import org.jboss.resteasy.reactive.RestHeader; import org.jboss.resteasy.reactive.RestHeader;
@Path("/admin/auth") @Path("/auth")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
public class AdminAuthApi { public class AdminAuthApi {
@Inject AuthService authService; @Inject AuthService authService;

View file

@ -1,47 +0,0 @@
package fr.kektus.summer2024.presentation.rest;
import fr.kektus.summer2024.Validators;
import fr.kektus.summer2024.domain.entity.LocationEntity;
import fr.kektus.summer2024.domain.service.AuthService;
import fr.kektus.summer2024.domain.service.LocationService;
import jakarta.inject.Inject;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.NotAuthorizedException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
import lombok.With;
import org.jboss.resteasy.reactive.RestHeader;
@Path("/admin/location")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class AdminLocationApi {
@Inject AuthService authService;
@Inject LocationService locationService;
@POST
public LocationEntity createPost(@RestHeader("X-admin-token") final String adminToken,
CreateLocation.Request request) {
if (!authService.isAdminTokenValid(adminToken))
throw new NotAuthorizedException("provided admin token is invalid");
Validators.assertNotNull(request, new BadRequestException());
Validators.assertNotNull(request.latitude, new BadRequestException());
Validators.assertNotNull(request.longitude, new BadRequestException());
return locationService.createLocation(request.latitude, request.longitude);
}
public static class CreateLocation {
@NoArgsConstructor @AllArgsConstructor @With
public static class Request {
public Float latitude;
public Float longitude;
}
}
}

View file

@ -1,65 +0,0 @@
package fr.kektus.summer2024.presentation.rest;
import fr.kektus.summer2024.domain.entity.PostEntity;
import fr.kektus.summer2024.domain.entity.PostNestedEntity;
import fr.kektus.summer2024.domain.service.AuthService;
import fr.kektus.summer2024.domain.service.PostService;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotAuthorizedException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
import lombok.With;
import org.jboss.resteasy.reactive.RestHeader;
import java.util.List;
@Path("/admin/posts")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class AdminPostApi {
@Inject PostService postService;
@Inject AuthService authService;
@POST
public PostEntity createPost(@RestHeader("X-admin-token") final String adminToken, CreatePost.Request request) {
if (!authService.isAdminTokenValid(adminToken))
throw new NotAuthorizedException("provided admin token is invalid");
PostEntity post = new PostEntity().withDescription(request.description)
.withLatitude(request.latitude)
.withLongitude(request.longitude)
.withCity(request.city)
.withCountry(request.country)
.withAssets(request.assets);
postService.createPost(post);
return post;
}
@GET
public List<PostNestedEntity> getPosts(@RestHeader("X-admin-token") final String adminToken) {
if (!authService.isAdminTokenValid(adminToken))
throw new NotAuthorizedException("provided admin token is invalid");
return postService.getAllPosts();
}
public static class CreatePost {
@NoArgsConstructor @AllArgsConstructor @With
public static class Request {
public String description;
public Float latitude;
public Float longitude;
public String city;
public String country;
public List<Long> assets;
}
}
}

View file

@ -1,21 +1,51 @@
package fr.kektus.summer2024.presentation.rest; package fr.kektus.summer2024.presentation.rest;
import fr.kektus.summer2024.Validators;
import fr.kektus.summer2024.domain.entity.LocationEntity; import fr.kektus.summer2024.domain.entity.LocationEntity;
import fr.kektus.summer2024.domain.service.AuthService;
import fr.kektus.summer2024.domain.service.LocationService; import fr.kektus.summer2024.domain.service.LocationService;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes; import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.GET; import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotAuthorizedException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path; import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces; import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
import lombok.With;
import org.jboss.resteasy.reactive.RestHeader;
@Path("/location") @Path("/location")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
public class LocationApi { public class LocationApi {
@Inject AuthService authService;
@Inject LocationService locationService; @Inject LocationService locationService;
@GET @Path("/last") @GET @Path("/last")
public LocationEntity getLastLocation() { public LocationEntity getLastLocation() {
return locationService.getLatestLocation(); return locationService.getLatestLocation();
} }
@POST
public LocationEntity createPost(@RestHeader("X-admin-token") final String adminToken,
CreateLocation.Request request) {
if (!authService.isAdminTokenValid(adminToken))
throw new NotAuthorizedException("provided admin token is invalid");
Validators.assertNotNull(request, new BadRequestException());
Validators.assertNotNull(request.latitude, new BadRequestException());
Validators.assertNotNull(request.longitude, new BadRequestException());
return locationService.createLocation(request.latitude, request.longitude);
}
public static class CreateLocation {
@NoArgsConstructor @AllArgsConstructor @With
public static class Request {
public Float latitude;
public Float longitude;
}
}
} }

View file

@ -1,20 +1,25 @@
package fr.kektus.summer2024.presentation.rest; package fr.kektus.summer2024.presentation.rest;
import fr.kektus.summer2024.converters.PublicPostConverter; import fr.kektus.summer2024.domain.entity.PostEntity;
import fr.kektus.summer2024.domain.entity.PostNestedEntity;
import fr.kektus.summer2024.domain.service.AuthService;
import fr.kektus.summer2024.domain.service.PostService; import fr.kektus.summer2024.domain.service.PostService;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes; import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET; import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotAuthorizedException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path; import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces; import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.With; import lombok.With;
import org.jboss.resteasy.reactive.RestHeader;
import java.time.ZonedDateTime;
import java.util.List; import java.util.List;
@Path("/posts") @Path("/posts")
@ -22,29 +27,89 @@ import java.util.List;
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
public class PostApi { public class PostApi {
@Inject PostService postService; @Inject PostService postService;
@Inject PublicPostConverter postConverter; @Inject AuthService authService;
@NoArgsConstructor @AllArgsConstructor @With @GET
@Getter @Setter public List<PostNestedEntity> getAllPosts() {
public static class PostDto { return postService.getAllPosts();
@NoArgsConstructor @AllArgsConstructor @With }
@Getter @Setter
public static class Location {
public Float lat;
public Float lon;
public String city;
public String country;
}
public Long id; @POST
public ZonedDateTime date; public PostEntity createPost(@RestHeader("X-admin-token") final String adminToken, CreatePost.Request request) {
public String description; if (!authService.isAdminTokenValid(adminToken))
public Location location; throw new NotAuthorizedException("provided admin token is invalid");
public List<String> assets;
PostEntity post = new PostEntity().withDescription(request.description)
.withLatitude(request.latitude)
.withLongitude(request.longitude)
.withCity(request.city)
.withCountry(request.country)
.withAssets(request.assets);
postService.createPost(post);
return post;
} }
@GET @GET
public List<PostDto> list() { @Path("/{id}")
return postService.getAllPostsOrdered().stream().map(postConverter::toPostDto).toList(); public PostNestedEntity getPost(@RestHeader("X-admin-token") final String adminToken,
@PathParam("id") final Long id) {
if (!authService.isAdminTokenValid(adminToken))
throw new NotAuthorizedException("provided admin token is invalid");
return postService.getPost(id);
}
@DELETE
@Path("/{id}")
public void deletePost(@RestHeader("X-admin-token") final String adminToken, @PathParam("id") final Long id) {
if (!authService.isAdminTokenValid(adminToken))
throw new NotAuthorizedException("provided admin token is invalid");
postService.deletePost(id);
}
@PUT
@Path("/{id}")
public PostEntity updatePost(@RestHeader("X-admin-token") final String adminToken,
@PathParam("id") final Long id,
final UpdatePost.Request request) {
if (!authService.isAdminTokenValid(adminToken))
throw new NotAuthorizedException("provided admin token is invalid");
return postService.updatePost(new PostEntity()
.withId(id)
.withDescription(request.description)
.withLatitude(request.latitude)
.withLongitude(request.longitude)
.withCity(request.city)
.withCountry(request.country)
.withAssets(request.assets)
);
}
public static class CreatePost {
@NoArgsConstructor @AllArgsConstructor @With
public static class Request {
public String description;
public Float latitude;
public Float longitude;
public String city;
public String country;
public List<Long> assets;
}
}
public static class UpdatePost {
@NoArgsConstructor @AllArgsConstructor @With
public static class Request {
public String description;
public Float latitude;
public Float longitude;
public String city;
public String country;
public List<Long> assets;
}
} }
} }

View file

@ -58,11 +58,23 @@ function postDataToggle() {
<template> <template>
<div class="section relative overflow-hidden" ref="section" :data-anchor="'post-' + post.id"> <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"> <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" /> <img
<div class="grid grid-cols-3 grid-rows-5 absolute w-full h-full top-0 left-0 pointer-events-none"> class="absolute top-0 left-0 w-full h-full object-cover -z-10"
<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"> :data-src="asset.presignedUrl"
<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="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" /> <EyeOff v-if="postDataVisible" :size="20" />
<Info :size="20" v-else /> <Info :size="20" v-else />
</div> </div>
@ -70,7 +82,7 @@ function postDataToggle() {
ref="postDataTitle" ref="postDataTitle"
class="mx-2 scroll-m-20 pb-2 text-3xl font-semibold tracking-tight transition-opacity duration-500 mt-1" 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> </h2>
<div <div
ref="postDataDescription" ref="postDataDescription"

View file

@ -4,3 +4,29 @@ import { twMerge } from 'tailwind-merge'
export function cn(...inputs) { export function cn(...inputs) {
return twMerge(clsx(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' import { useAuthStore } from '@/stores/auth.js'
const CreatePostView = () => import('@/views/CreatePostView.vue') const CreatePostView = () => import('@/views/CreatePostView.vue')
const EditPostView = () => import('@/views/EditPostView.vue')
const AdminView = () => import('@/views/AdminView.vue') const AdminView = () => import('@/views/AdminView.vue')
const LoginView = () => import('@/views/LoginView.vue') const LoginView = () => import('@/views/LoginView.vue')
const MapView = () => import('@/views/MapView.vue') const MapView = () => import('@/views/MapView.vue')
const SendLocationView = () => import('@/views/SendLocationView.vue') const SendLocationView = () => import('@/views/SendLocationView.vue')
const NotFoundView = () => import('@/views/NotFoundView.vue')
function authenticatedRoute(to) { function authenticatedRoute(to) {
const authStore = useAuthStore() const authStore = useAuthStore()
@ -48,12 +50,19 @@ const router = createRouter({
component: CreatePostView, component: CreatePostView,
beforeEnter: [authenticatedRoute] beforeEnter: [authenticatedRoute]
}, },
{
path: '/admin/post/:id',
name: 'admin_edit_post',
component: EditPostView,
beforeEnter: [authenticatedRoute]
},
{ {
path: '/admin/send-location', path: '/admin/send-location',
name: 'admin_send_location', name: 'admin_send_location',
component: SendLocationView, component: SendLocationView,
beforeEnter: [authenticatedRoute] beforeEnter: [authenticatedRoute]
} },
{ path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundView }
] ]
}) })

View file

@ -1,23 +1,203 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { useAuthStore } from '@/stores/auth.js' 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", () => { export const useAdminPostsStore = defineStore('adminPosts', () => {
const authStore = useAuthStore(); const authStore = useAuthStore()
const posts = ref([]) const posts = ref([])
function fetchPosts() { function fetchPosts() {
if (!authStore.isAuth) if (!authStore.isAuth) return
return
fetch(API_BASE_URL + "/admin/posts", { fetch(API_BASE_URL + '/posts', {
headers: { 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 isAuth.value = false
error.value = false error.value = false
return fetch(API_BASE_URL + '/admin/auth/check', { return fetch(API_BASE_URL + '/auth/check', {
headers: { headers: {
'X-admin-token': token 'X-admin-token': token
} }

View file

@ -22,7 +22,7 @@ export const usePostsStore = defineStore('posts', () => {
const postDate = new Date(post.date) const postDate = new Date(post.date)
post.formatedDate = formatRelative(postDate, new Date(), { locale: fr }) post.formatedDate = formatRelative(postDate, new Date(), { locale: fr })
post.formatedDescription = marked.parse(post.description) 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 return posts.value

View file

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

View file

@ -25,18 +25,20 @@ import {
} from '@/components/ui/carousel/index.js' } from '@/components/ui/carousel/index.js'
import { Card, CardContent } from '@/components/ui/card/index.js' import { Card, CardContent } from '@/components/ui/card/index.js'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert/index.js' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert/index.js'
import { useAuthStore } from '@/stores/auth.js' import { getCityAndCountry } from '@/lib/utils.js'
import { API_BASE_URL, S3_BUCKET, S3_ENDPOINT } from '@/config.js' import { useAdminPostsStore } from '@/stores/adminPosts.js'
const authStore = useAuthStore() const adminPostStore = useAdminPostsStore()
const formSchema = toTypedSchema(z.object({ const formSchema = toTypedSchema(
description: z.string().min(1), z.object({
latitude: z.number(), description: z.string().min(1),
longitude: z.number(), latitude: z.number(),
city: z.string().min(1), longitude: z.number(),
country: z.string().min(1) city: z.string().min(1),
})) country: z.string().min(1)
})
)
const form = useForm({ const form = useForm({
validationSchema: formSchema validationSchema: formSchema
}) })
@ -53,96 +55,18 @@ const formStatus = ref({
errorMsg: '' 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) => { 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') formContainer.value.classList.add('hidden')
formStatus.value.sending = true 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 = [] throw e
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
} }
formStatus.value.sending = false formStatus.value.sending = false
@ -179,31 +103,17 @@ function mediaReorder(index, diff) {
selectedFiles.value[index + diff] = tmp selectedFiles.value[index + diff] = tmp
} }
function getCityAndCountry() { function updateCityAndCountry() {
const lat = latitudeInput.value.value const lat = latitudeInput.value.value
const lon = longitudeInput.value.value const lon = longitudeInput.value.value
if (!lat || !lon)
return
fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=jsonv2`) if (!lat || !lon) return
.then((resp) => resp.json()).then((resp) => {
const cityFieldOrder = ['city', 'town', 'borough', 'village', 'suburb', 'municipality', 'county', 'state'] getCityAndCountry(lat, lon).then((cityAndCountry) => {
let found = false form.setFieldValue('city', cityAndCountry[0])
for (const field of cityFieldOrder) { form.setFieldValue('country', cityAndCountry[1])
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)
}) })
} }
</script> </script>
<template> <template>
@ -273,24 +183,34 @@ function getCityAndCountry() {
</FormControl> </FormControl>
</FormItem> </FormItem>
</FormField> </FormField>
<Button class="place-self-end" @click="getCityAndCountry"> <Button class="place-self-end" @click="updateCityAndCountry">
<RefreshCcw /> <RefreshCcw />
</Button> </Button>
</div> </div>
</form> </form>
<form id="assets-form" class="mt-6"> <form id="assets-form" class="mt-6">
<Label for="medias">Medias</Label> <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> </form>
<Carousel <Carousel
class="w-full mt-10" class="w-full mt-10"
:opts="{ :opts="{
align: 'start', align: 'start'
}" }"
v-if="selectedFiles.length > 0" v-if="selectedFiles.length > 0"
> >
<CarouselContent> <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"> <div class="flex flex-col">
<Card> <Card>
<CardContent class="flex aspect-square items-center justify-center p-6"> <CardContent class="flex aspect-square items-center justify-center p-6">
@ -320,5 +240,4 @@ function getCityAndCountry() {
</div> </div>
</template> </template>
<style scoped> <style scoped></style>
</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') formContainer.value.classList.add('hidden')
formStatus.value.sending = true formStatus.value.sending = true
const response = await fetch(API_BASE_URL + '/admin/location', { const response = await fetch(API_BASE_URL + '/location', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',