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
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
|
||||||
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')
|
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',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue