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.
|
|
@ -31,3 +31,4 @@ build/
|
||||||
|
|
||||||
### VS Code ###
|
### VS Code ###
|
||||||
.vscode/
|
.vscode/
|
||||||
|
src/main/resources/application.properties
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package com.example.PhotoGallery.WebConfig;
|
package com.example.PhotoGallery.Configs;
|
||||||
|
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
|
@ -3,12 +3,15 @@ package com.example.PhotoGallery.Controller;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
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.Models.Photo;
|
||||||
import com.example.PhotoGallery.Services.PhotoService;
|
import com.example.PhotoGallery.Services.PhotoService;
|
||||||
|
|
||||||
|
|
@ -17,21 +20,28 @@ public class PhotoHomeController {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
PhotoService photoService;
|
PhotoService photoService;
|
||||||
|
@Autowired
|
||||||
|
ImageIngestionCoordinator ingestionCoordinator;
|
||||||
|
|
||||||
private final int RANDOM_NUMBER_IMAGES = 5;
|
private final int RANDOM_NUMBER_IMAGES = 5;
|
||||||
private final int FIRST_PAGE = 0;
|
private final int FIRST_PAGE = 0;
|
||||||
private final int PAGE_SIZE = 10;
|
private final int PAGE_SIZE = 10;
|
||||||
private int pageCounter = 0;
|
private int pageCounter = 0;
|
||||||
|
|
||||||
|
@GetMapping("/")
|
||||||
|
public String redirectToHome() {
|
||||||
|
return "redirect:/home";
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/home")
|
@GetMapping("/home")
|
||||||
private String home(Model model) {
|
private String home(Model model) {
|
||||||
|
|
||||||
// reset the page counter when the page home page is reloaded
|
// reset the page counter when the page home page is reloaded
|
||||||
pageCounter = 0;
|
pageCounter = 0;
|
||||||
|
|
||||||
// upon a page refresh we will scan the db and add any new images that are in
|
// upon a page refresh we will scan the image directory,
|
||||||
// the directory
|
// if there are any images that are not in the DB we can then compress and
|
||||||
photoService.WriteImagesFromDirToDB(); // TODO: run this method asynchronously
|
// insert them
|
||||||
|
|
||||||
model.addAttribute("images", photoService.getPagedPhotos(FIRST_PAGE, PAGE_SIZE));
|
model.addAttribute("images", photoService.getPagedPhotos(FIRST_PAGE, PAGE_SIZE));
|
||||||
model.addAttribute("carouselImages", photoService.getRandomPhotos(RANDOM_NUMBER_IMAGES));
|
model.addAttribute("carouselImages", photoService.getRandomPhotos(RANDOM_NUMBER_IMAGES));
|
||||||
|
|
@ -40,6 +50,14 @@ public class PhotoHomeController {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/scanLibrary")
|
||||||
|
@ResponseBody
|
||||||
|
public ResponseEntity<Void> scanLibrary() {
|
||||||
|
ingestionCoordinator.startIfNotRunning(
|
||||||
|
photoService::writeImagesFromDirToDB);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/loadMoreImages")
|
@GetMapping("/loadMoreImages")
|
||||||
public String loadMoreImages(Model model) {
|
public String loadMoreImages(Model model) {
|
||||||
int totalPages = photoService.getPagedPhotos(FIRST_PAGE, PAGE_SIZE).getTotalPages();
|
int totalPages = photoService.getPagedPhotos(FIRST_PAGE, PAGE_SIZE).getTotalPages();
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
package com.example.PhotoGallery.Controller;
|
package com.example.PhotoGallery.Controller;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
|
|
@ -88,11 +86,12 @@ private String updatePhotoDetails(@PathVariable Long id,
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/photo/favorite/{id}")
|
@PostMapping("/photo/favorite/{id}")
|
||||||
private String setFavourite(@PathVariable("id") Long id) {
|
private String setFavourite(Model model, @PathVariable("id") Long id) {
|
||||||
|
|
||||||
photoService.setFavourite(id);
|
photoService.setFavourite(id);
|
||||||
|
model.addAttribute("photo", photoService.findById(id));
|
||||||
|
|
||||||
return "photo::empty";
|
return "photo::favButton";
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -2,8 +2,10 @@ package com.example.PhotoGallery;
|
||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@EnableAsync
|
||||||
public class PhotoGalleryApplication {
|
public class PhotoGalleryApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ public interface PhotoRepo extends JpaRepository<Photo, Long> {
|
||||||
@Query(value = "SELECT file_name FROM photos", nativeQuery = true)
|
@Query(value = "SELECT file_name FROM photos", nativeQuery = true)
|
||||||
List<String> getAllFileNames();
|
List<String> getAllFileNames();
|
||||||
|
|
||||||
@Query(value = "SELECT * FROM photos", nativeQuery = true)
|
@Query(value = "SELECT * FROM photos ORDER BY id DESC", nativeQuery = true)
|
||||||
Page<Photo> getPagedPhotos(Pageable pageable);
|
Page<Photo> getPagedPhotos(Pageable pageable);
|
||||||
|
|
||||||
@Query(value = "SELECT * FROM photos ORDER BY RAND() LIMIT :numRecords", nativeQuery = true)
|
@Query(value = "SELECT * FROM photos ORDER BY RAND() LIMIT :numRecords", nativeQuery = true)
|
||||||
|
|
@ -45,7 +45,7 @@ public interface PhotoRepo extends JpaRepository<Photo, Long> {
|
||||||
""", nativeQuery = true)
|
""", nativeQuery = true)
|
||||||
List<Photo> searchPhotos(@Param("searchValue") String searchValue);
|
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);
|
Page<Photo> findAllExpect(@Param("ids") List<Long> ids, Pageable pageable);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -10,9 +10,11 @@ import java.util.stream.Stream;
|
||||||
import org.json.simple.JSONObject;
|
import org.json.simple.JSONObject;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import com.example.PhotoGallery.Models.Photo;
|
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();
|
List<String> allFileName = getAllFileNames();
|
||||||
|
|
||||||
try (Stream<Path> imagePaths = Files.list(originalFilesPath)) {
|
try (Stream<Path> imagePaths = Files.list(originalFilesPath)) {
|
||||||
imagePaths.forEach((imagePath) -> {
|
imagePaths.forEach(imagePath -> {
|
||||||
|
|
||||||
// only create the thumbnail and write to the DB if the photo doesnt exist in
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
|
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) {
|
} catch (IOException e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
|
|
@ -98,7 +107,7 @@ public class PhotoService {
|
||||||
return photoRepo.findById(id).get();
|
return photoRepo.findById(id).get();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void save(Photo photo){
|
public void save(Photo photo) {
|
||||||
photoRepo.save(photo);
|
photoRepo.save(photo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -138,7 +147,7 @@ public class PhotoService {
|
||||||
|
|
||||||
photo.setFilePath("images" + "/" + imagePath.getFileName().toString());
|
photo.setFilePath("images" + "/" + imagePath.getFileName().toString());
|
||||||
photo.setFileName(imagePath.getFileName().toString());
|
photo.setFileName(imagePath.getFileName().toString());
|
||||||
photo.setTitle("");
|
photo.setTitle(imagePath.getFileName().toString());
|
||||||
photo.setFavourite(false);
|
photo.setFavourite(false);
|
||||||
|
|
||||||
JSONObject extractedData = extractMetaData.getImageMetaData(imagePath.toString());
|
JSONObject extractedData = extractMetaData.getImageMetaData(imagePath.toString());
|
||||||
|
|
@ -146,6 +155,9 @@ public class PhotoService {
|
||||||
|
|
||||||
photo.setThumbnailPath("thumbnails" + "/" + imagePath.getFileName().toString());
|
photo.setThumbnailPath("thumbnails" + "/" + imagePath.getFileName().toString());
|
||||||
|
|
||||||
|
// Set<String> generatedTags = taggingService.generateTags(imagePath);
|
||||||
|
// photo.getTags().addAll(generatedTags);
|
||||||
|
|
||||||
photoRepo.save(photo);
|
photoRepo.save(photo);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,18 @@ spring.application.name=PhotoGallery
|
||||||
server.port=8083
|
server.port=8083
|
||||||
|
|
||||||
spring.datasource.url=jdbc:mysql://192.168.0.150:3306/photos?useSSL=false&serverTimezone=UTC
|
spring.datasource.url=jdbc:mysql://192.168.0.150:3306/photos?useSSL=false&serverTimezone=UTC
|
||||||
spring.datasource.username=photo_user
|
spring.datasource.username=DevUser
|
||||||
spring.datasource.password=MckopServerPhotos
|
spring.datasource.password=Dev
|
||||||
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
|
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
|
||||||
spring.jpa.show-sql=true
|
spring.jpa.show-sql=true
|
||||||
spring.jpa.hibernate.ddl-auto=update
|
spring.jpa.hibernate.ddl-auto=update
|
||||||
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
|
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
|
||||||
|
|
||||||
|
server.servlet.context-path=/photogallery
|
||||||
|
|
||||||
#===========================================================================
|
#===========================================================================
|
||||||
#Photos directories
|
#Photos directories
|
||||||
photogallery.paths.originals=/srv/nas/PhotoGalleryImages/images/
|
photogallery.paths.originals=/images/
|
||||||
photogallery.paths.thumbnails=/srv/nas/PhotoGalleryImages/thumbnails/
|
photogallery.paths.thumbnails=/thumbnails/
|
||||||
|
spring.servlet.multipart.max-file-size=25MB
|
||||||
|
spring.servlet.multipart.max-request-size=100MB
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -1,223 +1,94 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html data-bs-theme="dark" lang="en">
|
<html lang="en" data-bs-theme="dark">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
|
||||||
<title>PhotoGallery</title>
|
<title>PhotoGallery | Explore Your Collection</title>
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/css/bootstrap.min.css">
|
<meta name="description" content="Explore your photo collection with PhotoGallery. Browse, search, and manage images effortlessly.">
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css">
|
<meta name="keywords" content="photo gallery, image collection, photo search, photo management, photography">
|
||||||
|
|
||||||
<style>
|
<link rel="stylesheet" th:href="@{/css/bootstrap.min.css}">
|
||||||
:root {
|
<link rel="stylesheet" th:href="@{/css/home.css}">
|
||||||
--grid-gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
</head>
|
||||||
|
|
||||||
<body>
|
<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>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="collapse navbar-collapse" id="navMain">
|
<div class="collapse navbar-collapse" id="navMain">
|
||||||
<div class="mx-auto search-container mt-3 mt-md-0">
|
<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..." aria-label="Search photos"
|
||||||
<input class="form-control" type="search" name="search" id="search" placeholder="Search the collection..."
|
th:hx-post="@{/search}" hx-trigger="input changed delay:500ms, keyup[key=='Enter']" hx-target="#image-container" hx-swap="outerHTML">
|
||||||
hx-post="/search"
|
|
||||||
hx-trigger="input changed delay:500ms, keyup[key=='Enter']"
|
|
||||||
hx-target="#image-container"
|
|
||||||
hx-swap="outerHTML">
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="navbar-nav ms-auto align-items-center gap-2 mt-3 mt-md-0">
|
<ul class="navbar-nav ms-auto align-items-center gap-2 mt-3 mt-md-0">
|
||||||
<li class="nav-item">
|
<li>
|
||||||
<button class="btn btn-sm btn-outline-light rounded-pill px-3 dropdown-toggle" data-bs-toggle="dropdown">
|
<a class="btn" th:href="@{/uploadPhoto}" aria-label="Upload photo">
|
||||||
<i class="bi bi-filter-right me-1"></i> Sort By
|
<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>
|
</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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="container-fluid px-0 ">
|
<!-- Hero Carousel -->
|
||||||
<div id="carousel-1" class="carousel slide d-flex justify-content-center align-items-center " data-bs-ride="carousel" data-bs-interval="4000">
|
<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 class="carousel-inner" style="max-width: 65rem;">
|
||||||
<div th:each="carouselImage, iterStat : ${carouselImages}" class="carousel-item"
|
<div th:each="carouselImage, iterStat : ${carouselImages}" class="carousel-item" th:classappend="${iterStat.first} ? active : ''">
|
||||||
th:classappend="${iterStat.first} ? active : ''">
|
<img th:src="@{${carouselImage.filePath}}" alt="Carousel Image" class="d-block w-100">
|
||||||
<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 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="carousel-indicators">
|
||||||
|
<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>
|
||||||
|
|
||||||
<main class="py-4 py-md-5">
|
<!-- Photo Grid -->
|
||||||
<div id="image-container" th:fragment="image-container" class="container-fluid px-2">
|
<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">
|
<div id="image-row" class="custom-photo-grid">
|
||||||
<th:block th:fragment="images">
|
<th:block th:fragment="images">
|
||||||
<div class="photo-card" th:each="image : ${images}">
|
<div class="photo-card" th:each="image : ${images}">
|
||||||
<a th:href="@{/photo/{id}(id=${image.id})}">
|
<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">
|
<div class="photo-overlay">
|
||||||
<small class="text-white text-truncate" th:text="${image.title}">Image Title</small>
|
<small class="text-white text-truncate" th:text="${image.title}">Image Title</small>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</th:block>
|
</th:block>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="htmx-indicator">
|
<div class="htmx-indicator">
|
||||||
<div class="spinner-border text-primary" role="status"></div>
|
<div class="spinner-border text-primary" role="status">
|
||||||
</div>
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
|
||||||
<div
|
|
||||||
style="height: 10px;"
|
|
||||||
hx-get="/loadMoreImages"
|
|
||||||
hx-trigger="intersect"
|
|
||||||
hx-target="#image-row"
|
|
||||||
hx-swap="beforeend"
|
|
||||||
hx-indicator=".htmx-indicator">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/js/bootstrap.bundle.min.js"></script>
|
<div style="height: 10px;" th:hx-get="@{/loadMoreImages}" hx-trigger="intersect" hx-target="#image-row" hx-swap="beforeend" hx-indicator=".htmx-indicator"></div>
|
||||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script th:src="@{/js/bootstrap.bundle.min.js}" defer></script>
|
||||||
|
<script th:src="@{/js/htmx.min.js}" defer></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -1,172 +1,108 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html data-bs-theme="dark" lang="en">
|
<html lang="en" data-bs-theme="dark">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
|
||||||
<title>Photo Viewer | Premium Gallery</title>
|
<title>Photo Viewer | PhotoGallery</title>
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/css/bootstrap.min.css">
|
<meta name="description"
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css">
|
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>
|
<link rel="stylesheet" th:href="@{/css/bootstrap.min.css}">
|
||||||
body {
|
<link rel="stylesheet" th:href="@{/css/photo.css}">
|
||||||
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>
|
|
||||||
</head>
|
</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">
|
<div class="container">
|
||||||
<a class="navbar-brand fw-bold text-uppercase tracking-wider" href="/home">
|
<a class="navbar-brand fw-bold text-uppercase tracking-wider" th:href="@{/home}">
|
||||||
<i class="bi bi-camera-reels me-2 text-primary"></i>Gallery
|
<img th:src="@{/icons/camera-reels.svg}" class="icon me-2" alt="">Gallery
|
||||||
</a>
|
</a>
|
||||||
<div class="ms-auto">
|
<div class="ms-auto">
|
||||||
<a class="btn btn-sm btn-outline-light rounded-pill px-4" href="/home">
|
<a class="btn btn-sm btn-outline-light rounded-pill px-4" th:href="@{/home}">
|
||||||
<i class="bi bi-arrow-left me-2"></i>Back to Grid
|
<img th:src="@{/icons/arrow-left.svg}" class="icon me-2" alt="">Back to Grid
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</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="row g-4">
|
||||||
|
|
||||||
<div class="col-lg-8">
|
<!-- Photo Display -->
|
||||||
|
<section class="col-lg-8">
|
||||||
<div class="photo-stage mb-4">
|
<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>
|
||||||
|
|
||||||
<div class="d-flex justify-content-center">
|
<div class="d-flex justify-content-center">
|
||||||
<div class="btn-group btn-action-group rounded-pill overflow-hidden shadow">
|
<div class="btn-group btn-action-group rounded-pill overflow-hidden shadow" role="group"
|
||||||
<a th:href="@{${photo.filePath}}" class="btn" download title="Download">
|
aria-label="Photo Actions">
|
||||||
<i class="bi bi-download"></i>
|
<a th:href="@{${photo.filePath}}" class="btn" download aria-label="Download photo">
|
||||||
|
<img th:src="@{/icons/download.svg}" class="icon" alt="">
|
||||||
</a>
|
</a>
|
||||||
<a th:href="@{${photo.filePath}}" class="btn" target="_blank" title="Open original">
|
<a th:href="@{${photo.filePath}}" class="btn" target="_blank" rel="noopener"
|
||||||
<i class="bi bi-box-arrow-up-right"></i>
|
aria-label="Open original photo">
|
||||||
|
<img th:src="@{/icons/arrow-up-right.svg}" class="icon" alt="">
|
||||||
</a>
|
</a>
|
||||||
<button class="btn" th:fragment="favButton"
|
<button class="btn" th:fragment="favButton"
|
||||||
th:attr="hx-post=@{/photo/favorite/{id}(id=${photo.id})}"
|
th:attr="hx-post=@{/photo/favorite/{id}(id=${photo.id})}"
|
||||||
hx-swap="outerHTML" hx-boost="false">
|
hx-swap="outerHTML"
|
||||||
<i th:if="${!photo.favourite}" class="bi bi-star"></i>
|
hx-boost="false" aria-label="Toggle favorite">
|
||||||
<i th:if="${photo.favourite}" class="bi bi-star-fill text-warning"></i>
|
<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>
|
||||||
<button class="btn" title="Fullscreen"
|
<button class="btn" title="Fullscreen"
|
||||||
onclick="document.querySelector('.photo-stage img').requestFullscreen()">
|
onclick="document.querySelector('.photo-stage img').requestFullscreen()"
|
||||||
<i class="bi bi-arrows-fullscreen"></i>
|
aria-label="View fullscreen">
|
||||||
|
<img th:src="@{/icons/arrows-fullscreen.svg}" class="icon" alt="">
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div class="col-lg-4">
|
<!-- Photo Metadata & Editing -->
|
||||||
<form th:object="${photo}" method="post" th:action="@{/photo/{id}(id=${photo.id})}" class="card mb-4 shadow-sm" hx-boost="false">
|
<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">
|
<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">
|
<div class="mb-3">
|
||||||
<label class="form-label">Title</label>
|
<label class="form-label" for="title">Title</label>
|
||||||
<input th:field="*{title}" class="form-control" type="text" placeholder="Add a title...">
|
<input id="title" th:field="*{title}" class="form-control" type="text"
|
||||||
|
placeholder="Add a title...">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Description</label>
|
<label class="form-label" for="description">Description</label>
|
||||||
<textarea th:field="*{description}" class="form-control" rows="3" placeholder="Describe this moment..."></textarea>
|
<textarea id="description" th:field="*{description}" class="form-control" rows="3"
|
||||||
|
placeholder="Describe this moment..."></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Tags</label>
|
<label class="form-label">Tags</label>
|
||||||
<div class="input-group">
|
<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"
|
<input name="newTag" class="form-control border-start-0" maxlength="20"
|
||||||
placeholder="Add tag & hit Enter"
|
placeholder="Add tag & hit Enter"
|
||||||
th:attr="hx-post=@{/tags/add(photoId=${photo.id})}"
|
th:attr="hx-post=@{/tags/add(photoId=${photo.id})}"
|
||||||
hx-trigger="keyup[key=='Enter']"
|
hx-trigger="keyup[key=='Enter'], blur"
|
||||||
hx-target="#tagContainer"
|
hx-target="#tagContainer"
|
||||||
hx-swap="beforeend"
|
hx-swap="beforeend"
|
||||||
hx-on::after-request="this.value=''"
|
hx-on::after-request="this.value=''"
|
||||||
hx-boost="false" />
|
hx-boost="false" aria-label="Add a new tag" />
|
||||||
</div>
|
</div>
|
||||||
<div id="tagContainer" class="mt-3 d-flex flex-wrap gap-2">
|
<div id="tagContainer" class="mt-3 d-flex flex-wrap gap-2">
|
||||||
<th:block th:each="tag : *{tags}">
|
<th:block th:each="tag : *{tags}">
|
||||||
|
|
@ -176,8 +112,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="form-label">Copyright</label>
|
<label class="form-label" for="copyright">Copyright</label>
|
||||||
<input th:field="*{copyRight}" class="form-control" type="text">
|
<input id="copyright" th:field="*{copyRight}" class="form-control" type="text">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-2">
|
<div class="row g-2">
|
||||||
|
|
@ -191,27 +127,33 @@
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
<span th:text="${tag}"></span>
|
||||||
<input type="hidden" name="tags" th:value="${tag}">
|
<input type="hidden" name="tags" th:value="${tag}">
|
||||||
<button type="button" class="btn-close btn-close-white ms-2" style="font-size: 0.6rem;"
|
<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-target="closest .badge"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
hx-boost="false">
|
hx-boost="false"
|
||||||
|
aria-label="Remove tag">
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<!-- Technical Info -->
|
||||||
<div class="card mb-4 shadow-sm">
|
<div class="card mb-4 shadow-sm">
|
||||||
<div class="card-body p-4">
|
<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">
|
<div class="meta-row">
|
||||||
<span class="meta-label">Format</span>
|
<span class="meta-label">Format</span>
|
||||||
<span class="meta-value text-uppercase" th:text="${photo.mimeType}"></span>
|
<span class="meta-value text-uppercase" th:text="${photo.mimeType}"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<span class="meta-label">Resolution</span>
|
<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>
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<span class="meta-label">Size</span>
|
<span class="meta-label">Size</span>
|
||||||
|
|
@ -224,22 +166,17 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card overflow-hidden shadow-sm">
|
</aside>
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/js/bootstrap.bundle.min.js"></script>
|
<!-- Scripts -->
|
||||||
<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>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
document.body.addEventListener("keydown", function(e) {
|
document.body.addEventListener("keydown", function (e) {
|
||||||
if (e.target.name === "newTag" && e.key === "Enter") {
|
if (e.target.name === "newTag" && e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
@ -248,4 +185,5 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -1,92 +1,99 @@
|
||||||
<!DOCTYPE html>
|
<!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>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
|
||||||
<title>Untitled</title>
|
<title>Upload Photo | PhotoGallery</title>
|
||||||
<link rel="stylesheet" href="/css/uploadPhoto.css">
|
<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>
|
</head>
|
||||||
|
|
||||||
<body style="width: 100%;height: 100%;">
|
<body>
|
||||||
<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
|
<!-- Navbar -->
|
||||||
class="bs-icon-sm bs-icon-rounded bs-icon-primary d-flex justify-content-center align-items-center me-2 bs-icon"><svg
|
<nav class="navbar navbar-expand-md sticky-top shadow-sm p-3">
|
||||||
xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor"
|
<div class="container">
|
||||||
viewBox="0 0 16 16" class="bi bi-camera">
|
<a class="navbar-brand fw-bold text-uppercase tracking-wider" th:href="@{/home}">
|
||||||
<path
|
<img th:src="@{/icons/camera-reels.svg}" class="icon me-2" alt="">
|
||||||
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">
|
Gallery
|
||||||
</path>
|
</a>
|
||||||
<path
|
<div class="ms-auto">
|
||||||
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">
|
<a class="btn btn-sm btn-outline-light rounded-pill px-4" th:href="@{/home}"
|
||||||
</path>
|
aria-label="Back to gallery grid">
|
||||||
</svg></span><span>Photo Gallery</span></a><button data-bs-toggle="collapse" class="navbar-toggler"
|
<img th:src="@{/icons/arrow-left.svg}" class="icon me-2" alt="">
|
||||||
data-bs-target="#navcol-3"><span class="visually-hidden">Toggle navigation</span><span
|
Back to Grid
|
||||||
class="navbar-toggler-icon"></span></button>
|
</a>
|
||||||
<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 </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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<section class="position-relative d-flex justify-content-center py-4 py-xl-5" style="width: 100%;height: 90%;">
|
|
||||||
<div style="width: 100%;">
|
<!-- Main Upload Section -->
|
||||||
<div>
|
<main class="container d-flex justify-content-center align-items-center min-vh-100">
|
||||||
<div class="container position-relative" style="width: 100%;">
|
<div class="card p-4 shadow-lg w-100" style="max-width: 500px;">
|
||||||
<div class="row d-flex justify-content-center">
|
<h1 class="mb-3 text-center fs-4">Upload Photos</h1>
|
||||||
<div class="col-md-8 col-lg-6 col-xl-5 col-xxl-4">
|
|
||||||
<div class="card mb-5">
|
<form method="post" enctype="multipart/form-data"
|
||||||
<div class="card-body p-sm-5">
|
th:hx-post="@{/uploadPhoto}"
|
||||||
<h2 class="text-center mb-4">Add Photo</h2>
|
hx-target="#response" hx-swap="innerHTML">
|
||||||
<form method="post">
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<input class="form-control" type="text" id="title" name="title"
|
<label for="files" class="form-label visually-hidden">Select Photos</label>
|
||||||
placeholder="Title" required="">
|
<input class="form-control" type="file" id="files" name="files" accept="image/*" multiple required
|
||||||
</div>
|
onchange="if(this.files.length > 20){ alert('Maximum 20 images allowed per upload'); this.value=''; }"
|
||||||
<div class="d-flex mb-3">
|
aria-describedby="fileHelp">
|
||||||
<input class="form-control me-2" type="number" id="width" name="width"
|
<div id="fileHelp" class="form-text text-muted">Maximum 20 images per upload.</div>
|
||||||
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>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
<!-- Response Message -->
|
||||||
|
<div id="response" th:fragment="responseMessage" class="mt-3 text-center small" th:text="${message}"></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</main>
|
||||||
<script src="/js/uploadPhoto.js"></script>
|
|
||||||
|
<!-- 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>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||