parent
527f770a18
commit
e1882e5a7f
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
private String loadMoreThumbnails(Model model,
|
||||
@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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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="">
|
||||
|
||||
<div th:fragment="images" class="col" th:each="image : ${images}">
|
||||
<main class="py-4 py-md-5">
|
||||
<div id="image-container" th:fragment="image-container" class="container-fluid px-2">
|
||||
|
||||
<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>
|
||||
|
|
@ -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; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.photo-stage img { max-height: 50vh; }
|
||||
.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: 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='{"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">
|
||||
<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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue