Refactor HTML templates for improved structure and styling

- Updated home.html with enhanced meta tags, improved navbar structure, and refined carousel and photo grid layout.
- Simplified CSS by linking external stylesheets and removing inline styles.
- Enhanced accessibility features, including aria-labels and improved button functionalities.
- Updated photo.html to include better metadata handling, improved form structure, and enhanced accessibility.
- Refined uploadPhoto.html with a clearer upload process, added progress handling, and improved user feedback.
This commit is contained in:
Kiyan 2025-12-21 09:07:18 +02:00
parent e1882e5a7f
commit 763912901a
37 changed files with 801 additions and 479 deletions

1
.gitignore vendored
View File

@ -31,3 +31,4 @@ build/
### VS Code ###
.vscode/
src/main/resources/application.properties

View File

@ -0,0 +1,27 @@
package com.example.PhotoGallery.Components;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;
import org.springframework.stereotype.Component;
@Component
public class ImageIngestionCoordinator {
private final AtomicBoolean running = new AtomicBoolean(false);
public boolean startIfNotRunning(Runnable task) {
if (running.compareAndSet(false, true)) {
CompletableFuture.runAsync(() -> {
try {
task.run();
} finally {
running.set(false);
}
});
return true;
}
return false;
}
}

View File

@ -0,0 +1,27 @@
package com.example.PhotoGallery.Configs;
import java.util.concurrent.Executor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "taskExecutor")
public Executor taskExecutor(){
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("Async-");
executor.initialize();
return executor;
}
}

View File

@ -1,4 +1,4 @@
package com.example.PhotoGallery.WebConfig;
package com.example.PhotoGallery.Configs;
import java.util.concurrent.TimeUnit;

View File

