- Functioning photo update

- Functioning photo search
This commit is contained in:
Kiyan 2025-12-19 18:53:58 +02:00
parent 527f770a18
commit e1882e5a7f
9 changed files with 356 additions and 321 deletions

View File

@ -50,14 +50,14 @@ public class PhotoHomeController {
}
pageCounter++;
model.addAttribute("images", photoService.getPagedPhotos(pageCounter, 10));
model.addAttribute("images", photoService.getPagedPhotos(pageCounter, 30));
return "home :: images";
}
@PostMapping("/search")
public String search(Model model, @RequestParam("search") String search) {
List<Photo> searchedPhotos = photoService.findAllBySearch(search);
List<Photo> searchedPhotos = photoService.searchPhotos(search);
model.addAttribute("images", searchedPhotos);

View File

@ -21,40 +21,35 @@ 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);
model.addAttribute("tags", photo.getTags());
model.addAttribute("thumbnails",
photoService.findAllExpect(List.of(id), PAGE_NUM, PAGE_SIZE));
return "photo";
}
@PostMapping("/photo/{id}")
private String updatePhotoDetails(@PathVariable("id") Long id,
@ModelAttribute Photo photo) {
@PostMapping("/photo/{id}")
private String updatePhotoDetails(@PathVariable Long id,
@ModelAttribute Photo formPhoto) {
photoService.updatePhotoDetails(id, photo, tags);
photoService.updatePhotoDetails(id, formPhoto);
return "redirect:/photo/" + id;
}
System.out.println(photo);
return "redirect:/photo/" + id;
}
@GetMapping("/loadMoreThumbnails")
private String loadMoreThumbnails(Model model,
@RequestParam("id") Long id,
@RequestParam("pageNum") int page) {
@RequestParam("id") Long id,
@RequestParam("pageNum") int page) {
model.addAttribute("thumbnails", photoService.findAllExpect(List.of(id), page + 1, 30));
@ -65,17 +60,24 @@ public class PhotoViewController {
}
@PostMapping("/tags/add")
public String addTag(@RequestParam String tag,
public String addTag(@RequestParam Long photoId,
@RequestParam String newTag,
Model model) {
String normalized = tag.trim().toUpperCase();
if (normalized.isEmpty() || tags.contains(normalized)) {
String normalized = newTag.trim().toUpperCase();
if (normalized.isEmpty()) {
return "photo :: empty";
}
tags.add(normalized);
model.addAttribute("tag", normalized);
Photo photo = photoService.findById(photoId);
return "photo:: badge";
if (!photo.getTags().contains(normalized)) {
photo.getTags().add(normalized);
photoService.save(photo);
}
model.addAttribute("tag", normalized);
return "photo :: tagBadge(tag=${tag})";
}
@PostMapping("/tags/remove")
@ -86,7 +88,7 @@ public class PhotoViewController {
}
@PostMapping("/photo/favorite/{id}")
private String setFavourite(@PathVariable("id") Long id){
private String setFavourite(@PathVariable("id") Long id) {
photoService.setFavourite(id);

View File

@ -2,12 +2,14 @@ package com.example.PhotoGallery.Models;
import java.util.List;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.Table;
import lombok.*;
@ -26,8 +28,11 @@ public class Photo {
@Column(length = 255)
private String description;
@Column
private List<Long> tags;
@ElementCollection
@CollectionTable(name = "photo_tags",
joinColumns = @JoinColumn(name = "photo_id"))
@Column(name = "tag")
private List<String> tags;
@Column
private Integer width;

View File

@ -1,24 +0,0 @@
package com.example.PhotoGallery.Models;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Data;
@Entity
@Table(name = "tags")
@Data
public class Tag {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column
private String name;
}

View File

@ -26,22 +26,25 @@ public interface PhotoRepo extends JpaRepository<Photo, Long> {
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, '%')
SELECT DISTINCT p.*
FROM photos p
LEFT JOIN photo_tags pt ON pt.photo_id = p.id
WHERE
CONCAT_WS(' ',
p.colour_space,
p.copy_right,
p.file_name,
p.file_size,
p.height,
p.mime_type,
p.title,
p.description,
p.width
) LIKE CONCAT('%', :searchValue, '%')
OR pt.tag LIKE CONCAT('%', :searchValue, '%')
""", nativeQuery = true)
List<Photo> findAllBySearch(@Param("search") String search);
List<Photo> searchPhotos(@Param("searchValue") String searchValue);
// 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

@ -1,22 +0,0 @@
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

@ -4,7 +4,6 @@ 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;
@ -17,9 +16,7 @@ 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 {
@ -30,8 +27,6 @@ public class PhotoService {
private ImageCompression imageCompression;
@Autowired
private PhotoRepo photoRepo;
@Autowired
private TagRepo tagRepo;
private Path originalFilesPath;
private Path thumbnailsPath;
@ -94,45 +89,32 @@ public class PhotoService {
return photoRepo.getRandomPhotos(numRandRecords);
}
public List<Photo> findAllBySearch(String search) {
public List<Photo> searchPhotos(String search) {
return photoRepo.findAllBySearch(search);
return photoRepo.searchPhotos(search);
}
public Photo findById(Long id) {
return photoRepo.findById(id).get();
}
public void save(Photo photo){
photoRepo.save(photo);
}
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) {
public void updatePhotoDetails(Long id, Photo newPhoto) {
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.setTags(newPhoto.getTags());
photo.setCopyRight(newPhoto.getCopyRight());
photoRepo.save(photo);

View File

@ -1,115 +1,223 @@
<!DOCTYPE html>
<html data-bs-theme="light" lang="en" style="width: 100%;height: 100%;">
<html data-bs-theme="dark" lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>Photo Gallery</title>
<title>PhotoGallery</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">
<style>
body {
background-color: #222831;
:root {
--grid-gap: 4px;
}
</style>
body {
background-color: #0b0c10;
color: #ffffff;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
/* Glassmorphism Navbar */
.navbar {
background: rgba(11, 12, 16, 0.8) !important;
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255, 255, 255, 0.1) !important;
}
/* Carousel Refinement */
#carousel-1 {
border-radius: 0 0 20px 20px;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
}
.carousel-item img {
filter: brightness(0.8);
transition: transform 10s ease;
}
.carousel-item.active img {
transform: scale(1.1);
}
/* THE 5-COLUMN GRID (Forces 5 columns on ALL screens) */
.custom-photo-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: var(--grid-gap);
padding: 0;
}
.photo-card {
position: relative;
aspect-ratio: 1 / 1;
overflow: hidden;
background: #1f2833;
cursor: pointer;
}
.photo-card img {
width: 100%;
height: 100%;
object-fit: cover;
transition: all 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.photo-card:hover img {
transform: scale(1.1);
filter: brightness(1.1);
}
/* Overlay effect on hover */
.photo-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0,0,0,0.7) 0%, transparent 50%);
opacity: 0;
transition: opacity 0.3s;
display: flex;
align-items: flex-end;
padding: 8px;
}
.photo-card:hover .photo-overlay {
opacity: 1;
}
/* Search bar styling */
.search-container {
position: relative;
max-width: 400px;
width: 100%;
}
.search-container .form-control {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
color: white;
padding-left: 40px;
border-radius: 50px;
}
.search-container .bi-search {
position: absolute;
left: 15px;
top: 50%;
transform: translateY(-50%);
color: rgba(255,255,255,0.5);
}
/* HTMX Loading Indicator */
.htmx-indicator {
display: none;
text-align: center;
padding: 20px;
}
.htmx-requesting .htmx-indicator {
display: block;
}
/* Mobile specific adjustments to keep 5 columns usable */
@media (max-width: 576px) {
:root { --grid-gap: 2px; }
.navbar-brand { font-size: 1.1rem; }
.carousel-item img { height: 250px !important; }
}
</style>
</head>
<body style="height: 100%;width: 100%;">
<nav class="navbar navbar-expand-md sticky-top bg-body border-bottom">
<body>
<nav class="navbar navbar-expand-md sticky-top shadow-sm">
<div class="container">
<a class="navbar-brand fw-bold" href="#">Photo Gallery</a>
<a class="navbar-brand fw-bold text-uppercase tracking-wider" href="/">
<i class="bi bi-camera-reels me-2"></i>Gallery
</a>
<button id="filterDropdown" class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#filterDropdown">
<button class="navbar-toggler border-0" 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 align-items-md-center gap-md-2">
<div class="mx-auto search-container mt-3 mt-md-0">
<i class="bi bi-search"></i>
<input class="form-control" type="search" name="search" id="search" placeholder="Search the collection..."
hx-post="/search"
hx-trigger="input changed delay:500ms, keyup[key=='Enter']"
hx-target="#image-container"
hx-swap="outerHTML">
</div>
<ul class="navbar-nav ms-auto align-items-center gap-2 mt-3 mt-md-0">
<li class="nav-item">
<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">
Filter
<button class="btn btn-sm btn-outline-light rounded-pill px-3 dropdown-toggle" data-bs-toggle="dropdown">
<i class="bi bi-filter-right me-1"></i> Sort By
</button>
<ul class="dropdown-menu dropdown-menu-dark">
<li><a class="dropdown-item" href="#">Action</a></li>
<li><a class="dropdown-item" href="#">Another action</a></li>
<li><a class="dropdown-item" href="#">Something else here</a></li>
<ul class="dropdown-menu dropdown-menu-end shadow">
<li><a class="dropdown-item" href="#">Newest First</a></li>
<li><a class="dropdown-item" href="#">Most Favourited</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#">Clear Filters</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
<div class="container ps-0 pe-0">
<div class="carousel slide" data-bs-ride="carousel" data-bs-interval="2500" data-bs-pause="false"
id="carousel-1">
<div class="carousel-inner">
<div class="container-fluid px-0 ">
<div id="carousel-1" class="carousel slide d-flex justify-content-center align-items-center " data-bs-ride="carousel" data-bs-interval="4000">
<div class="carousel-inner" style="max-width: 65rem;">
<div th:each="carouselImage, iterStat : ${carouselImages}" class="carousel-item"
th:classappend="${iterStat.first} ? active : ''">
<img class="object-fit-cover w-100 d-block" alt="Slide Image" th:src="@{${carouselImage.filePath}}"
height="500" loading="eager" width="1080">
<img class="w-100 d-block object-fit-cover" th:src="@{${carouselImage.filePath}}"
style="height: 500px;" alt="Hero Image">
<div class="carousel-caption d-none d-md-block text-start">
<h2 class="display-4 fw-bold" th:text="${carouselImage.title}">Highlight</h2>
<p class="lead" th:text="${carouselImage.description}">Featured collection item.</p>
</div>
</div>
</div>
<div>
<a class="carousel-control-prev" href="#carousel-1" role="button" data-bs-slide="prev"><span
class="carousel-control-prev-icon"></span>
<span class="visually-hidden">Previous</span>
</a>
<a class="carousel-control-next" href="#carousel-1" role="button" data-bs-slide="next">
<span class="carousel-control-next-icon"></span>
<span class="visually-hidden">Next</span>
</a>
</div>
<div class="carousel-indicators">
<button th:each="carouselImage,iterStat : ${carouselImages}" th:class="${iterStat.first} ? active : ''"
type="button" data-bs-target="#carousel-1"
th:attr="data-bs-slide-to=${iterStat.count - 1}"></button>
type="button" data-bs-target="#carousel-1" th:attr="data-bs-slide-to=${iterStat.index}"></button>
</div>
</div>
<div>
<section class="pt-5 pb-0 my-0 py-xl-5" style="padding: 0;">
<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="">
<main class="py-4 py-md-5">
<div id="image-container" th:fragment="image-container" class="container-fluid px-2">
<div th:fragment="images" class="col" th:each="image : ${images}">
<div id="image-row" class="custom-photo-grid">
<th:block th:fragment="images">
<div class="photo-card" th:each="image : ${images}">
<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}}"
loading="lazy">
<img th:src="@{${image.thumbnailPath}}" loading="lazy" alt="Gallery Image">
<div class="photo-overlay">
<small class="text-white text-truncate" th:text="${image.title}">Image Title</small>
</div>
</a>
</div>
</div>
<div
style="height: 1px;"
hx-get="/loadMoreImages"
hx-trigger="intersect"
hx-target="#image-row"
hx-swap="beforeend"
hx-indicator=".htmx-indicator">
</div>
</th:block>
</div>
</section>
</div>
<div class="htmx-indicator">
<div class="spinner-border text-primary" role="status"></div>
</div>
<div
style="height: 10px;"
hx-get="/loadMoreImages"
hx-trigger="intersect"
hx-target="#image-row"
hx-swap="beforeend"
hx-indicator=".htmx-indicator">
</div>
</div>
</main>
</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>
</body>
</html>

View File

@ -1,222 +1,200 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="light">
<html data-bs-theme="dark" lang="en">
<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">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>Photo Viewer | Premium 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">
<style>
body { background-color: var(--bs-body-bg); }
body {
background-color: #0b0c10;
color: #ffffff;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
/* Glassmorphism Navbar */
.navbar {
background: rgba(11, 12, 16, 0.8) !important;
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255, 255, 255, 0.1) !important;
}
/* Photo Display Stage */
.photo-stage {
background: #f8f9fa;
border-radius: .5rem;
padding: 1rem;
text-align: center;
min-height: 400px;
background: radial-gradient(circle, #1f2833 0%, #0b0c10 100%);
border-radius: 1.5rem;
padding: 2rem;
display: flex;
align-items: center;
justify-content: center;
min-height: 500px;
box-shadow: inset 0 0 50px rgba(0,0,0,0.5);
border: 1px solid rgba(255,255,255,0.05);
}
.photo-stage img {
max-height: 70vh;
max-height: 75vh;
object-fit: contain;
width: 100%;
border-radius: 0.5rem;
box-shadow: 0 20px 40px rgba(0,0,0,0.6);
}
/* 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;
/* Refined Cards */
.card {
background: rgba(31, 40, 51, 0.4);
backdrop-filter: blur(5px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1rem;
}
.thumbnail-item {
display: inline-block;
vertical-align: middle;
margin-right: 8px;
scroll-snap-align: start;
position: relative;
.form-control, .form-control:focus {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: white;
border-radius: 0.5rem;
}
.thumbnail-item img {
width: 110px;
height: 80px;
object-fit: cover;
border-radius: .25rem;
cursor: pointer;
opacity: .6;
transition: opacity .2s, transform .2s;
.form-label {
color: rgba(255,255,255,0.6);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 1px;
}
/* Active State Styles */
.thumbnail-item img.active {
opacity: 1;
border: 3px solid #0d6efd; /* Bootstrap Primary Color */
transform: scale(1.02);
/* Action Buttons */
.btn-action-group .btn {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
color: white;
padding: 0.5rem 1rem;
transition: all 0.3s ease;
}
.thumbnail-item img:hover {
opacity: 1;
transform: scale(1.05);
.btn-action-group .btn:hover {
background: rgba(255,255,255,0.2);
transform: translateY(-2px);
}
/* Metadata List */
.meta-row {
display: flex;
justify-content: space-between;
font-size: .9rem;
padding: .25rem 0;
border-bottom: 1px dashed #dee2e6;
padding: 0.75rem 0;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.thumbnail-strip::-webkit-scrollbar { height: 6px; }
.thumbnail-strip::-webkit-scrollbar-thumb { background: #dee2e6; border-radius: 3px; }
.meta-label { color: rgba(255,255,255,0.5); font-size: 0.9rem; }
.meta-value { font-weight: 500; font-size: 0.9rem; }
@media (max-width: 767px) {
.photo-stage img { max-height: 50vh; }
@media (max-width: 991px) {
.photo-stage { min-height: 350px; padding: 1rem; }
}
</style>
</head>
<body hx-boost="true">
<nav class="navbar navbar-expand-md sticky-top bg-body border-bottom">
<nav class="navbar navbar-expand-md sticky-top">
<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>
<a class="navbar-brand fw-bold text-uppercase tracking-wider" href="/home">
<i class="bi bi-camera-reels me-2 text-primary"></i>Gallery
</a>
<div class="ms-auto">
<a class="btn btn-sm btn-outline-light rounded-pill px-4" href="/home">
<i class="bi bi-arrow-left me-2"></i>Back to Grid
</a>
</div>
</div>
</nav>
<div class="container-fluid py-4">
<div class="container py-4 py-md-5">
<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 class="photo-stage mb-4">
<img th:src="@{${photo.filePath}}" alt="Main photo" class="img-fluid">
</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">
<div class="d-flex justify-content-center">
<div class="btn-group btn-action-group rounded-pill overflow-hidden shadow">
<a th:href="@{${photo.filePath}}" class="btn" 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">
<a th:href="@{${photo.filePath}}" class="btn" 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"
<button class="btn" th:fragment="favButton"
th:attr="hx-post=@{/photo/favorite/{id}(id=${photo.id})}"
hx-swap="outerHTML"
hx-boost="false"
title="Toggle favorite">
hx-swap="outerHTML" hx-boost="false">
<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"
<button class="btn" 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>
<form th:object="${photo}" method="post" th:action="@{/photo/{id}(id=${photo.id})}" class="card mb-4 shadow-sm" hx-boost="false">
<div class="card-body p-4">
<h5 class="fw-bold mb-4"><i class="bi bi-pencil-square me-2 text-primary"></i>Properties</h5>
<div class="mb-3">
<label class="form-label">Title</label>
<input th:field="*{title}" class="form-control" type="text">
<input th:field="*{title}" class="form-control" type="text" placeholder="Add a title...">
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea th:field="*{description}" class="form-control" rows="4"></textarea>
<textarea th:field="*{description}" class="form-control" rows="3" placeholder="Describe this moment..."></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">
<div class="input-group">
<span class="input-group-text bg-transparent border-end-0 text-white-50"><i class="bi bi-tag"></i></span>
<input name="newTag" class="form-control border-start-0" maxlength="20"
placeholder="Add tag & hit Enter"
th:attr="hx-post=@{/tags/add(photoId=${photo.id})}"
hx-trigger="keyup[key=='Enter']"
hx-target="#tagContainer"
hx-swap="beforeend"
hx-on::after-request="this.value=''"
hx-boost="false" />
</div>
<div id="tagContainer" class="mt-3 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">
<div class="mb-4">
<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 class="row g-2">
<div class="col-6">
<button class="btn btn-outline-light w-100 rounded-pill" type="reset">Reset</button>
</div>
<div class="col-6">
<button class="btn btn-primary w-100 rounded-pill" type="submit">Save</button>
</div>
</div>
</div>
</form>
<span th:fragment="tagBadge(tag)" class="badge bg-dark d-flex align-items-center">
<span th:fragment="tagBadge(tag)" class="badge rounded-pill bg-primary bg-opacity-25 border border-primary border-opacity-50 px-3 py-2 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;"
<button type="button" class="btn-close btn-close-white ms-2" style="font-size: 0.6rem;"
hx-post="/tags/remove"
hx-target="closest .badge"
hx-swap="outerHTML"
@ -224,27 +202,31 @@
</button>
</span>
<div class="card mb-4">
<div class="card-body">
<h6 class="card-title mb-3">Metadata</h6>
<div class="card mb-4 shadow-sm">
<div class="card-body p-4">
<h6 class="fw-bold mb-3"><i class="bi bi-info-circle me-2 text-primary"></i>Technical Info</h6>
<div class="meta-row">
<span>Type</span><span th:text="${photo.mimeType}"></span>
<span class="meta-label">Format</span>
<span class="meta-value text-uppercase" th:text="${photo.mimeType}"></span>
</div>
<div class="meta-row">
<span>Resolution</span><span th:text="${photo.width} + ' x ' + ${photo.height}"></span>
<span class="meta-label">Resolution</span>
<span class="meta-value" th:text="${photo.width} + ' × ' + ${photo.height}"></span>
</div>
<div class="meta-row">
<span>File Size</span><span th:text="${photo.file_size} + ' MB'"></span>
<span class="meta-label">Size</span>
<span class="meta-value" th:text="${photo.file_size} + ' MB'"></span>
</div>
<div class="meta-row border-0">
<span>Color Space</span><span th:text="${photo.colourSpace}"></span>
<span class="meta-label">Color Space</span>
<span class="meta-value" th:text="${photo.colourSpace}"></span>
</div>
</div>
</div>
<div class="card">
<div class="card overflow-hidden shadow-sm">
<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>
<iframe src="https://cdn.bootstrapstudio.io/placeholders/map.html" width="100%" height="200" style="border:0; filter: invert(90%) hue-rotate(180deg);" loading="lazy"></iframe>
</div>
</div>
@ -257,7 +239,6 @@
<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();