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;
|
package com.example.PhotoGallery.Controller;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
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;
|
import com.example.PhotoGallery.Services.PhotoService;
|
||||||
|
|
||||||
@Controller
|
@Controller
|
||||||
public class PhotosController {
|
public class PhotoHomeController {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
PhotoService photoService;
|
PhotoService photoService;
|
||||||
|
|
@ -20,11 +26,12 @@ public class PhotosController {
|
||||||
@GetMapping("/home")
|
@GetMapping("/home")
|
||||||
private String home(Model model) {
|
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;
|
pageCounter = 0;
|
||||||
|
|
||||||
//upon a page refresh we will scan the db and add any new images that are in the directory
|
// upon a page refresh we will scan the db and add any new images that are in
|
||||||
photoService.WriteImagesFromDirToDB(); //TODO: run this method asynchronously
|
// the directory
|
||||||
|
photoService.WriteImagesFromDirToDB(); // TODO: run this method asynchronously
|
||||||
|
|
||||||
model.addAttribute("images", photoService.getPagedPhotos(FIRST_PAGE, PAGE_SIZE));
|
model.addAttribute("images", photoService.getPagedPhotos(FIRST_PAGE, PAGE_SIZE));
|
||||||
model.addAttribute("carouselImages", photoService.getRandomPhotos(RANDOM_NUMBER_IMAGES));
|
model.addAttribute("carouselImages", photoService.getRandomPhotos(RANDOM_NUMBER_IMAGES));
|
||||||
|
|
@ -37,8 +44,8 @@ public class PhotosController {
|
||||||
public String loadMoreImages(Model model) {
|
public String loadMoreImages(Model model) {
|
||||||
int totalPages = photoService.getPagedPhotos(FIRST_PAGE, PAGE_SIZE).getTotalPages();
|
int totalPages = photoService.getPagedPhotos(FIRST_PAGE, PAGE_SIZE).getTotalPages();
|
||||||
|
|
||||||
//dont load any more images if we have reached the last images
|
// dont load any more images if we have reached the last images
|
||||||
if (pageCounter == totalPages){
|
if (pageCounter == totalPages) {
|
||||||
return "home :: images";
|
return "home :: images";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,4 +54,15 @@ public class PhotosController {
|
||||||
return "home :: images";
|
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)
|
@Column(nullable = false)
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
|
@Column(length = 255)
|
||||||
|
private String description;
|
||||||
|
|
||||||
@Column
|
@Column
|
||||||
private List<Integer> tags;
|
private List<Long> tags;
|
||||||
|
|
||||||
@Column
|
@Column
|
||||||
private Integer width;
|
private Integer width;
|
||||||
|
|
@ -44,6 +47,12 @@ public class Photo {
|
||||||
@Column
|
@Column
|
||||||
private String mimeType;
|
private String mimeType;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
private String colourSpace;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
private String copyRight;
|
||||||
|
|
||||||
@Column
|
@Column
|
||||||
private Double file_size;
|
private Double file_size;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import lombok.Data;
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "tags")
|
@Table(name = "tags")
|
||||||
@Data
|
@Data
|
||||||
public class Tags {
|
public class Tag {
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
|
@ -14,17 +14,35 @@ import com.example.PhotoGallery.Models.Photo;
|
||||||
@Repository
|
@Repository
|
||||||
public interface PhotoRepo extends JpaRepository<Photo, Long> {
|
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();
|
List<String> getAllFileNames();
|
||||||
|
|
||||||
|
|
||||||
@Query(value = "SELECT * FROM photos", nativeQuery = true)
|
@Query(value = "SELECT * FROM photos", nativeQuery = true)
|
||||||
Page<Photo> getPagedPhotos(Pageable pageable);
|
Page<Photo> getPagedPhotos(Pageable pageable);
|
||||||
|
|
||||||
@Query(value = "SELECT * FROM photos ORDER BY RAND() LIMIT :numRecords", nativeQuery = true)
|
@Query(value = "SELECT * FROM photos ORDER BY RAND() LIMIT :numRecords", nativeQuery = true)
|
||||||
List<Photo> getRandomPhotos(@Param("numRecords") int numRandRecords);
|
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
|
@Service
|
||||||
public class ExtractImageMetadata {
|
public class ExtractImageMetadata {
|
||||||
|
|
||||||
public String getImageMetaData(String path) {
|
public JSONObject getImageMetaData(String path) {
|
||||||
File imageFile = new File(path);
|
File imageFile = new File(path);
|
||||||
try {
|
try {
|
||||||
|
|
||||||
|
|
@ -36,14 +36,14 @@ public class ExtractImageMetadata {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return metaData.toString();
|
return metaData;
|
||||||
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
} catch (com.drew.imaging.ImageProcessingException e) {
|
} catch (com.drew.imaging.ImageProcessingException e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
}
|
}
|
||||||
return "";
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@ import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import org.json.simple.JSONObject;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
|
|
@ -15,7 +17,9 @@ import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import com.example.PhotoGallery.Models.Photo;
|
import com.example.PhotoGallery.Models.Photo;
|
||||||
|
import com.example.PhotoGallery.Models.Tag;
|
||||||
import com.example.PhotoGallery.Repos.PhotoRepo;
|
import com.example.PhotoGallery.Repos.PhotoRepo;
|
||||||
|
import com.example.PhotoGallery.Repos.TagRepo;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class PhotoService {
|
public class PhotoService {
|
||||||
|
|
@ -26,6 +30,8 @@ public class PhotoService {
|
||||||
private ImageCompression imageCompression;
|
private ImageCompression imageCompression;
|
||||||
@Autowired
|
@Autowired
|
||||||
private PhotoRepo photoRepo;
|
private PhotoRepo photoRepo;
|
||||||
|
@Autowired
|
||||||
|
private TagRepo tagRepo;
|
||||||
|
|
||||||
private Path originalFilesPath;
|
private Path originalFilesPath;
|
||||||
private Path thumbnailsPath;
|
private Path thumbnailsPath;
|
||||||
|
|
@ -53,11 +59,11 @@ public class PhotoService {
|
||||||
try (Stream<Path> imagePaths = Files.list(originalFilesPath)) {
|
try (Stream<Path> imagePaths = Files.list(originalFilesPath)) {
|
||||||
imagePaths.forEach((imagePath) -> {
|
imagePaths.forEach((imagePath) -> {
|
||||||
|
|
||||||
|
// only create the thumbnail and write to the DB if the photo doesnt exist in
|
||||||
//only create the thumbnail and write to the DB if the photo doesnt exist in the DB
|
// the DB
|
||||||
if (!allFileName.contains(imagePath.getFileName().toString())) {
|
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());
|
imageCompression.compressImage(imagePath.toString(), thumbnailsPath.toString());
|
||||||
writePhotoDataToDB(imagePath);
|
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) {
|
private void writePhotoDataToDB(Path imagePath) {
|
||||||
|
|
||||||
Photo photo = new Photo();
|
Photo photo = new Photo();
|
||||||
|
|
@ -78,12 +159,8 @@ public class PhotoService {
|
||||||
photo.setTitle("");
|
photo.setTitle("");
|
||||||
photo.setFavourite(false);
|
photo.setFavourite(false);
|
||||||
|
|
||||||
// get info from metadata TODO: extract details from metadata string
|
JSONObject extractedData = extractMetaData.getImageMetaData(imagePath.toString());
|
||||||
photo.setFile_size(0.0);
|
populatePhotoData(photo, extractedData);
|
||||||
photo.setHeight(0);
|
|
||||||
photo.setWidth(0);
|
|
||||||
photo.setMimeType("");
|
|
||||||
photo.setTags(null);
|
|
||||||
|
|
||||||
photo.setThumbnailPath("thumbnails" + "/" + imagePath.getFileName().toString());
|
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(){
|
// Exif data
|
||||||
return photoRepo.findAll();
|
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){
|
JSONObject exifIFD0 = (JSONObject) extractedData.get("Exif IFD0");
|
||||||
Pageable pageable = PageRequest.of(pageNo, pageSize);
|
String copyright = (exifIFD0 != null && exifIFD0.get("Copyright") != null)
|
||||||
return photoRepo.getPagedPhotos(pageable);
|
? exifIFD0.get("Copyright").toString()
|
||||||
}
|
: null;
|
||||||
|
|
||||||
public List<Photo> getRandomPhotos(int numRandRecords){
|
photo.setFile_size(Math.ceil(Double.parseDouble(fileSize) / 1048576));
|
||||||
return photoRepo.getRandomPhotos(numRandRecords);
|
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/**")
|
registry.addResourceHandler("/thumbnails/**")
|
||||||
.addResourceLocations("file:/srv/nas/PhotoGalleryImages/thumbnails/")
|
.addResourceLocations("file:/srv/nas/PhotoGalleryImages/thumbnails/")
|
||||||
.setCacheControl(CacheControl.maxAge(30, TimeUnit.DAYS));
|
.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>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
|
<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@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">
|
<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;
|
background-color: #222831;
|
||||||
}
|
}
|
||||||
|
|
||||||
#search:active{
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -25,21 +22,25 @@
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a class="navbar-brand fw-bold" href="#">Photo Gallery</a>
|
<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>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="collapse navbar-collapse" id="navMain">
|
<div class="collapse navbar-collapse" id="navMain">
|
||||||
<ul class="navbar-nav ms-auto align-items-md-center gap-md-2">
|
<ul class="navbar-nav ms-auto align-items-md-center gap-md-2">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<div>
|
<div class="d-inline-flex align-items-center">
|
||||||
<i class="bi bi-search"></i>
|
<i class="bi bi-search me-2"></i>
|
||||||
<input style="border: none;" type="search" name="search" id="search" placeholder="Search Photos...">
|
<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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="btn btn-outline-dark dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
<button class="btn btn-outline-dark dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
Dropdown
|
Filter
|
||||||
</button>
|
</button>
|
||||||
<ul class="dropdown-menu dropdown-menu-dark">
|
<ul class="dropdown-menu dropdown-menu-dark">
|
||||||
<li><a class="dropdown-item" href="#">Action</a></li>
|
<li><a class="dropdown-item" href="#">Action</a></li>
|
||||||
|
|
@ -80,13 +81,12 @@
|
||||||
<div>
|
<div>
|
||||||
<section class="pt-5 pb-0 my-0 py-xl-5" style="padding: 0;">
|
<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"
|
<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="">
|
data-bss-baguettebox="">
|
||||||
|
|
||||||
<div th:fragment="images" class="col" th:each="image : ${images}">
|
<div th:fragment="images" class="col" th:each="image : ${images}">
|
||||||
<span th:text="${images.number}"></span>
|
<a th:href="@{/photo/{id}(id=${image.id})}">
|
||||||
<a th:href="@{${image.filePath}}">
|
|
||||||
<img class="bg-black img-fluid aspect-ratio-4x3 object-fit-cover w-100 h-100"
|
<img class="bg-black img-fluid aspect-ratio-4x3 object-fit-cover w-100 h-100"
|
||||||
style="padding: 1px;"
|
style="padding: 1px;"
|
||||||
alt="Replace with image description" th:src="@{${image.thumbnailPath}}"
|
alt="Replace with image description" th:src="@{${image.thumbnailPath}}"
|
||||||
|
|
@ -100,7 +100,8 @@
|
||||||
hx-get="/loadMoreImages"
|
hx-get="/loadMoreImages"
|
||||||
hx-trigger="intersect"
|
hx-trigger="intersect"
|
||||||
hx-target="#image-row"
|
hx-target="#image-row"
|
||||||
hx-swap="beforeend">
|
hx-swap="beforeend"
|
||||||
|
hx-indicator=".htmx-indicator">
|
||||||
</div>
|
</div>
|
||||||
</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