Implement Photo and Tag management features: Add controllers, services, and repositories for photo and tag handling; enhance metadata extraction; update home and photo templates for improved user experience.
This commit is contained in:
parent
b587244dae
commit
527f770a18
|
|
@ -1,13 +1,19 @@
|
|||
package com.example.PhotoGallery.Controller;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import com.example.PhotoGallery.Models.Photo;
|
||||
import com.example.PhotoGallery.Services.PhotoService;
|
||||
|
||||
@Controller
|
||||
public class PhotosController {
|
||||
public class PhotoHomeController {
|
||||
|
||||
@Autowired
|
||||
PhotoService photoService;
|
||||
|
|
@ -20,11 +26,12 @@ public class PhotosController {
|
|||
@GetMapping("/home")
|
||||
private String home(Model model) {
|
||||
|
||||
//reset the page counter when the page home page is reloaded
|
||||
// reset the page counter when the page home page is reloaded
|
||||
pageCounter = 0;
|
||||
|
||||
//upon a page refresh we will scan the db and add any new images that are in the directory
|
||||
photoService.WriteImagesFromDirToDB(); //TODO: run this method asynchronously
|
||||
// upon a page refresh we will scan the db and add any new images that are in
|
||||
// the directory
|
||||
photoService.WriteImagesFromDirToDB(); // TODO: run this method asynchronously
|
||||
|
||||
model.addAttribute("images", photoService.getPagedPhotos(FIRST_PAGE, PAGE_SIZE));
|
||||
model.addAttribute("carouselImages", photoService.getRandomPhotos(RANDOM_NUMBER_IMAGES));
|
||||
|
|
@ -37,8 +44,8 @@ public class PhotosController {
|
|||
public String loadMoreImages(Model model) {
|
||||
int totalPages = photoService.getPagedPhotos(FIRST_PAGE, PAGE_SIZE).getTotalPages();
|
||||
|
||||
//dont load any more images if we have reached the last images
|
||||
if (pageCounter == totalPages){
|
||||
// dont load any more images if we have reached the last images
|
||||
if (pageCounter == totalPages) {
|
||||
return "home :: images";
|
||||
}
|
||||
|
||||
|
|
@ -47,4 +54,15 @@ public class PhotosController {
|
|||
return "home :: images";
|
||||
}
|
||||
|
||||
@PostMapping("/search")
|
||||
public String search(Model model, @RequestParam("search") String search) {
|
||||
|
||||
List<Photo> searchedPhotos = photoService.findAllBySearch(search);
|
||||
|
||||
model.addAttribute("images", searchedPhotos);
|
||||
|
||||
return "home :: image-container";
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
package com.example.PhotoGallery.Controller;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import com.example.PhotoGallery.Models.Photo;
|
||||
import com.example.PhotoGallery.Services.PhotoService;
|
||||
|
||||
@Controller
|
||||
public class PhotoViewController {
|
||||
|
||||
@Autowired
|
||||
private PhotoService photoService;
|
||||
|
||||
private ArrayList<String> tags = new ArrayList<>();
|
||||
|
||||
@GetMapping("/photo/{id}")
|
||||
private String photoView(Model model, @PathVariable("id") Long id) {
|
||||
|
||||
// reset tags on page load
|
||||
tags.clear();
|
||||
|
||||
final int PAGE_SIZE = 30;
|
||||
final int PAGE_NUM = 0;
|
||||
|
||||
Photo photo = photoService.findById(id);
|
||||
|
||||
model.addAttribute("thumbnails", photoService.findAllExpect(List.of(id), PAGE_NUM, PAGE_SIZE));
|
||||
model.addAttribute("photo", photo);
|
||||
|
||||
return "photo";
|
||||
}
|
||||
|
||||
@PostMapping("/photo/{id}")
|
||||
private String updatePhotoDetails(@PathVariable("id") Long id,
|
||||
@ModelAttribute Photo photo) {
|
||||
|
||||
photoService.updatePhotoDetails(id, photo, tags);
|
||||
|
||||
System.out.println(photo);
|
||||
|
||||
return "redirect:/photo/" + id;
|
||||
}
|
||||
|
||||
@GetMapping("/loadMoreThumbnails")
|
||||
private String loadMoreThumbnails(Model model,
|
||||
@RequestParam("id") Long id,
|
||||
@RequestParam("pageNum") int page) {
|
||||
|
||||
model.addAttribute("thumbnails", photoService.findAllExpect(List.of(id), page + 1, 30));
|
||||
|
||||
Photo photo = photoService.findById(id);
|
||||
model.addAttribute("photo", photo);
|
||||
|
||||
return "photo :: thumbnail-strip";
|
||||
}
|
||||
|
||||
@PostMapping("/tags/add")
|
||||
public String addTag(@RequestParam String tag,
|
||||
Model model) {
|
||||
String normalized = tag.trim().toUpperCase();
|
||||
if (normalized.isEmpty() || tags.contains(normalized)) {
|
||||
return "photo :: empty";
|
||||
}
|
||||
|
||||
tags.add(normalized);
|
||||
model.addAttribute("tag", normalized);
|
||||
|
||||
return "photo:: badge";
|
||||
}
|
||||
|
||||
@PostMapping("/tags/remove")
|
||||
private String removeTag() {
|
||||
|
||||
return "photo :: empty";
|
||||
|
||||
}
|
||||
|
||||
@PostMapping("/photo/favorite/{id}")
|
||||
private String setFavourite(@PathVariable("id") Long id){
|
||||
|
||||
photoService.setFavourite(id);
|
||||
|
||||
return "photo::empty";
|
||||
|
||||
}
|
||||
|
||||
@GetMapping("/changePhoto/{id}")
|
||||
public String changePhoto(Model model, @PathVariable Long id) {
|
||||
|
||||
Photo photo = photoService.findById(id);
|
||||
model.addAttribute("photo", photo);
|
||||
|
||||
return "photo :: photoStage";
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -23,8 +23,11 @@ public class Photo {
|
|||
@Column(nullable = false)
|
||||
private String title;
|
||||
|
||||
@Column(length = 255)
|
||||
private String description;
|
||||
|
||||
@Column
|
||||
private List<Integer> tags;
|
||||
private List<Long> tags;
|
||||
|
||||
@Column
|
||||
private Integer width;
|
||||
|
|
@ -44,6 +47,12 @@ public class Photo {
|
|||
@Column
|
||||
private String mimeType;
|
||||
|
||||
@Column
|
||||
private String colourSpace;
|
||||
|
||||
@Column
|
||||
private String copyRight;
|
||||
|
||||
@Column
|
||||
private Double file_size;
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import lombok.Data;
|
|||
@Entity
|
||||
@Table(name = "tags")
|
||||
@Data
|
||||
public class Tags {
|
||||
public class Tag {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
|
|
@ -14,17 +14,35 @@ import com.example.PhotoGallery.Models.Photo;
|
|||
@Repository
|
||||
public interface PhotoRepo extends JpaRepository<Photo, Long> {
|
||||
|
||||
boolean existsByFileName(String filName);
|
||||
boolean existsByFileName(String fileName);
|
||||
|
||||
|
||||
@Query(value = "SELECT file_name FROM photos ", nativeQuery = true)
|
||||
@Query(value = "SELECT file_name FROM photos", nativeQuery = true)
|
||||
List<String> getAllFileNames();
|
||||
|
||||
|
||||
@Query(value = "SELECT * FROM photos", nativeQuery = true)
|
||||
Page<Photo> getPagedPhotos(Pageable pageable);
|
||||
|
||||
@Query(value = "SELECT * FROM photos ORDER BY RAND() LIMIT :numRecords", nativeQuery = true)
|
||||
List<Photo> getRandomPhotos(@Param("numRecords") int numRandRecords);
|
||||
|
||||
@Query(value = """
|
||||
SELECT *
|
||||
FROM photos
|
||||
WHERE
|
||||
CONCAT_WS(' ',
|
||||
colour_space,
|
||||
copy_right,
|
||||
file_name,
|
||||
file_size,
|
||||
height,
|
||||
mime_type,
|
||||
title,
|
||||
width) LIKE CONCAT('%', :search, '%')
|
||||
""", nativeQuery = true)
|
||||
List<Photo> findAllBySearch(@Param("search") String search);
|
||||
|
||||
// Ensure :ids is never null when calling this, or the query needs "OR :ids IS NULL"
|
||||
@Query(value = "SELECT * FROM photos WHERE id NOT IN (:ids)", nativeQuery = true)
|
||||
Page<Photo> findAllExpect(@Param("ids") List<Long> ids, Pageable pageable);
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.example.PhotoGallery.Repos;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import com.example.PhotoGallery.Models.Tag;
|
||||
|
||||
@Repository
|
||||
public interface TagRepo extends JpaRepository<Tag, Long> {
|
||||
Boolean existsByName(String name);
|
||||
|
||||
Tag findByName(String name);
|
||||
|
||||
|
||||
@Query(value= "SELECT name FROM tags where id in (:tagIds)", nativeQuery= true)
|
||||
List<String> getAllTagNamesIn(@Param("tagIds") List<Long> tagIds);
|
||||
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ import org.springframework.stereotype.Service;
|
|||
@Service
|
||||
public class ExtractImageMetadata {
|
||||
|
||||
public String getImageMetaData(String path) {
|
||||
public JSONObject getImageMetaData(String path) {
|
||||
File imageFile = new File(path);
|
||||
try {
|
||||
|
||||
|
|
@ -36,14 +36,14 @@ public class ExtractImageMetadata {
|
|||
|
||||
}
|
||||
|
||||
return metaData.toString();
|
||||
return metaData;
|
||||
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
} catch (com.drew.imaging.ImageProcessingException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return "";
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ import java.io.IOException;
|
|||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.json.simple.JSONObject;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.Page;
|
||||
|
|
@ -15,7 +17,9 @@ import org.springframework.data.domain.Pageable;
|
|||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.example.PhotoGallery.Models.Photo;
|
||||
import com.example.PhotoGallery.Models.Tag;
|
||||
import com.example.PhotoGallery.Repos.PhotoRepo;
|
||||
import com.example.PhotoGallery.Repos.TagRepo;
|
||||
|
||||
@Service
|
||||
public class PhotoService {
|
||||
|
|
@ -26,6 +30,8 @@ public class PhotoService {
|
|||
private ImageCompression imageCompression;
|
||||
@Autowired
|
||||
private PhotoRepo photoRepo;
|
||||
@Autowired
|
||||
private TagRepo tagRepo;
|
||||
|
||||
private Path originalFilesPath;
|
||||
private Path thumbnailsPath;
|
||||
|
|
@ -53,11 +59,11 @@ public class PhotoService {
|
|||
try (Stream<Path> imagePaths = Files.list(originalFilesPath)) {
|
||||
imagePaths.forEach((imagePath) -> {
|
||||
|
||||
|
||||
//only create the thumbnail and write to the DB if the photo doesnt exist in the DB
|
||||
// only create the thumbnail and write to the DB if the photo doesnt exist in
|
||||
// the DB
|
||||
if (!allFileName.contains(imagePath.getFileName().toString())) {
|
||||
|
||||
//compress the image and write the new image to the thumbnail folder
|
||||
// compress the image and write the new image to the thumbnail folder
|
||||
imageCompression.compressImage(imagePath.toString(), thumbnailsPath.toString());
|
||||
writePhotoDataToDB(imagePath);
|
||||
|
||||
|
|
@ -69,6 +75,81 @@ public class PhotoService {
|
|||
}
|
||||
}
|
||||
|
||||
public List<String> getAllFileNames() {
|
||||
|
||||
return photoRepo.getAllFileNames();
|
||||
|
||||
}
|
||||
|
||||
public List<Photo> getAllPhotots() {
|
||||
return photoRepo.findAll();
|
||||
}
|
||||
|
||||
public Page<Photo> getPagedPhotos(int pageNo, int pageSize) {
|
||||
Pageable pageable = PageRequest.of(pageNo, pageSize);
|
||||
return photoRepo.getPagedPhotos(pageable);
|
||||
}
|
||||
|
||||
public List<Photo> getRandomPhotos(int numRandRecords) {
|
||||
return photoRepo.getRandomPhotos(numRandRecords);
|
||||
}
|
||||
|
||||
public List<Photo> findAllBySearch(String search) {
|
||||
|
||||
return photoRepo.findAllBySearch(search);
|
||||
}
|
||||
|
||||
public Photo findById(Long id) {
|
||||
return photoRepo.findById(id).get();
|
||||
}
|
||||
|
||||
public Page<Photo> findAllExpect(List<Long> ids, int pageNo, int pageSize) {
|
||||
Pageable pageable = PageRequest.of(pageNo, pageSize);
|
||||
return photoRepo.findAllExpect(ids, pageable);
|
||||
}
|
||||
|
||||
public void updatePhotoDetails(Long id, Photo newPhoto, List<String> tagNames) {
|
||||
|
||||
Photo photo = photoRepo.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("No Photo with Id" + id));
|
||||
|
||||
photo.setTitle(newPhoto.getTitle());
|
||||
photo.setDescription(newPhoto.getDescription());
|
||||
|
||||
List<Long> tagIds = new ArrayList<>();
|
||||
|
||||
for (String tagName : tagNames){
|
||||
// if the tagName is not in the Tags table
|
||||
// we define a new Tag and insert it into the Tags table
|
||||
if (!tagRepo.existsByName(tagName)) {
|
||||
Tag tag = new Tag();
|
||||
tag.setName(tagName);
|
||||
Tag newlyCreatedTag = tagRepo.save(tag);
|
||||
tagIds.add(newlyCreatedTag.getId());
|
||||
} else {
|
||||
Tag foundTag = tagRepo.findByName(tagName);
|
||||
tagIds.add(foundTag.getId());
|
||||
}
|
||||
}
|
||||
|
||||
photo.setTags(tagIds);
|
||||
photo.setCopyRight(newPhoto.getCopyRight());
|
||||
|
||||
photoRepo.save(photo);
|
||||
|
||||
}
|
||||
|
||||
public void setFavourite(Long id) {
|
||||
|
||||
Photo photo = photoRepo.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("No Photo with Id" + id));
|
||||
|
||||
photo.setFavourite(!photo.isFavourite());
|
||||
|
||||
photoRepo.save(photo);
|
||||
|
||||
}
|
||||
|
||||
private void writePhotoDataToDB(Path imagePath) {
|
||||
|
||||
Photo photo = new Photo();
|
||||
|
|
@ -78,12 +159,8 @@ public class PhotoService {
|
|||
photo.setTitle("");
|
||||
photo.setFavourite(false);
|
||||
|
||||
// get info from metadata TODO: extract details from metadata string
|
||||
photo.setFile_size(0.0);
|
||||
photo.setHeight(0);
|
||||
photo.setWidth(0);
|
||||
photo.setMimeType("");
|
||||
photo.setTags(null);
|
||||
JSONObject extractedData = extractMetaData.getImageMetaData(imagePath.toString());
|
||||
populatePhotoData(photo, extractedData);
|
||||
|
||||
photo.setThumbnailPath("thumbnails" + "/" + imagePath.getFileName().toString());
|
||||
|
||||
|
|
@ -91,23 +168,67 @@ public class PhotoService {
|
|||
|
||||
}
|
||||
|
||||
public List<String> getAllFileNames() {
|
||||
/**
|
||||
*
|
||||
* Populate the passed in photo object with the extracted data
|
||||
*
|
||||
*
|
||||
* @param photo
|
||||
* @param extractedData
|
||||
* @return
|
||||
*/
|
||||
private Photo populatePhotoData(Photo photo, JSONObject extractedData) {
|
||||
// File Size
|
||||
JSONObject fileObj = (JSONObject) extractedData.get("File");
|
||||
String fileSize = (fileObj != null && fileObj.get("File Size") != null)
|
||||
? fileObj.get("File Size").toString().replace(" bytes", "")
|
||||
: null;
|
||||
|
||||
return photoRepo.getAllFileNames();
|
||||
// MIME Type
|
||||
JSONObject fileTypeObj = (JSONObject) extractedData.get("File Type");
|
||||
String mimeType = (fileTypeObj != null && fileTypeObj.get("Detected File Type Name") != null)
|
||||
? fileTypeObj.get("Detected File Type Name").toString()
|
||||
: null;
|
||||
|
||||
}
|
||||
// JPEG dimensions
|
||||
JSONObject jpegObj = (JSONObject) extractedData.get("JPEG");
|
||||
int height = 0;
|
||||
int width = 0;
|
||||
if (jpegObj != null) {
|
||||
if (jpegObj.get("Image Height") != null) {
|
||||
try {
|
||||
height = Integer.parseInt(jpegObj.get("Image Height").toString().replace(" pixels", ""));
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
if (jpegObj.get("Image Width") != null) {
|
||||
try {
|
||||
width = Integer.parseInt(jpegObj.get("Image Width").toString().replace(" pixels", ""));
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public List<Photo> getAllPhotots(){
|
||||
return photoRepo.findAll();
|
||||
}
|
||||
// Exif data
|
||||
JSONObject exifSubIFD = (JSONObject) extractedData.get("Exif SubIFD");
|
||||
String colourSpace = (exifSubIFD != null && exifSubIFD.get("Color Space") != null)
|
||||
? exifSubIFD.get("Color Space").toString()
|
||||
: null;
|
||||
|
||||
public Page<Photo> getPagedPhotos(int pageNo, int pageSize){
|
||||
Pageable pageable = PageRequest.of(pageNo, pageSize);
|
||||
return photoRepo.getPagedPhotos(pageable);
|
||||
}
|
||||
JSONObject exifIFD0 = (JSONObject) extractedData.get("Exif IFD0");
|
||||
String copyright = (exifIFD0 != null && exifIFD0.get("Copyright") != null)
|
||||
? exifIFD0.get("Copyright").toString()
|
||||
: null;
|
||||
|
||||
public List<Photo> getRandomPhotos(int numRandRecords){
|
||||
return photoRepo.getRandomPhotos(numRandRecords);
|
||||
photo.setFile_size(Math.ceil(Double.parseDouble(fileSize) / 1048576));
|
||||
photo.setHeight(height);
|
||||
photo.setWidth(width);
|
||||
photo.setMimeType(mimeType);
|
||||
photo.setColourSpace(colourSpace);
|
||||
photo.setCopyRight(copyright);
|
||||
photo.setTags(null);
|
||||
|
||||
return photo;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,14 @@ public class StaticResourceConfig implements WebMvcConfigurer {
|
|||
registry.addResourceHandler("/thumbnails/**")
|
||||
.addResourceLocations("file:/srv/nas/PhotoGalleryImages/thumbnails/")
|
||||
.setCacheControl(CacheControl.maxAge(30, TimeUnit.DAYS));
|
||||
|
||||
registry.addResourceHandler("/photo/images/**")
|
||||
.addResourceLocations("file:/srv/nas/PhotoGalleryImages/images/")
|
||||
.setCacheControl(CacheControl.maxAge(30, TimeUnit.DAYS));
|
||||
|
||||
registry.addResourceHandler("/photo/thumbnails/**")
|
||||
.addResourceLocations("file:/srv/nas/PhotoGalleryImages/thumbnails/")
|
||||
.setCacheControl(CacheControl.maxAge(30, TimeUnit.DAYS));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
|
||||
<title>Untitled</title>
|
||||
<title>Photo Gallery</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css">
|
||||
|
||||
|
|
@ -13,9 +13,6 @@
|
|||
background-color: #222831;
|
||||
}
|
||||
|
||||
#search:active{
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
|
|
@ -25,21 +22,25 @@
|
|||
<div class="container">
|
||||
<a class="navbar-brand fw-bold" href="#">Photo Gallery</a>
|
||||
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain">
|
||||
<button id="filterDropdown" class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#filterDropdown">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="navMain">
|
||||
<ul class="navbar-nav ms-auto align-items-md-center gap-md-2">
|
||||
<li class="nav-item">
|
||||
<div>
|
||||
<i class="bi bi-search"></i>
|
||||
<input style="border: none;" type="search" name="search" id="search" placeholder="Search Photos...">
|
||||
<div class="d-inline-flex align-items-center">
|
||||
<i class="bi bi-search me-2"></i>
|
||||
<input class="form-control" type="search" name="search" id="search" placeholder="Search Photos..."
|
||||
hx-post="/search"
|
||||
hx-trigger="input changed delay:500ms, keyup[key=='Enter'] = 'enter'"
|
||||
hx-target="#image-container"
|
||||
hx-swap="outerHTML">
|
||||
</div>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="btn btn-outline-dark dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Dropdown
|
||||
Filter
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-dark">
|
||||
<li><a class="dropdown-item" href="#">Action</a></li>
|
||||
|
|
@ -80,13 +81,12 @@
|
|||
<div>
|
||||
<section class="pt-5 pb-0 my-0 py-xl-5" style="padding: 0;">
|
||||
|
||||
<div class="container">
|
||||
<div id="image-container" th:fragment="image-container" class="container">
|
||||
<div id="image-row" class="row gx-1 gy-1 row-cols-1 row-cols-md-2 row-cols-xl-5 mt-0"
|
||||
data-bss-baguettebox="">
|
||||
|
||||
<div th:fragment="images" class="col" th:each="image : ${images}">
|
||||
<span th:text="${images.number}"></span>
|
||||
<a th:href="@{${image.filePath}}">
|
||||
<a th:href="@{/photo/{id}(id=${image.id})}">
|
||||
<img class="bg-black img-fluid aspect-ratio-4x3 object-fit-cover w-100 h-100"
|
||||
style="padding: 1px;"
|
||||
alt="Replace with image description" th:src="@{${image.thumbnailPath}}"
|
||||
|
|
@ -100,7 +100,8 @@
|
|||
hx-get="/loadMoreImages"
|
||||
hx-trigger="intersect"
|
||||
hx-target="#image-row"
|
||||
hx-swap="beforeend">
|
||||
hx-swap="beforeend"
|
||||
hx-indicator=".htmx-indicator">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,270 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="light">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Photo Viewer</title>
|
||||
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
body { background-color: var(--bs-body-bg); }
|
||||
|
||||
.photo-stage {
|
||||
background: #f8f9fa;
|
||||
border-radius: .5rem;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.photo-stage img {
|
||||
max-height: 70vh;
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Horizontal Scroll Strip */
|
||||
.thumbnail-strip {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
scroll-snap-type: x mandatory;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.thumbnail-item {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: 8px;
|
||||
scroll-snap-align: start;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.thumbnail-item img {
|
||||
width: 110px;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
border-radius: .25rem;
|
||||
cursor: pointer;
|
||||
opacity: .6;
|
||||
transition: opacity .2s, transform .2s;
|
||||
}
|
||||
|
||||
/* Active State Styles */
|
||||
.thumbnail-item img.active {
|
||||
opacity: 1;
|
||||
border: 3px solid #0d6efd; /* Bootstrap Primary Color */
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.thumbnail-item img:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: .9rem;
|
||||
padding: .25rem 0;
|
||||
border-bottom: 1px dashed #dee2e6;
|
||||
}
|
||||
|
||||
.thumbnail-strip::-webkit-scrollbar { height: 6px; }
|
||||
.thumbnail-strip::-webkit-scrollbar-thumb { background: #dee2e6; border-radius: 3px; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.photo-stage img { max-height: 50vh; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body hx-boost="true">
|
||||
|
||||
<nav class="navbar navbar-expand-md sticky-top bg-body border-bottom">
|
||||
<div class="container">
|
||||
<a class="navbar-brand fw-bold" href="/home">Photo Gallery</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navMain">
|
||||
<ul class="navbar-nav ms-auto gap-2">
|
||||
<li class="nav-item"><a class="nav-link" href="/home">Home</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="row g-4">
|
||||
|
||||
<div class="col-lg-8">
|
||||
|
||||
<div class="photo-stage mb-3">
|
||||
<img th:src="@{${photo.filePath}}" alt="Main photo" class="img-fluid shadow-sm">
|
||||
</div>
|
||||
|
||||
<div id="thumbnail-strip" class="thumbnail-strip mb-3">
|
||||
|
||||
<div class="thumbnail-item">
|
||||
<img th:src="@{${photo.filePath}}" class="active" alt="Current">
|
||||
</div>
|
||||
|
||||
<th:block th:fragment="thumbnail-batch">
|
||||
<div class="thumbnail-item" th:each="thumbnail : ${thumbnails}">
|
||||
<a th:href="@{'/photo/' + ${thumbnail.id}}">
|
||||
<img th:src="@{${thumbnail.thumbnailPath}}" alt="Thumbnail">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div th:if="${thumbnails.hasNext()}"
|
||||
class="thumbnail-item d-inline-flex align-items-center justify-content-center bg-light text-muted"
|
||||
style="width: 110px; height: 80px; border-radius: .25rem;"
|
||||
th:attr="hx-get=@{/loadMoreThumbnails},
|
||||
hx-vals='{"id": ' + ${photo.id} + ', "pageNum": ' + ${thumbnails.number} + '}'"
|
||||
hx-trigger="intersect"
|
||||
hx-swap="outerHTML"
|
||||
hx-boost="false"> <span class="spinner-border spinner-border-sm" role="status"></span>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div class="btn-group" role="group">
|
||||
|
||||
<a th:href="@{${photo.filePath}}" class="btn btn-sm btn-outline-dark" download title="Download">
|
||||
<i class="bi bi-download"></i>
|
||||
</a>
|
||||
|
||||
<a th:href="@{${photo.filePath}}" class="btn btn-sm btn-outline-dark" target="_blank" title="Open original">
|
||||
<i class="bi bi-box-arrow-up-right"></i>
|
||||
</a>
|
||||
|
||||
<button class="btn btn-sm btn-outline-dark" th:fragment="favButton"
|
||||
th:attr="hx-post=@{/photo/favorite/{id}(id=${photo.id})}"
|
||||
hx-swap="outerHTML"
|
||||
hx-boost="false"
|
||||
title="Toggle favorite">
|
||||
<i th:if="${!photo.favourite}" class="bi bi-star"></i>
|
||||
<i th:if="${photo.favourite}" class="bi bi-star-fill text-warning"></i>
|
||||
</button>
|
||||
|
||||
<button class="btn btn-sm btn-outline-dark" title="Fullscreen"
|
||||
onclick="document.querySelector('.photo-stage img').requestFullscreen()">
|
||||
<i class="bi bi-arrows-fullscreen"></i>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<form th:object="${photo}" method="post" th:action="@{/photo/{id}(id=${photo.id})}" class="card mb-4" hx-boost="false">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title mb-3">Details</h6>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Title</label>
|
||||
<input th:field="*{title}" class="form-control" type="text">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea th:field="*{description}" class="form-control" rows="4"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Tags</label>
|
||||
|
||||
<input name="newTag" class="form-control" maxlength="20"
|
||||
placeholder="Enter a tag and press Enter"
|
||||
hx-post="/tags/add"
|
||||
hx-trigger="keyup[key=='Enter']"
|
||||
hx-target="#tagContainer"
|
||||
hx-swap="beforeend"
|
||||
hx-on::after-request="this.value=''"
|
||||
hx-boost="false" />
|
||||
|
||||
<div id="tagContainer" class="mt-2 d-flex flex-wrap gap-2">
|
||||
<th:block th:each="tag : *{tags}">
|
||||
<div th:replace="~{:: tagBadge(tag=${tag})}"></div>
|
||||
</th:block>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Copyright</label>
|
||||
<input th:field="*{copyRight}" class="form-control" type="text">
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<button class="btn btn-outline-dark" type="reset">Reset</button>
|
||||
<button class="btn btn-primary" type="submit">Update Details</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<span th:fragment="tagBadge(tag)" class="badge bg-dark d-flex align-items-center">
|
||||
<span th:text="${tag}"></span>
|
||||
<input type="hidden" name="tags" th:value="${tag}">
|
||||
<button type="button" class="btn-close btn-close-white ms-2" style="font-size: 0.5rem;"
|
||||
hx-post="/tags/remove"
|
||||
hx-target="closest .badge"
|
||||
hx-swap="outerHTML"
|
||||
hx-boost="false">
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title mb-3">Metadata</h6>
|
||||
<div class="meta-row">
|
||||
<span>Type</span><span th:text="${photo.mimeType}"></span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span>Resolution</span><span th:text="${photo.width} + ' x ' + ${photo.height}"></span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span>File Size</span><span th:text="${photo.file_size} + ' MB'"></span>
|
||||
</div>
|
||||
<div class="meta-row border-0">
|
||||
<span>Color Space</span><span th:text="${photo.colourSpace}"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<iframe src="https://cdn.bootstrapstudio.io/placeholders/map.html" width="100%" height="300" style="border:0" loading="lazy"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// Prevent Form Submission when hitting Enter on the Tag Input
|
||||
document.body.addEventListener("keydown", function(e) {
|
||||
if (e.target.name === "newTag" && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue