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:
Kiyan 2025-12-18 16:29:45 +02:00
parent b587244dae
commit 527f770a18
12 changed files with 625 additions and 52 deletions

View File

@ -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;
@ -23,7 +29,8 @@ public class PhotosController {
// 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
// the directory
photoService.WriteImagesFromDirToDB(); // TODO: run this method asynchronously 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));
@ -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";
}
} }

View File

@ -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";
}
}

View File

@ -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;

View File

@ -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)

View File

@ -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);
} }

View File

@ -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);
}

View File

@ -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;
} }
} }

View File

@ -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,8 +59,8 @@ 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
@ -69,28 +75,6 @@ public class PhotoService {
} }
} }
private void writePhotoDataToDB(Path imagePath) {
Photo photo = new Photo();
photo.setFilePath("images" + "/" + imagePath.getFileName().toString());
photo.setFileName(imagePath.getFileName().toString());
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);
photo.setThumbnailPath("thumbnails" + "/" + imagePath.getFileName().toString());
photoRepo.save(photo);
}
public List<String> getAllFileNames() { public List<String> getAllFileNames() {
return photoRepo.getAllFileNames(); return photoRepo.getAllFileNames();
@ -110,4 +94,141 @@ public class PhotoService {
return photoRepo.getRandomPhotos(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();
photo.setFilePath("images" + "/" + imagePath.getFileName().toString());
photo.setFileName(imagePath.getFileName().toString());
photo.setTitle("");
photo.setFavourite(false);
JSONObject extractedData = extractMetaData.getImageMetaData(imagePath.toString());
populatePhotoData(photo, extractedData);
photo.setThumbnailPath("thumbnails" + "/" + imagePath.getFileName().toString());
photoRepo.save(photo);
}
/**
*
* 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;
// 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) {
}
}
}
// Exif data
JSONObject exifSubIFD = (JSONObject) extractedData.get("Exif SubIFD");
String colourSpace = (exifSubIFD != null && exifSubIFD.get("Color Space") != null)
? exifSubIFD.get("Color Space").toString()
: null;
JSONObject exifIFD0 = (JSONObject) extractedData.get("Exif IFD0");
String copyright = (exifIFD0 != null && exifIFD0.get("Copyright") != null)
? exifIFD0.get("Copyright").toString()
: null;
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;
}
} }

View File

@ -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));
} }
} }

View File

@ -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>

View File

@ -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='{&quot;id&quot;: ' + ${photo.id} + ', &quot;pageNum&quot;: ' + ${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>