@ -3,12 +3,15 @@ package com.example.PhotoGallery.Controller;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import com.example.PhotoGallery.Components.ImageIngestionCoordinator;
import com.example.PhotoGallery.Models.Photo;
import com.example.PhotoGallery.Services.PhotoService;
@ -17,21 +20,28 @@ public class PhotoHomeController {
@Autowired
PhotoService photoService;
@Autowired
ImageIngestionCoordinator ingestionCoordinator;
private final int RANDOM_NUMBER_IMAGES = 5;
private final int FIRST_PAGE = 0;
private final int PAGE_SIZE = 10;
private int pageCounter = 0;
@GetMapping("/")
public String redirectToHome() {
return "redirect:/home";
}
@GetMapping("/home")
private String home(Model model) {
// reset the page counter when the page home page is reloaded
pageCounter = 0;
// upon a page refresh we will scan the db and add any new images that are in
// the directory
photoService.WriteImagesFromDirToDB(); // TODO: run this method asynchronously
// upon a page refresh we will scan the image directory,
// if there are any images that are not in the DB we can then compress and
// insert them
model.addAttribute("images", photoService.getPagedPhotos(FIRST_PAGE, PAGE_SIZE));
model.addAttribute("carouselImages", photoService.getRandomPhotos(RANDOM_NUMBER_IMAGES));
@ -40,6 +50,14 @@ public class PhotoHomeController {
}
@PostMapping("/scanLibrary")
@ResponseBody
public ResponseEntity<Void> scanLibrary() {
ingestionCoordinator.startIfNotRunning(
photoService::writeImagesFromDirToDB);
return ResponseEntity.noContent().build();
}
@GetMapping("/loadMoreImages")
public String loadMoreImages(Model model) {
int totalPages = photoService.getPagedPhotos(FIRST_PAGE, PAGE_SIZE).getTotalPages();

View File

@ -1,8 +1,6 @@
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;
@ -88,11 +86,12 @@ private String updatePhotoDetails(@PathVariable Long id,
}
@PostMapping("/photo/favorite/{id}")
private String setFavourite(@PathVariable("id") Long id) {
private String setFavourite(Model model, @PathVariable("id") Long id) {
photoService.setFavourite(id);
model.addAttribute("photo", photoService.findById(id));
return "photo::empty";
return "photo::favButton";
}

View File

@ -1,14 +0,0 @@
package com.example.PhotoGallery.Controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class UploadPhoto {
@GetMapping("/uploadPhoto")
private String uploadPhoto(){
return "uploadPhoto";
}
}

View File

@ -0,0 +1,122 @@
package com.example.PhotoGallery.Controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
import com.example.PhotoGallery.Components.ImageIngestionCoordinator;
import com.example.PhotoGallery.Services.PhotoService;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.UUID;
@Controller
public class UploadPhotoController {
@Autowired
ImageIngestionCoordinator ingestionCoordinator;
@Autowired
PhotoService photoService;
private static final int MAX_IMAGES_PER_UPLOAD = 20;
@Value("${photogallery.paths.originals}")
private String originalsPath;
@GetMapping("/uploadPhoto")
public String uploadPhotoPage() {
return "uploadPhoto";
}
@PostMapping("/uploadPhoto")
public String handleMultipleFileUpload(
@RequestParam("files") MultipartFile[] files,
Model model) {
if (files == null || files.length == 0) {
model.addAttribute("message", "No files selected.");
return "uploadPhoto :: responseMessage";
}
if (files.length > MAX_IMAGES_PER_UPLOAD) {
model.addAttribute(
"message",
"Maximum " + MAX_IMAGES_PER_UPLOAD + " images allowed per upload.");
return "uploadPhoto :: responseMessage";
}
int successCount = 0;
StringBuilder errors = new StringBuilder();
for (MultipartFile file : files) {
if (file.isEmpty()) {
continue;
}
// MIME validation
String contentType = file.getContentType();
if (contentType == null || !contentType.startsWith("image/")) {
errors.append("Skipped non-image file: ")
.append(file.getOriginalFilename());
continue;
}
// Metadata validation (actual image decode)
try (InputStream is = file.getInputStream()) {
BufferedImage image = ImageIO.read(is);
if (image == null) {
errors.append("Invalid image file: ")
.append(file.getOriginalFilename());
continue;
}
} catch (IOException e) {
errors.append("Failed to read image: ")
.append(file.getOriginalFilename());
continue;
}
try {
// Prevent filename collisions
String cleanName = StringUtils.cleanPath(file.getOriginalFilename());
String uniqueName = UUID.randomUUID() + "_" + cleanName;
Path targetPath = Paths.get(originalsPath).resolve(uniqueName);
Files.createDirectories(targetPath.getParent());
file.transferTo(targetPath.toFile());
successCount++;
} catch (IOException e) {
errors.append("Upload failed for ")
.append(file.getOriginalFilename());
}
}
//Update photo library by creating thumbnails and inserting into DB
scanLibrary();
model.addAttribute(
"message",
successCount + " image(s) uploaded successfully." + errors);
return "uploadPhoto :: responseMessage";
}
private void scanLibrary() {
ingestionCoordinator.startIfNotRunning(
photoService::writeImagesFromDirToDB);
}
}

View File

@ -2,8 +2,10 @@ package com.example.PhotoGallery;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication
@EnableAsync
public class PhotoGalleryApplication {
public static void main(String[] args) {

View File

@ -19,7 +19,7 @@ public interface PhotoRepo extends JpaRepository<Photo, Long> {
@Query(value = "SELECT file_name FROM photos", nativeQuery = true)
List<String> getAllFileNames();
@Query(value = "SELECT * FROM photos", nativeQuery = true)
@Query(value = "SELECT * FROM photos ORDER BY id DESC", nativeQuery = true)
Page<Photo> getPagedPhotos(Pageable pageable);
@Query(value = "SELECT * FROM photos ORDER BY RAND() LIMIT :numRecords", nativeQuery = true)
@ -45,7 +45,7 @@ public interface PhotoRepo extends JpaRepository<Photo, Long> {
""", nativeQuery = true)
List<Photo> searchPhotos(@Param("searchValue") String searchValue);
@Query(value = "SELECT * FROM photos WHERE id NOT IN (:ids)", nativeQuery = true)
@Query(value = "SELECT * FROM photos WHERE id NOT IN (:ids) ORDER BY id DESC", nativeQuery = true)
Page<Photo> findAllExpect(@Param("ids") List<Long> ids, Pageable pageable);
}

View File

@ -1,29 +0,0 @@
package com.example.PhotoGallery.Services;
import org.json.simple.JSONObject;
import org.json.simple.parser.JSONParser;
import org.springframework.stereotype.Service;
@Service
public class JsonParser {
public JSONObject parseJson(String jsonString){
JSONParser parser = new JSONParser();
try {
JSONObject jsonObject = (JSONObject) parser.parse(jsonString);
return jsonObject;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
public JSONObject getValue(JSONObject json, String key){
return (JSONObject) json.get(key);
}
}

View File

@ -10,9 +10,11 @@ import java.util.stream.Stream;
import org.json.simple.JSONObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import com.example.PhotoGallery.Models.Photo;
@ -45,25 +47,32 @@ public class PhotoService {
*
*
*/
public void WriteImagesFromDirToDB() {
@Async("taskExecutor")
public void writeImagesFromDirToDB() {
// initially get all the file names from the DB so we dont have to hit the DB
// for each photo
List<String> allFileName = getAllFileNames();
try (Stream<Path> imagePaths = Files.list(originalFilesPath)) {
imagePaths.forEach((imagePath) -> {
// only create the thumbnail and write to the DB if the photo doesnt exist in
// the DB
if (!allFileName.contains(imagePath.getFileName().toString())) {
// compress the image and write the new image to the thumbnail folder
imageCompression.compressImage(imagePath.toString(), thumbnailsPath.toString());
writePhotoDataToDB(imagePath);
imagePaths.forEach(imagePath -> {
if (Thread.currentThread().isInterrupted()) {
return;
}
String filename = imagePath.getFileName().toString();
if (!allFileName.contains(filename)) {
imageCompression.compressImage(
imagePath.toString(),
thumbnailsPath.toString());
try {
writePhotoDataToDB(imagePath);
} catch (DataIntegrityViolationException ignored) {
// already exists
}
}
});
} catch (IOException e) {
e.printStackTrace();
@ -98,7 +107,7 @@ public class PhotoService {
return photoRepo.findById(id).get();
}
public void save(Photo photo){
public void save(Photo photo) {
photoRepo.save(photo);
}
@ -138,7 +147,7 @@ public class PhotoService {
photo.setFilePath("images" + "/" + imagePath.getFileName().toString());
photo.setFileName(imagePath.getFileName().toString());
photo.setTitle("");
photo.setTitle(imagePath.getFileName().toString());
photo.setFavourite(false);
JSONObject extractedData = extractMetaData.getImageMetaData(imagePath.toString());
@ -146,6 +155,9 @@ public class PhotoService {
photo.setThumbnailPath("thumbnails" + "/" + imagePath.getFileName().toString());
// Set<String> generatedTags = taggingService.generateTags(imagePath);
// photo.getTags().addAll(generatedTags);
photoRepo.save(photo);
}

View File

@ -2,15 +2,18 @@ spring.application.name=PhotoGallery
server.port=8083
spring.datasource.url=jdbc:mysql://192.168.0.150:3306/photos?useSSL=false&serverTimezone=UTC
spring.datasource.username=photo_user
spring.datasource.password=MckopServerPhotos
spring.datasource.username=DevUser
spring.datasource.password=Dev
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
server.servlet.context-path=/photogallery
#===========================================================================
#Photos directories
photogallery.paths.originals=/srv/nas/PhotoGalleryImages/images/
photogallery.paths.thumbnails=/srv/nas/PhotoGalleryImages/thumbnails/
photogallery.paths.originals=/images/
photogallery.paths.thumbnails=/thumbnails/
spring.servlet.multipart.max-file-size=25MB
spring.servlet.multipart.max-request-size=100MB

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,140 @@
:root {
--grid-gap: 4px;
}
body {
background-color: #0b0c10;
color: #ffffff;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
.icon {
width: 1em;
height: 1em;
vertical-align: -0.125em;
filter: invert(1);
}
/* 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 */
#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;
width: 100%;
object-fit: cover;
height: 500px;
}
.carousel-item.active img {
transform: scale(1.1);
}
/* Photo Grid */
.custom-photo-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: var(--grid-gap);
}
.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: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94), filter 0.4s;
}
.photo-card:hover img {
transform: scale(1.1);
filter: brightness(1.1);
}
.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 */
.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 input {
padding-left: 2.25rem;
}
.search-container .bi-search {
position: absolute;
left: 15px;
top: 50%;
transform: translateY(-50%);
color: rgba(255, 255, 255, 0.5);
}
/* HTMX Loading */
.htmx-indicator {
display: none;
text-align: center;
padding: 20px;
}
.htmx-requesting .htmx-indicator {
display: block;
}
@media (min-width: 992px) {
.custom-photo-grid {
grid-template-columns: repeat(6, 1fr);
}
}
@media (min-width: 1200px) {
.custom-photo-grid {
grid-template-columns: repeat(8, 1fr);
}
}
@media (max-width: 575.98px) {
.custom-photo-grid {
grid-template-columns: repeat(5, 1fr);
}
}

View File

@ -0,0 +1,96 @@
body {
background-color: #0b0c10;
color: #ffffff;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
.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-stage {
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: 75vh;
object-fit: contain;
border-radius: 0.5rem;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.6);
}
.card {
background: rgba(31, 40, 51, 0.4);
backdrop-filter: blur(5px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1rem;
}
.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;
}
.form-label {
color: rgba(255, 255, 255, 0.6);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 1px;
}
.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;
}
.btn-action-group .btn:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
}
.meta-row {
display: flex;
justify-content: space-between;
padding: 0.75rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.meta-label {
color: rgba(255, 255, 255, 0.5);
font-size: 0.9rem;
}
.meta-value {
font-weight: 500;
font-size: 0.9rem;
}
.icon {
width: 1em;
height: 1em;
vertical-align: -0.125em;
filter: invert(1);
}
@media (max-width: 991px) {
.photo-stage {
min-height: 350px;
padding: 1rem;
}
}

View File

@ -0,0 +1,37 @@
body {
background-color: #0b0c10;
color: #ffffff;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
.navbar {
background: rgba(11, 12, 16, 0.85);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.card {
background: rgba(31, 40, 51, 0.45);
backdrop-filter: blur(6px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1rem;
}
.form-control,
.form-control:focus {
background: rgba(255, 255, 255, 0.06);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.15);
}
.progress {
height: 6px;
background-color: rgba(255, 255, 255, 0.1);
}
.icon {
width: 1em;
height: 1em;
vertical-align: -0.125em;
filter: invert(1);
}

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-clockwise" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2z"/>
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466"/>
</svg>

After

Width:  |  Height:  |  Size: 349 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-left" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8"/>
</svg>

After

Width:  |  Height:  |  Size: 310 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-up-right" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M14 2.5a.5.5 0 0 0-.5-.5h-6a.5.5 0 0 0 0 1h4.793L2.146 13.146a.5.5 0 0 0 .708.708L13 3.707V8.5a.5.5 0 0 0 1 0z"/>
</svg>

After

Width:  |  Height:  |  Size: 284 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrows-fullscreen" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M5.828 10.172a.5.5 0 0 0-.707 0l-4.096 4.096V11.5a.5.5 0 0 0-1 0v3.975a.5.5 0 0 0 .5.5H4.5a.5.5 0 0 0 0-1H1.732l4.096-4.096a.5.5 0 0 0 0-.707m4.344 0a.5.5 0 0 1 .707 0l4.096 4.096V11.5a.5.5 0 1 1 1 0v3.975a.5.5 0 0 1-.5.5H11.5a.5.5 0 0 1 0-1h2.768l-4.096-4.096a.5.5 0 0 1 0-.707m0-4.344a.5.5 0 0 0 .707 0l4.096-4.096V4.5a.5.5 0 1 0 1 0V.525a.5.5 0 0 0-.5-.5H11.5a.5.5 0 0 0 0 1h2.768l-4.096 4.096a.5.5 0 0 0 0 .707m-4.344 0a.5.5 0 0 1-.707 0L1.025 1.732V4.5a.5.5 0 0 1-1 0V.525a.5.5 0 0 1 .5-.5H4.5a.5.5 0 0 1 0 1H1.732l4.096 4.096a.5.5 0 0 1 0 .707"/>
</svg>

After

Width:  |  Height:  |  Size: 726 B

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-camera-reels" viewBox="0 0 16 16">
<path d="M6 3a3 3 0 1 1-6 0 3 3 0 0 1 6 0M1 3a2 2 0 1 0 4 0 2 2 0 0 0-4 0"/>
<path d="M9 6h.5a2 2 0 0 1 1.983 1.738l3.11-1.382A1 1 0 0 1 16 7.269v7.462a1 1 0 0 1-1.406.913l-3.111-1.382A2 2 0 0 1 9.5 16H2a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2zm6 8.73V7.27l-3.5 1.555v4.35zM1 8v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1"/>
<path d="M9 6a3 3 0 1 0 0-6 3 3 0 0 0 0 6M7 3a2 2 0 1 1 4 0 2 2 0 0 1-4 0"/>
</svg>

After

Width:  |  Height:  |  Size: 557 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-camera" viewBox="0 0 16 16">
<path d="M15 12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h1.172a3 3 0 0 0 2.12-.879l.83-.828A1 1 0 0 1 6.827 3h2.344a1 1 0 0 1 .707.293l.828.828A3 3 0 0 0 12.828 5H14a1 1 0 0 1 1 1zM2 4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-1.172a2 2 0 0 1-1.414-.586l-.828-.828A2 2 0 0 0 9.172 2H6.828a2 2 0 0 0-1.414.586l-.828.828A2 2 0 0 1 3.172 4z"/>
<path d="M8 11a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5m0 1a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7M3 6.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0"/>
</svg>

After

Width:  |  Height:  |  Size: 633 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-download" viewBox="0 0 16 16">
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5"/>
<path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708z"/>
</svg>

After

Width:  |  Height:  |  Size: 422 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-info-circle" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0"/>
</svg>

After

Width:  |  Height:  |  Size: 460 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-pencil-square" viewBox="0 0 16 16">
<path d="M15.502 1.94a.5.5 0 0 1 0 .706L14.459 3.69l-2-2L13.502.646a.5.5 0 0 1 .707 0l1.293 1.293zm-1.75 2.456-2-2L4.939 9.21a.5.5 0 0 0-.121.196l-.805 2.414a.25.25 0 0 0 .316.316l2.414-.805a.5.5 0 0 0 .196-.12l6.813-6.814z"/>
<path fill-rule="evenodd" d="M1 13.5A1.5 1.5 0 0 0 2.5 15h11a1.5 1.5 0 0 0 1.5-1.5v-6a.5.5 0 0 0-1 0v6a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5v-11a.5.5 0 0 1 .5-.5H9a.5.5 0 0 0 0-1H2.5A1.5 1.5 0 0 0 1 2.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 575 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-search" viewBox="0 0 16 16">
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001q.044.06.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1 1 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0"/>
</svg>

After

Width:  |  Height:  |  Size: 315 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-star-fill" viewBox="0 0 16 16">
<path d="M3.612 15.443c-.386.198-.824-.149-.746-.592l.83-4.73L.173 6.765c-.329-.314-.158-.888.283-.95l4.898-.696L7.538.792c.197-.39.73-.39.927 0l2.184 4.327 4.898.696c.441.062.612.636.282.95l-3.522 3.356.83 4.73c.078.443-.36.79-.746.592L8 13.187l-4.389 2.256z"/>
</svg>

After

Width:  |  Height:  |  Size: 399 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-star" viewBox="0 0 16 16">
<path d="M2.866 14.85c-.078.444.36.791.746.593l4.39-2.256 4.389 2.256c.386.198.824-.149.746-.592l-.83-4.73 3.522-3.356c.33-.314.16-.888-.282-.95l-4.898-.696L8.465.792a.513.513 0 0 0-.927 0L5.354 5.12l-4.898.696c-.441.062-.612.636-.283.95l3.523 3.356-.83 4.73zm4.905-2.767-3.686 1.894.694-3.957a.56.56 0 0 0-.163-.505L1.71 6.745l4.052-.576a.53.53 0 0 0 .393-.288L8 2.223l1.847 3.658a.53.53 0 0 0 .393.288l4.052.575-2.906 2.77a.56.56 0 0 0-.163.506l.694 3.957-3.686-1.894a.5.5 0 0 0-.461 0z"/>
</svg>

After

Width:  |  Height:  |  Size: 623 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-tag" viewBox="0 0 16 16">
<path d="M6 4.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m-1 0a.5.5 0 1 0-1 0 .5.5 0 0 0 1 0"/>
<path d="M2 1h4.586a1 1 0 0 1 .707.293l7 7a1 1 0 0 1 0 1.414l-4.586 4.586a1 1 0 0 1-1.414 0l-7-7A1 1 0 0 1 1 6.586V2a1 1 0 0 1 1-1m0 5.586 7 7L13.586 9l-7-7H2z"/>
</svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-upload" viewBox="0 0 16 16">
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5"/>
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708z"/>
</svg>

After

Width:  |  Height:  |  Size: 419 B

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,190 +1,65 @@
<!DOCTYPE html>
<html data-bs-theme="dark" lang="en">
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<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">
<title>PhotoGallery | Explore Your Collection</title>
<meta name="description" content="Explore your photo collection with PhotoGallery. Browse, search, and manage images effortlessly.">
<meta name="keywords" content="photo gallery, image collection, photo search, photo management, photography">
<style>
:root {
--grid-gap: 4px;
}
<link rel="stylesheet" th:href="@{/css/bootstrap.min.css}">
<link rel="stylesheet" th:href="@{/css/home.css}">
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>
<nav class="navbar navbar-expand-md sticky-top shadow-sm">
<div class="container">
<a class="navbar-brand fw-bold text-uppercase tracking-wider" href="/">
<i class="bi bi-camera-reels me-2"></i>Gallery
</a>
<button class="navbar-toggler border-0" type="button" data-bs-toggle="collapse" data-bs-target="#navMain">
<!-- Navbar -->
<nav class="navbar navbar-expand-md sticky-top shadow-sm p-3">
<div class="container">
<a class="navbar-brand fw-bold text-uppercase tracking-wider" th:href="@{/}">
<img th:src="@{/icons/camera-reels.svg}" class="icon me-2" alt="">Gallery
</a>
<button class="navbar-toggler border-0" type="button" data-bs-toggle="collapse" data-bs-target="#navMain" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navMain">
<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">
<input class="form-control" type="search" name="search" id="search" placeholder="Search the collection..." aria-label="Search photos"
th: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">
<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
<li>
<a class="btn" th:href="@{/uploadPhoto}" aria-label="Upload photo">
<img th:src="@{/icons/upload.svg}" class="icon" alt="">
</a>
</li>
<li>
<button class="btn" th:hx-post="@{/scanLibrary}" hx-trigger="click" hx-swap="none" aria-label="Scan library">
<img th:src="@{/icons/arrow-clockwise.svg}" class="icon" alt="">
</button>
<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-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">
<!-- Hero Carousel -->
<section 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="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 th:each="carouselImage, iterStat : ${carouselImages}" class="carousel-item" th:classappend="${iterStat.first} ? active : ''">
<img th:src="@{${carouselImage.filePath}}" alt="Carousel Image" class="d-block w-100">
</div>
</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.index}"></button>
</div>
<button th:each="carouselImage, iterStat : ${carouselImages}" type="button" data-bs-target="#carousel-1" th:attr="data-bs-slide-to=${iterStat.index}" th:class="${iterStat.first} ? active : ''" aria-label="Slide [[${iterStat.index + 1}]]"></button>
</div>
</section>
<!-- Photo Grid -->
<main class="py-4 py-md-5">
<div id="image-container" th:fragment="image-container" class="container-fluid px-2">
@ -192,7 +67,7 @@
<th:block th:fragment="images">
<div class="photo-card" th:each="image : ${images}">
<a th:href="@{/photo/{id}(id=${image.id})}">
<img th:src="@{${image.thumbnailPath}}" loading="lazy" alt="Gallery Image">
<img th:src="@{${image.thumbnailPath}}" loading="lazy" alt="Photo titled [[${image.title}]]">
<div class="photo-overlay">
<small class="text-white text-truncate" th:text="${image.title}">Image Title</small>
</div>
@ -202,22 +77,18 @@
</div>
<div class="htmx-indicator">
<div class="spinner-border text-primary" role="status"></div>
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</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 style="height: 10px;" th: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>
<script th:src="@{/js/bootstrap.bundle.min.js}" defer></script>
<script th:src="@{/js/htmx.min.js}" defer></script>
</body>
</html>

View File

@ -1,172 +1,108 @@
<!DOCTYPE html>
<html data-bs-theme="dark" lang="en">
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<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">
<title>Photo Viewer | PhotoGallery</title>
<meta name="description"
content="View and manage your photos in PhotoGallery. Edit titles, descriptions, tags, and technical info for each image.">
<meta name="keywords"
content="photo viewer, photo gallery, image management, photo tags, download photos, photo metadata">
<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;
}
/* Photo Display Stage */
.photo-stage {
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: 75vh;
object-fit: contain;
border-radius: 0.5rem;
box-shadow: 0 20px 40px rgba(0,0,0,0.6);
}
/* 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;
}
.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;
}
.form-label {
color: rgba(255,255,255,0.6);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 1px;
}
/* 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;
}
.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;
padding: 0.75rem 0;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.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>
<link rel="stylesheet" th:href="@{/css/bootstrap.min.css}">
<link rel="stylesheet" th:href="@{/css/photo.css}">
</head>
<body hx-boost="true">
<body>
<nav class="navbar navbar-expand-md sticky-top">
<!-- Navigation -->
<nav class="navbar navbar-expand-md sticky-top p-3">
<div class="container">
<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 class="navbar-brand fw-bold text-uppercase tracking-wider" th:href="@{/home}">
<img th:src="@{/icons/camera-reels.svg}" class="icon me-2" alt="">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 class="btn btn-sm btn-outline-light rounded-pill px-4" th:href="@{/home}">
<img th:src="@{/icons/arrow-left.svg}" class="icon me-2" alt="">Back to Grid
</a>
</div>
</div>
</nav>
<div class="container py-4 py-md-5">
<!-- Main Content -->
<main class="container py-4 py-md-5">
<div class="row g-4">
<div class="col-lg-8">
<!-- Photo Display -->
<section class="col-lg-8">
<div class="photo-stage mb-4">
<img th:src="@{${photo.filePath}}" alt="Main photo" class="img-fluid">
<img th:src="@{${photo.filePath}}" alt="Photo of [[${photo.title}]]" class="img-fluid">
</div>
<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>
<div class="btn-group btn-action-group rounded-pill overflow-hidden shadow" role="group"
aria-label="Photo Actions">
<a th:href="@{${photo.filePath}}" class="btn" download aria-label="Download photo">
<img th:src="@{/icons/download.svg}" class="icon" alt="">
</a>
<a th:href="@{${photo.filePath}}" class="btn" target="_blank" title="Open original">
<i class="bi bi-box-arrow-up-right"></i>
<a th:href="@{${photo.filePath}}" class="btn" target="_blank" rel="noopener"
aria-label="Open original photo">
<img th:src="@{/icons/arrow-up-right.svg}" class="icon" alt="">
</a>
<button class="btn" th:fragment="favButton"
th:attr="hx-post=@{/photo/favorite/{id}(id=${photo.id})}"
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>
hx-swap="outerHTML"
hx-boost="false" aria-label="Toggle favorite">
<img th:if="${!photo.favourite}" th:src="@{/icons/star.svg}" class="icon" alt="">
<img th:if="${photo.favourite}" th:src="@{/icons/star-fill.svg}" class="icon" alt="">
</button>
<button class="btn" title="Fullscreen"
onclick="document.querySelector('.photo-stage img').requestFullscreen()">
<i class="bi bi-arrows-fullscreen"></i>
onclick="document.querySelector('.photo-stage img').requestFullscreen()"
aria-label="View fullscreen">
<img th:src="@{/icons/arrows-fullscreen.svg}" class="icon" alt="">
</button>
</div>
</div>
</div>
</section>
<div class="col-lg-4">
<form th:object="${photo}" method="post" th:action="@{/photo/{id}(id=${photo.id})}" class="card mb-4 shadow-sm" hx-boost="false">
<!-- Photo Metadata & Editing -->
<aside class="col-lg-4">
<!-- Properties Form -->
<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>
<h2 class="fw-bold mb-4 fs-5">
<img th:src="@{/icons/pencil-square.svg}" class="icon me-2" alt="">Properties
</h2>
<div class="mb-3">
<label class="form-label">Title</label>
<input th:field="*{title}" class="form-control" type="text" placeholder="Add a title...">
<label class="form-label" for="title">Title</label>
<input id="title" 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="3" placeholder="Describe this moment..."></textarea>
<label class="form-label" for="description">Description</label>
<textarea id="description" th:field="*{description}" class="form-control" rows="3"
placeholder="Describe this moment..."></textarea>
</div>
<div class="mb-3">
<label class="form-label">Tags</label>
<div class="input-group">
<span class="input-group-text bg-transparent border-end-0 text-white-50"><i class="bi bi-tag"></i></span>
<span class="input-group-text bg-transparent border-end-0 text-white-50">
<img th:src="@{/icons/tag.svg}" class="icon" alt="">
</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-trigger="keyup[key=='Enter'], blur"
hx-target="#tagContainer"
hx-swap="beforeend"
hx-on::after-request="this.value=''"
hx-boost="false" />
hx-boost="false" aria-label="Add a new tag" />
</div>
<div id="tagContainer" class="mt-3 d-flex flex-wrap gap-2">
<th:block th:each="tag : *{tags}">
@ -176,8 +112,8 @@
</div>
<div class="mb-4">
<label class="form-label">Copyright</label>
<input th:field="*{copyRight}" class="form-control" type="text">
<label class="form-label" for="copyright">Copyright</label>
<input id="copyright" th:field="*{copyRight}" class="form-control" type="text">
</div>
<div class="row g-2">
@ -191,27 +127,33 @@
</div>
</form>
<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">
<!-- Tag Badge Fragment -->
<span th:if="${!#strings.isEmpty(tag)}" 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.6rem;"
hx-post="/tags/remove"
th:hx-post="@{/tags/remove}"
hx-target="closest .badge"
hx-swap="outerHTML"
hx-boost="false">
hx-boost="false"
aria-label="Remove tag">
</button>
</span>
<!-- Technical Info -->
<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>
<h3 class="fw-bold mb-3 fs-6">
<img th:src="@{/icons/info-circle.svg}" class="icon me-2" alt="">Technical Info
</h3>
<div class="meta-row">
<span class="meta-label">Format</span>
<span class="meta-value text-uppercase" th:text="${photo.mimeType}"></span>
</div>
<div class="meta-row">
<span class="meta-label">Resolution</span>
<span class="meta-value" th:text="${photo.width} + ' × ' + ${photo.height}"></span>
<span class="meta-value" th:text="${photo.width} + ' x ' + ${photo.height}"></span>
</div>
<div class="meta-row">
<span class="meta-label">Size</span>
@ -224,22 +166,17 @@
</div>
</div>
<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="200" style="border:0; filter: invert(90%) hue-rotate(180deg);" loading="lazy"></iframe>
</div>
</aside>
</div>
</main>
</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>
<!-- Scripts -->
<script th:src="@{/js/bootstrap.bundle.min.js}" defer></script>
<script th:src="@{/js/htmx.min.js}" defer></script>
<script>
document.addEventListener("DOMContentLoaded", () => {
document.body.addEventListener("keydown", function(e) {
document.body.addEventListener("keydown", function (e) {
if (e.target.name === "newTag" && e.key === "Enter") {
e.preventDefault();
}
@ -248,4 +185,5 @@
</script>
</body>
</html>

View File

@ -1,92 +1,99 @@
<!DOCTYPE html>
<html data-bs-theme="light" lang="en" style="width: 100%;height: 100%;">
<html lang="en" data-bs-theme="dark" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>Untitled</title>
<link rel="stylesheet" href="/css/uploadPhoto.css">
<title>Upload Photo | PhotoGallery</title>
<meta name="description" content="Upload and manage your photos in PhotoGallery. Add up to 20 images per upload with real-time progress feedback.">
<meta name="keywords" content="photo upload, image management, gallery, photo gallery, multiple image upload">
<link rel="stylesheet" th:href="@{/css/bootstrap.min.css}">
<link rel="stylesheet" th:href="@{/css/uploadPhoto.css}">
</head>
<body style="width: 100%;height: 100%;">
<nav class="navbar navbar-expand-md bg-body pt-0 mb-0 pb-0" style="height: 10%;">
<div class="container"><a class="navbar-brand d-flex align-items-center" href="#"><span
class="bs-icon-sm bs-icon-rounded bs-icon-primary d-flex justify-content-center align-items-center me-2 bs-icon"><svg
xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor"
viewBox="0 0 16 16" class="bi bi-camera">
<path
d="M15 12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h1.172a3 3 0 0 0 2.12-.879l.83-.828A1 1 0 0 1 6.827 3h2.344a1 1 0 0 1 .707.293l.828.828A3 3 0 0 0 12.828 5H14a1 1 0 0 1 1 1zM2 4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-1.172a2 2 0 0 1-1.414-.586l-.828-.828A2 2 0 0 0 9.172 2H6.828a2 2 0 0 0-1.414.586l-.828.828A2 2 0 0 1 3.172 4z">
</path>
<path
d="M8 11a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5m0 1a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7M3 6.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0">
</path>
</svg></span><span>Photo Gallery</span></a><button data-bs-toggle="collapse" class="navbar-toggler"
data-bs-target="#navcol-3"><span class="visually-hidden">Toggle navigation</span><span
class="navbar-toggler-icon"></span></button>
<div class="collapse navbar-collapse" id="navcol-3">
<ul class="navbar-nav align-items-center mx-auto">
<li class="nav-item">
<div class="nav-item dropdown"><a class="dropdown-toggle px-2 py-2" aria-expanded="false"
data-bs-toggle="dropdown" href="#" style="color: black;">Filter&nbsp;</a>
<div class="dropdown-menu"><a class="dropdown-item" href="#">First Item</a><a
class="dropdown-item" href="#">Second Item</a><a class="dropdown-item"
href="#">Third Item</a></div>
</div>
</li>
<li class="nav-item"><a class="nav-link active my-0 py-2" href="#">First Item</a></li>
<li class="nav-item"><a class="nav-link" href="#">Second Item</a></li>
<li class="nav-item"><a class="nav-link" href="#">Third Item</a></li>
</ul>
<body>
<!-- Navbar -->
<nav class="navbar navbar-expand-md sticky-top shadow-sm p-3">
<div class="container">
<a class="navbar-brand fw-bold text-uppercase tracking-wider" th:href="@{/home}">
<img th:src="@{/icons/camera-reels.svg}" class="icon me-2" alt="">
Gallery
</a>
<div class="ms-auto">
<a class="btn btn-sm btn-outline-light rounded-pill px-4" th:href="@{/home}"
aria-label="Back to gallery grid">
<img th:src="@{/icons/arrow-left.svg}" class="icon me-2" alt="">
Back to Grid
</a>
</div>
</div>
</nav>
<section class="position-relative d-flex justify-content-center py-4 py-xl-5" style="width: 100%;height: 90%;">
<div style="width: 100%;">
<div>
<div class="container position-relative" style="width: 100%;">
<div class="row d-flex justify-content-center">
<div class="col-md-8 col-lg-6 col-xl-5 col-xxl-4">
<div class="card mb-5">
<div class="card-body p-sm-5">
<h2 class="text-center mb-4">Add Photo</h2>
<form method="post">
<!-- Main Upload Section -->
<main class="container d-flex justify-content-center align-items-center min-vh-100">
<div class="card p-4 shadow-lg w-100" style="max-width: 500px;">
<h1 class="mb-3 text-center fs-4">Upload Photos</h1>
<form method="post" enctype="multipart/form-data"
th:hx-post="@{/uploadPhoto}"
hx-target="#response" hx-swap="innerHTML">
<div class="mb-3">
<input class="form-control" type="text" id="title" name="title"
placeholder="Title" required="">
<label for="files" class="form-label visually-hidden">Select Photos</label>
<input class="form-control" type="file" id="files" name="files" accept="image/*" multiple required
onchange="if(this.files.length > 20){ alert('Maximum 20 images allowed per upload'); this.value=''; }"
aria-describedby="fileHelp">
<div id="fileHelp" class="form-text text-muted">Maximum 20 images per upload.</div>
</div>
<div class="d-flex mb-3">
<input class="form-control me-2" type="number" id="width" name="width"
placeholder="Width" inputmode="numeric">
<input class="form-control ms-2" type="number" id="height" name="height"
placeholder="Height" inputmode="numeric">
</div>
<div class="mb-3">
<input class="form-control" type="datetime" id="datetime" name="datetime"
placeholder="Capture Date" required="">
</div>
<div class="mb-3">
<textarea class="form-control" id="Tags-2" name="Tags" rows="6"
placeholder="Tags"></textarea>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="formCheck-1">
<label class="form-check-label" for="formCheck-1">Favourite</label>
</div>
</div>
<div class="mb-3"><input class="form-control" type="file"></div>
<div><button class="btn btn-primary w-100 d-block" type="submit">Upload
Photo</button></div>
<button class="btn btn-primary w-100" type="submit" aria-label="Upload selected images">
Upload Images
</button>
</form>
<!-- Progress Bar -->
<div class="progress mt-3 d-none" id="uploadProgress" role="progressbar" aria-label="Upload progress">
<div class="progress-bar progress-bar-striped progress-bar-animated" style="width: 0%;"></div>
</div>
<!-- Response Message -->
<div id="response" th:fragment="responseMessage" class="mt-3 text-center small" th:text="${message}"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<script src="/js/uploadPhoto.js"></script>
</main>
<!-- Scripts -->
<script th:src="@{/js/bootstrap.bundle.min.js}" defer></script>
<script th:src="@{/js/htmx.min.js}" defer></script>
<script>
// Upload progress handling
document.body.addEventListener('htmx:xhr:loadstart', function () {
const bar = document.getElementById('uploadProgress');
if (bar) {
bar.classList.remove('d-none');
bar.firstElementChild.style.width = '25%';
}
});
document.body.addEventListener('htmx:xhr:progress', function (evt) {
const bar = document.querySelector('#uploadProgress .progress-bar');
if (bar && evt.detail.lengthComputable) {
bar.style.width = (evt.detail.loaded / evt.detail.total * 100) + '%';
}
});
document.body.addEventListener('htmx:afterRequest', function () {
const bar = document.getElementById('uploadProgress');
if (bar) {
bar.firstElementChild.style.width = '100%';
setTimeout(() => bar.classList.add('d-none'), 800);
}
});
</script>
</body>
</html>