diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java index a420ae3a..00ea62bf 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java @@ -6,6 +6,7 @@ import com.adityachandel.booklore.config.security.filter.DualJwtAuthenticationFi import com.adityachandel.booklore.config.security.filter.KoboAuthFilter; import com.adityachandel.booklore.config.security.filter.KoreaderAuthFilter; import com.adityachandel.booklore.config.security.service.OpdsUserDetailsService; +import jakarta.servlet.http.HttpServletResponse; import lombok.AllArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -29,8 +30,6 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import jakarta.servlet.http.HttpServletResponse; - @AllArgsConstructor @EnableMethodSecurity @Configuration diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMediaController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMediaController.java index c5778aea..31ec5844 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMediaController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMediaController.java @@ -4,6 +4,7 @@ import com.adityachandel.booklore.service.book.BookService; import com.adityachandel.booklore.service.bookdrop.BookDropService; import com.adityachandel.booklore.service.reader.CbxReaderService; import com.adityachandel.booklore.service.reader.PdfReaderService; +import com.adityachandel.booklore.service.IconService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -36,6 +37,7 @@ public class BookMediaController { private final PdfReaderService pdfReaderService; private final CbxReaderService cbxReaderService; private final BookDropService bookDropService; + private final IconService iconService; @Operation(summary = "Get book thumbnail", description = "Retrieve the thumbnail image for a specific book.") @ApiResponse(responseCode = "200", description = "Book thumbnail returned successfully") @@ -117,4 +119,14 @@ public class BookMediaController { return ResponseEntity.internalServerError().build(); } } + + @Operation(summary = "Get SVG icon", description = "Retrieve an SVG icon by its name.") + @ApiResponse(responseCode = "200", description = "SVG icon returned successfully") + @GetMapping("/icon/{name}") + public ResponseEntity getSvgIcon(@Parameter(description = "Name of the icon") @PathVariable String name) { + String svgData = iconService.getSvgIcon(name); + return ResponseEntity.ok() + .contentType(MediaType.valueOf("image/svg+xml")) + .body(svgData); + } } \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/IconController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/IconController.java new file mode 100644 index 00000000..03799c06 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/IconController.java @@ -0,0 +1,48 @@ +package com.adityachandel.booklore.controller; + +import com.adityachandel.booklore.model.dto.request.SvgIconCreateRequest; +import com.adityachandel.booklore.service.IconService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Icons", description = "Endpoints for managing SVG icons") +@AllArgsConstructor +@RestController +@RequestMapping("/api/v1/icons") +public class IconController { + + private final IconService iconService; + + @Operation(summary = "Save an SVG icon", description = "Saves an SVG icon to the system.") + @ApiResponse(responseCode = "200", description = "SVG icon saved successfully") + @PostMapping + public ResponseEntity saveSvgIcon(@Valid @RequestBody SvgIconCreateRequest svgIconCreateRequest) { + iconService.saveSvgIcon(svgIconCreateRequest); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "Get paginated icon names", description = "Retrieve a paginated list of icon names (default 50 per page).") + @ApiResponse(responseCode = "200", description = "Icon names retrieved successfully") + @GetMapping + public ResponseEntity> getIconNames( + @Parameter(description = "Page number") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "Page size") @RequestParam(defaultValue = "50") int size) { + Page response = iconService.getIconNames(page, size); + return ResponseEntity.ok(response); + } + + @Operation(summary = "Delete an SVG icon", description = "Deletes an SVG icon by its name.") + @ApiResponse(responseCode = "200", description = "SVG icon deleted successfully") + @DeleteMapping("/{svgName}") + public ResponseEntity deleteSvgIcon(@Parameter(description = "SVG icon name") @PathVariable String svgName) { + iconService.deleteSvgIcon(svgName); + return ResponseEntity.ok().build(); + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/LibraryController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/LibraryController.java index 36fa6ff2..35b8eb5c 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/LibraryController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/LibraryController.java @@ -13,6 +13,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.AllArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -35,8 +36,8 @@ public class LibraryController { @Operation(summary = "Get a library by ID", description = "Retrieve details of a specific library by its ID.") @ApiResponses({ - @ApiResponse(responseCode = "200", description = "Library details returned successfully"), - @ApiResponse(responseCode = "404", description = "Library not found") + @ApiResponse(responseCode = "200", description = "Library details returned successfully"), + @ApiResponse(responseCode = "404", description = "Library not found") }) @GetMapping("/{libraryId}") @CheckLibraryAccess(libraryIdParam = "libraryId") @@ -50,7 +51,7 @@ public class LibraryController { @PostMapping @PreAuthorize("@securityUtil.canManipulateLibrary() or @securityUtil.isAdmin()") public ResponseEntity createLibrary( - @Parameter(description = "Library creation request") @RequestBody CreateLibraryRequest request) { + @Parameter(description = "Library creation request") @Validated @RequestBody CreateLibraryRequest request) { return ResponseEntity.ok(libraryService.createLibrary(request)); } @@ -60,7 +61,7 @@ public class LibraryController { @CheckLibraryAccess(libraryIdParam = "libraryId") @PreAuthorize("@securityUtil.canManipulateLibrary() or @securityUtil.isAdmin()") public ResponseEntity updateLibrary( - @Parameter(description = "Library update request") @RequestBody CreateLibraryRequest request, + @Parameter(description = "Library update request") @Validated @RequestBody CreateLibraryRequest request, @Parameter(description = "ID of the library") @PathVariable Long libraryId) { return ResponseEntity.ok(libraryService.updateLibrary(request, libraryId)); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java b/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java index 84052fa6..1ecca03e 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java @@ -54,7 +54,8 @@ public enum ApiError { FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "File not found: %s"), SHELF_CANNOT_BE_DELETED(HttpStatus.FORBIDDEN, "'%s' shelf can't be deleted" ), TASK_NOT_FOUND(HttpStatus.NOT_FOUND, "Scheduled task not found: %s"), - TASK_ALREADY_RUNNING(HttpStatus.CONFLICT, "Task is already running: %s"),; + TASK_ALREADY_RUNNING(HttpStatus.CONFLICT, "Task is already running: %s"), + ICON_ALREADY_EXISTS(HttpStatus.CONFLICT, "SVG icon with name '%s' already exists"),; private final HttpStatus status; private final String message; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/Library.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/Library.java index d4aa3520..d930f78e 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/Library.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/Library.java @@ -1,6 +1,7 @@ package com.adityachandel.booklore.model.dto; import com.adityachandel.booklore.model.enums.BookFileType; +import com.adityachandel.booklore.model.enums.IconType; import com.adityachandel.booklore.model.enums.LibraryScanMode; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.Builder; @@ -16,6 +17,7 @@ public class Library { private String name; private Sort sort; private String icon; + private IconType iconType; private String fileNamingPattern; private boolean watch; private List paths; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/MagicShelf.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/MagicShelf.java index 3fec3207..89dd490c 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/MagicShelf.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/MagicShelf.java @@ -1,5 +1,6 @@ package com.adityachandel.booklore.model.dto; +import com.adityachandel.booklore.model.enums.IconType; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @@ -18,6 +19,8 @@ public class MagicShelf { @Size(max = 64, message = "Icon must not exceed 64 characters") private String icon; + private IconType iconType; + @NotNull(message = "Filter JSON must not be null") @Size(min = 2, message = "Filter JSON must not be empty") private String filterJson; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/Shelf.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/Shelf.java index 4a61f232..33d18c63 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/Shelf.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/Shelf.java @@ -1,5 +1,6 @@ package com.adityachandel.booklore.model.dto; +import com.adityachandel.booklore.model.enums.IconType; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.Builder; import lombok.Data; @@ -11,6 +12,7 @@ public class Shelf { private Long id; private String name; private String icon; + private IconType iconType; private Sort sort; private Long userId; } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/CreateLibraryRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/CreateLibraryRequest.java index 434cc391..16765402 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/CreateLibraryRequest.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/CreateLibraryRequest.java @@ -2,10 +2,12 @@ package com.adityachandel.booklore.model.dto.request; import com.adityachandel.booklore.model.dto.LibraryPath; import com.adityachandel.booklore.model.enums.BookFileType; +import com.adityachandel.booklore.model.enums.IconType; import com.adityachandel.booklore.model.enums.LibraryScanMode; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; import lombok.Builder; import lombok.Data; @@ -15,12 +17,18 @@ import java.util.List; @Builder @JsonIgnoreProperties(ignoreUnknown = true) public class CreateLibraryRequest { - @NotBlank + @NotBlank(message = "Library name must not be empty.") private String name; - @NotBlank + + @NotBlank(message = "Library icon must not be empty.") private String icon; - @NotEmpty + + @NotNull(message = "Library icon type must not be null.") + private IconType iconType; + + @NotEmpty(message = "Library paths must not be empty.") private List paths; + private boolean watch; private LibraryScanMode scanMode; private BookFileType defaultBookFormat; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/ShelfCreateRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/ShelfCreateRequest.java index 09b2ad36..b31c2943 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/ShelfCreateRequest.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/ShelfCreateRequest.java @@ -1,6 +1,8 @@ package com.adityachandel.booklore.model.dto.request; +import com.adityachandel.booklore.model.enums.IconType; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Null; import lombok.Builder; import lombok.Data; @@ -8,7 +10,6 @@ import lombok.Data; @Builder @Data public class ShelfCreateRequest { - @Null(message = "Id should be null for creation.") private Long id; @@ -17,4 +18,7 @@ public class ShelfCreateRequest { @NotBlank(message = "Shelf icon must not be empty.") private String icon; + + @NotNull(message = "Shelf icon type must not be null.") + private IconType iconType; } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/SvgIconCreateRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/SvgIconCreateRequest.java new file mode 100644 index 00000000..9e05c9d3 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/SvgIconCreateRequest.java @@ -0,0 +1,18 @@ +package com.adityachandel.booklore.model.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class SvgIconCreateRequest { + @NotBlank(message = "SVG name is required") + @Size(min = 1, max = 255, message = "SVG name must be between 1 and 255 characters") + @Pattern(regexp = "^[a-zA-Z0-9-]+$", message = "SVG name can only contain alphanumeric characters and hyphens") + private String svgName; + + @NotBlank(message = "SVG data is required") + @Size(max = 1048576, message = "SVG data must not exceed 1MB") + private String svgData; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/LibraryEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/LibraryEntity.java index 5d6578fa..b8a79956 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/LibraryEntity.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/LibraryEntity.java @@ -3,6 +3,7 @@ package com.adityachandel.booklore.model.entity; import com.adityachandel.booklore.convertor.SortConverter; import com.adityachandel.booklore.model.dto.Sort; import com.adityachandel.booklore.model.enums.BookFileType; +import com.adityachandel.booklore.model.enums.IconType; import com.adityachandel.booklore.model.enums.LibraryScanMode; import jakarta.persistence.*; import lombok.*; @@ -40,6 +41,10 @@ public class LibraryEntity { private String icon; + @Enumerated(EnumType.STRING) + @Column(name = "icon_type", nullable = false) + private IconType iconType; + @Column(name = "file_naming_pattern") private String fileNamingPattern; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/MagicShelfEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/MagicShelfEntity.java index 676baa43..a8b3f146 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/MagicShelfEntity.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/MagicShelfEntity.java @@ -1,5 +1,6 @@ package com.adityachandel.booklore.model.entity; +import com.adityachandel.booklore.model.enums.IconType; import jakarta.persistence.*; import lombok.*; @@ -29,6 +30,10 @@ public class MagicShelfEntity { @Column(nullable = false) private String icon; + @Enumerated(EnumType.STRING) + @Column(name = "icon_type", nullable = false) + private IconType iconType; + @Column(name = "filter_json", columnDefinition = "json", nullable = false) private String filterJson; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/ShelfEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/ShelfEntity.java index 649c54bc..c08a200d 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/ShelfEntity.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/ShelfEntity.java @@ -2,6 +2,7 @@ package com.adityachandel.booklore.model.entity; import com.adityachandel.booklore.convertor.SortConverter; import com.adityachandel.booklore.model.dto.Sort; +import com.adityachandel.booklore.model.enums.IconType; import jakarta.persistence.*; import lombok.*; @@ -34,6 +35,10 @@ public class ShelfEntity { private String icon; + @Enumerated(EnumType.STRING) + @Column(name = "icon_type", nullable = false) + private IconType iconType; + @ManyToMany(fetch = FetchType.LAZY) @JoinTable( name = "book_shelf_mapping", diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/IconType.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/IconType.java new file mode 100644 index 00000000..c40b9a1b --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/IconType.java @@ -0,0 +1,7 @@ +package com.adityachandel.booklore.model.enums; + +public enum IconType { + PRIME_NG, + CUSTOM_SVG +} + diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/IconService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/IconService.java new file mode 100644 index 00000000..dc19db33 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/IconService.java @@ -0,0 +1,232 @@ +package com.adityachandel.booklore.service; + +import com.adityachandel.booklore.config.AppProperties; +import com.adityachandel.booklore.exception.ApiError; +import com.adityachandel.booklore.model.dto.request.SvgIconCreateRequest; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Stream; + +@Slf4j +@RequiredArgsConstructor +@Service +public class IconService { + + private final AppProperties appProperties; + + private final ConcurrentHashMap svgCache = new ConcurrentHashMap<>(); + + private static final String ICONS_DIR = "icons"; + private static final String SVG_DIR = "svg"; + private static final String SVG_EXTENSION = ".svg"; + private static final int MAX_CACHE_SIZE = 1000; + private static final String SVG_START_TAG = " paths = Files.list(iconsPath)) { + paths.filter(Files::isRegularFile) + .filter(path -> path.toString().endsWith(SVG_EXTENSION)) + .limit(MAX_CACHE_SIZE) + .forEach(path -> { + try { + String filename = path.getFileName().toString(); + String content = Files.readString(path); + svgCache.put(filename, content); + } catch (IOException e) { + log.warn("Failed to load icon: {}", path.getFileName(), e); + } + }); + } + } + + public void saveSvgIcon(SvgIconCreateRequest request) { + validateSvgData(request.getSvgData()); + + String filename = normalizeFilename(request.getSvgName()); + Path filePath = getIconsSvgPath().resolve(filename); + + if (Files.exists(filePath)) { + log.warn("SVG icon already exists: {}", filename); + throw ApiError.ICON_ALREADY_EXISTS.createException(request.getSvgName()); + } + + try { + Files.writeString(filePath, request.getSvgData(), + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING); + + updateCache(filename, request.getSvgData()); + + log.info("SVG icon saved successfully: {}", filename); + } catch (IOException e) { + log.error("Failed to save SVG icon: {}", e.getMessage(), e); + throw ApiError.FILE_READ_ERROR.createException("Failed to save SVG icon: " + e.getMessage()); + } + } + + public String getSvgIcon(String name) { + String filename = normalizeFilename(name); + String cachedSvg = svgCache.get(filename); + + if (cachedSvg != null) { + return cachedSvg; + } + return loadAndCacheIcon(filename, name); + } + + private String loadAndCacheIcon(String filename, String originalName) { + Path filePath = getIconsSvgPath().resolve(filename); + + if (!Files.exists(filePath)) { + log.warn("SVG icon not found: {}", filename); + throw ApiError.FILE_NOT_FOUND.createException("SVG icon not found: " + originalName); + } + + try { + String svgData = Files.readString(filePath); + updateCache(filename, svgData); + return svgData; + } catch (IOException e) { + log.error("Failed to read SVG icon: {}", e.getMessage(), e); + throw ApiError.FILE_READ_ERROR.createException("Failed to read SVG icon: " + e.getMessage()); + } + } + + public void deleteSvgIcon(String svgName) { + String filename = normalizeFilename(svgName); + Path filePath = getIconsSvgPath().resolve(filename); + + try { + if (!Files.exists(filePath)) { + log.warn("SVG icon not found for deletion: {}", filename); + throw ApiError.FILE_NOT_FOUND.createException("SVG icon not found: " + svgName); + } + + Files.delete(filePath); + svgCache.remove(filename); + + log.info("SVG icon deleted successfully: {}", filename); + } catch (IOException e) { + log.error("Failed to delete SVG icon: {}", e.getMessage(), e); + throw ApiError.FILE_READ_ERROR.createException("Failed to delete SVG icon: " + e.getMessage()); + } + } + + public Page getIconNames(int page, int size) { + validatePaginationParams(page, size); + + Path iconsPath = getIconsSvgPath(); + + if (!Files.exists(iconsPath)) { + return new PageImpl<>(Collections.emptyList(), PageRequest.of(page, size), 0); + } + + try (Stream paths = Files.list(iconsPath)) { + List allIcons = paths + .filter(Files::isRegularFile) + .filter(path -> path.toString().endsWith(SVG_EXTENSION)) + .map(path -> path.getFileName().toString().replace(SVG_EXTENSION, "")) + .sorted() + .toList(); + + return createPage(allIcons, page, size); + } catch (IOException e) { + log.error("Failed to read icon names: {}", e.getMessage(), e); + throw ApiError.FILE_READ_ERROR.createException("Failed to read icon names: " + e.getMessage()); + } + } + + private Page createPage(List allIcons, int page, int size) { + int totalElements = allIcons.size(); + int fromIndex = page * size; + int toIndex = Math.min(fromIndex + size, totalElements); + + List pageContent = fromIndex < totalElements + ? allIcons.subList(fromIndex, toIndex) + : Collections.emptyList(); + + return new PageImpl<>(pageContent, PageRequest.of(page, size), totalElements); + } + + private void updateCache(String filename, String content) { + if (!svgCache.containsKey(filename) && svgCache.size() >= MAX_CACHE_SIZE) { + String firstKey = svgCache.keys().nextElement(); + svgCache.remove(firstKey); + } + svgCache.put(filename, content); + } + + private Path getIconsSvgPath() { + return Paths.get(appProperties.getPathConfig(), ICONS_DIR, SVG_DIR); + } + + private void validateSvgData(String svgData) { + if (svgData == null || svgData.isBlank()) { + throw ApiError.INVALID_INPUT.createException("SVG data cannot be empty"); + } + + String trimmed = svgData.trim(); + if (!trimmed.startsWith(SVG_START_TAG) && !trimmed.startsWith(XML_DECLARATION)) { + throw ApiError.INVALID_INPUT.createException("Invalid SVG format: must start with tag"); + } + } + + private void validatePaginationParams(int page, int size) { + if (page < 0) { + throw ApiError.INVALID_INPUT.createException("Page index must not be less than zero"); + } + if (size < 1) { + throw ApiError.INVALID_INPUT.createException("Page size must not be less than one"); + } + } + + private String normalizeFilename(String filename) { + if (filename == null || filename.isBlank()) { + throw ApiError.INVALID_INPUT.createException("Filename cannot be empty"); + } + + String sanitized = filename.trim().replaceAll("[^a-zA-Z0-9._-]", "_"); + return sanitized.endsWith(SVG_EXTENSION) ? sanitized : sanitized + SVG_EXTENSION; + } + + ConcurrentHashMap getSvgCache() { + return svgCache; + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/MagicShelfService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/MagicShelfService.java index 696fddbf..2fce2989 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/MagicShelfService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/MagicShelfService.java @@ -56,6 +56,7 @@ public class MagicShelfService { } existing.setName(dto.getName()); existing.setIcon(dto.getIcon()); + existing.setIconType(dto.getIconType()); existing.setFilterJson(dto.getFilterJson()); existing.setPublic(dto.getIsPublic()); return toDto(magicShelfRepository.save(existing)); @@ -81,6 +82,7 @@ public class MagicShelfService { dto.setId(entity.getId()); dto.setName(entity.getName()); dto.setIcon(entity.getIcon()); + dto.setIconType(entity.getIconType()); dto.setFilterJson(entity.getFilterJson()); dto.setIsPublic(entity.isPublic()); return dto; @@ -91,6 +93,7 @@ public class MagicShelfService { entity.setId(dto.getId()); entity.setName(dto.getName()); entity.setIcon(dto.getIcon()); + entity.setIconType(dto.getIconType()); entity.setFilterJson(dto.getFilterJson()); entity.setPublic(dto.getIsPublic()); entity.setUserId(userId); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/ShelfService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/ShelfService.java index d9a4d134..416620ca 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/ShelfService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/ShelfService.java @@ -40,6 +40,7 @@ public class ShelfService { ShelfEntity shelfEntity = ShelfEntity.builder() .icon(request.getIcon()) .name(request.getName()) + .iconType(request.getIconType()) .user(fetchUserEntityById(userId)) .build(); return shelfMapper.toShelf(shelfRepository.save(shelfEntity)); @@ -49,6 +50,7 @@ public class ShelfService { ShelfEntity shelfEntity = findShelfByIdOrThrow(id); shelfEntity.setName(request.getName()); shelfEntity.setIcon(request.getIcon()); + shelfEntity.setIconType(request.getIconType()); return shelfMapper.toShelf(shelfRepository.save(shelfEntity)); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/library/LibraryService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/library/LibraryService.java index 3417d114..7ba27a1a 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/library/LibraryService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/library/LibraryService.java @@ -71,6 +71,7 @@ public class LibraryService { library.setName(request.getName()); library.setIcon(request.getIcon()); + library.setIconType(request.getIconType()); library.setWatch(request.isWatch()); if (request.getScanMode() != null) { library.setScanMode(request.getScanMode()); @@ -149,6 +150,7 @@ public class LibraryService { .collect(Collectors.toList()) ) .icon(request.getIcon()) + .iconType(request.getIconType()) .watch(request.isWatch()) .scanMode(request.getScanMode() != null ? request.getScanMode() : LibraryScanMode.FILE_AS_BOOK) .defaultBookFormat(request.getDefaultBookFormat()) diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/AppMigrationService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/AppMigrationService.java index a0631db9..351482fe 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/AppMigrationService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/AppMigrationService.java @@ -13,6 +13,8 @@ import com.adityachandel.booklore.util.FileUtils; import jakarta.transaction.Transactional; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import org.springframework.stereotype.Service; import javax.imageio.ImageIO; @@ -23,6 +25,7 @@ import java.io.UncheckedIOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; import java.time.LocalDateTime; import java.util.Comparator; import java.util.List; @@ -194,4 +197,51 @@ public class AppMigrationService { log.info("Completed migration: populateCoversAndResizeThumbnails in {} ms", elapsedMs); } + @Transactional + public void moveIconsToDataFolder() { + if (migrationRepository.existsById("moveIconsToDataFolder")) return; + + long start = System.nanoTime(); + log.info("Starting migration: moveIconsToDataFolder"); + + try { + String targetFolder = fileService.getIconsSvgFolder(); + Path targetDir = Paths.get(targetFolder); + Files.createDirectories(targetDir); + + PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); + Resource[] resources = resolver.getResources("classpath:static/images/icons/svg/*.svg"); + + int copiedCount = 0; + for (Resource resource : resources) { + String filename = resource.getFilename(); + if (filename == null) continue; + + Path targetFile = targetDir.resolve(filename); + + try (var inputStream = resource.getInputStream()) { + Files.copy(inputStream, targetFile, StandardCopyOption.REPLACE_EXISTING); + copiedCount++; + log.debug("Copied icon: {} to {}", filename, targetFile); + } catch (IOException e) { + log.error("Failed to copy icon: {}", filename, e); + } + } + + log.info("Copied {} SVG icons from resources to data folder", copiedCount); + + migrationRepository.save(new AppMigrationEntity( + "moveIconsToDataFolder", + LocalDateTime.now(), + "Move SVG icons from resources/static/images/icons/svg to data/icons/svg" + )); + + long elapsedMs = (System.nanoTime() - start) / 1_000_000; + log.info("Completed migration: moveIconsToDataFolder in {} ms", elapsedMs); + } catch (IOException e) { + log.error("Error during migration moveIconsToDataFolder", e); + throw new UncheckedIOException(e); + } + } + } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/AppMigrationStartup.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/AppMigrationStartup.java index 9e81bc87..c7f78ec5 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/AppMigrationStartup.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/AppMigrationStartup.java @@ -17,5 +17,6 @@ public class AppMigrationStartup { appMigrationService.populateMetadataScoresOnce(); appMigrationService.populateFileHashesOnce(); appMigrationService.populateCoversAndResizeThumbnails(); + appMigrationService.moveIconsToDataFolder(); } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/util/FileService.java b/booklore-api/src/main/java/com/adityachandel/booklore/util/FileService.java index bdf9ede0..bfbf7d33 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/util/FileService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/util/FileService.java @@ -42,6 +42,8 @@ public class FileService { // @formatter:off private static final String IMAGES_DIR = "images"; private static final String BACKGROUNDS_DIR = "backgrounds"; + private static final String ICONS_DIR = "icons"; + private static final String SVG_DIR = "svg"; private static final String THUMBNAIL_FILENAME = "thumbnail.jpg"; private static final String COVER_FILENAME = "cover.jpg"; private static final String JPEG_MIME_TYPE = "image/jpeg"; @@ -401,6 +403,10 @@ public class FileService { return new ClassPathResource("static/images/background.jpg"); } + public String getIconsSvgFolder() { + return Paths.get(appProperties.getPathConfig(), ICONS_DIR, SVG_DIR).toString(); + } + // ======================================== // UTILITY METHODS // ======================================== diff --git a/booklore-api/src/main/resources/db/migration/V66__Add_icon_type_to_entities.sql b/booklore-api/src/main/resources/db/migration/V66__Add_icon_type_to_entities.sql new file mode 100644 index 00000000..6fb7520e --- /dev/null +++ b/booklore-api/src/main/resources/db/migration/V66__Add_icon_type_to_entities.sql @@ -0,0 +1,13 @@ +ALTER TABLE library + ADD COLUMN IF NOT EXISTS icon_type VARCHAR(100) NOT NULL DEFAULT 'PRIME_NG'; + +ALTER TABLE magic_shelf + ADD COLUMN IF NOT EXISTS icon_type VARCHAR(100) NOT NULL DEFAULT 'PRIME_NG'; + +ALTER TABLE shelf + ADD COLUMN IF NOT EXISTS icon_type VARCHAR(100) NOT NULL DEFAULT 'PRIME_NG'; + +UPDATE library SET icon_type = 'PRIME_NG' WHERE icon_type IS NULL; +UPDATE magic_shelf SET icon_type = 'PRIME_NG' WHERE icon_type IS NULL; +UPDATE shelf SET icon_type = 'PRIME_NG' WHERE icon_type IS NULL; + diff --git a/booklore-api/src/main/resources/static/images/icons/svg/atom.svg b/booklore-api/src/main/resources/static/images/icons/svg/atom.svg new file mode 100644 index 00000000..8b33eb0e --- /dev/null +++ b/booklore-api/src/main/resources/static/images/icons/svg/atom.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/booklore-api/src/main/resources/static/images/icons/svg/banana.svg b/booklore-api/src/main/resources/static/images/icons/svg/banana.svg new file mode 100644 index 00000000..56d39cf6 --- /dev/null +++ b/booklore-api/src/main/resources/static/images/icons/svg/banana.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/booklore-api/src/main/resources/static/images/icons/svg/beef.svg b/booklore-api/src/main/resources/static/images/icons/svg/beef.svg new file mode 100644 index 00000000..c5ae7085 --- /dev/null +++ b/booklore-api/src/main/resources/static/images/icons/svg/beef.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/booklore-api/src/main/resources/static/images/icons/svg/brain.svg b/booklore-api/src/main/resources/static/images/icons/svg/brain.svg new file mode 100644 index 00000000..259ac478 --- /dev/null +++ b/booklore-api/src/main/resources/static/images/icons/svg/brain.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/booklore-api/src/main/resources/static/images/icons/svg/chef-hat.svg b/booklore-api/src/main/resources/static/images/icons/svg/chef-hat.svg new file mode 100644 index 00000000..fdce6c49 --- /dev/null +++ b/booklore-api/src/main/resources/static/images/icons/svg/chef-hat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/booklore-api/src/main/resources/static/images/icons/svg/drama.svg b/booklore-api/src/main/resources/static/images/icons/svg/drama.svg new file mode 100644 index 00000000..0797b6f0 --- /dev/null +++ b/booklore-api/src/main/resources/static/images/icons/svg/drama.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/booklore-api/src/main/resources/static/images/icons/svg/ferris-wheel.svg b/booklore-api/src/main/resources/static/images/icons/svg/ferris-wheel.svg new file mode 100644 index 00000000..15dc12b8 --- /dev/null +++ b/booklore-api/src/main/resources/static/images/icons/svg/ferris-wheel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/booklore-api/src/main/resources/static/images/icons/svg/flame-kindling.svg b/booklore-api/src/main/resources/static/images/icons/svg/flame-kindling.svg new file mode 100644 index 00000000..66a71ca6 --- /dev/null +++ b/booklore-api/src/main/resources/static/images/icons/svg/flame-kindling.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/booklore-api/src/main/resources/static/images/icons/svg/ghost.svg b/booklore-api/src/main/resources/static/images/icons/svg/ghost.svg new file mode 100644 index 00000000..9d8d0df8 --- /dev/null +++ b/booklore-api/src/main/resources/static/images/icons/svg/ghost.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/booklore-api/src/main/resources/static/images/icons/svg/hamburger.svg b/booklore-api/src/main/resources/static/images/icons/svg/hamburger.svg new file mode 100644 index 00000000..26cf5073 --- /dev/null +++ b/booklore-api/src/main/resources/static/images/icons/svg/hamburger.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/booklore-api/src/main/resources/static/images/icons/svg/plane.svg b/booklore-api/src/main/resources/static/images/icons/svg/plane.svg new file mode 100644 index 00000000..f036eb68 --- /dev/null +++ b/booklore-api/src/main/resources/static/images/icons/svg/plane.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/booklore-api/src/main/resources/static/images/icons/svg/rocket.svg b/booklore-api/src/main/resources/static/images/icons/svg/rocket.svg new file mode 100644 index 00000000..14fb3aab --- /dev/null +++ b/booklore-api/src/main/resources/static/images/icons/svg/rocket.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/booklore-api/src/main/resources/static/images/icons/svg/roller-coaster.svg b/booklore-api/src/main/resources/static/images/icons/svg/roller-coaster.svg new file mode 100644 index 00000000..e3ed9bfe --- /dev/null +++ b/booklore-api/src/main/resources/static/images/icons/svg/roller-coaster.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/booklore-api/src/main/resources/static/images/icons/svg/rose.svg b/booklore-api/src/main/resources/static/images/icons/svg/rose.svg new file mode 100644 index 00000000..450ccd7c --- /dev/null +++ b/booklore-api/src/main/resources/static/images/icons/svg/rose.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/booklore-api/src/main/resources/static/images/icons/svg/skull.svg b/booklore-api/src/main/resources/static/images/icons/svg/skull.svg new file mode 100644 index 00000000..19958d8f --- /dev/null +++ b/booklore-api/src/main/resources/static/images/icons/svg/skull.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/booklore-api/src/main/resources/static/images/icons/svg/snail.svg b/booklore-api/src/main/resources/static/images/icons/svg/snail.svg new file mode 100644 index 00000000..03b50a90 --- /dev/null +++ b/booklore-api/src/main/resources/static/images/icons/svg/snail.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/booklore-api/src/main/resources/static/images/icons/svg/swords.svg b/booklore-api/src/main/resources/static/images/icons/svg/swords.svg new file mode 100644 index 00000000..832b82ba --- /dev/null +++ b/booklore-api/src/main/resources/static/images/icons/svg/swords.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/booklore-api/src/main/resources/static/images/icons/svg/tent-tree.svg b/booklore-api/src/main/resources/static/images/icons/svg/tent-tree.svg new file mode 100644 index 00000000..239f87f9 --- /dev/null +++ b/booklore-api/src/main/resources/static/images/icons/svg/tent-tree.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/booklore-api/src/main/resources/static/images/icons/svg/tree-palm.svg b/booklore-api/src/main/resources/static/images/icons/svg/tree-palm.svg new file mode 100644 index 00000000..edc54297 --- /dev/null +++ b/booklore-api/src/main/resources/static/images/icons/svg/tree-palm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/booklore-api/src/main/resources/static/images/icons/svg/turntable.svg b/booklore-api/src/main/resources/static/images/icons/svg/turntable.svg new file mode 100644 index 00000000..5ed52b5a --- /dev/null +++ b/booklore-api/src/main/resources/static/images/icons/svg/turntable.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/IconServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/IconServiceTest.java new file mode 100644 index 00000000..4aac2571 --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/IconServiceTest.java @@ -0,0 +1,194 @@ +package com.adityachandel.booklore.service; + +import com.adityachandel.booklore.config.AppProperties; +import com.adityachandel.booklore.exception.APIException; +import com.adityachandel.booklore.model.dto.request.SvgIconCreateRequest; +import org.junit.jupiter.api.*; +import org.springframework.data.domain.Page; + +import java.io.IOException; +import java.nio.file.*; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class IconServiceTest { + + private static final String TEST_DIR = "test-icons"; + private static final String SVG_DIR = "svg"; + private static final String SVG_NAME = "testicon"; + private static final String SVG_DATA = ""; + private static final String SVG_DATA_XML = ""; + private static final String INVALID_SVG_DATA = ""; + private static final String INVALID_SVG_DATA_NO_END = ""; + + private IconService iconService; + private Path iconsSvgPath; + + @BeforeAll + void setup() throws IOException { + AppProperties appProperties = new AppProperties() { + @Override + public String getPathConfig() { + return TEST_DIR; + } + }; + iconService = new IconService(appProperties); + iconsSvgPath = Paths.get(TEST_DIR, "icons", SVG_DIR); + Files.createDirectories(iconsSvgPath); + iconService.init(); + } + + @AfterEach + void cleanup() throws IOException { + try (DirectoryStream stream = Files.newDirectoryStream(iconsSvgPath)) { + for (Path entry : stream) { + Files.deleteIfExists(entry); + } + } + iconService.getSvgCache().clear(); + } + + @AfterAll + void teardown() throws IOException { + if (Files.exists(iconsSvgPath)) { + try (DirectoryStream stream = Files.newDirectoryStream(iconsSvgPath)) { + for (Path entry : stream) { + Files.deleteIfExists(entry); + } + } + Files.deleteIfExists(iconsSvgPath); + Files.deleteIfExists(iconsSvgPath.getParent()); + Files.deleteIfExists(Paths.get(TEST_DIR)); + } + } + + @Test + void saveSvgIcon_validSvg_savesFileAndCaches() { + SvgIconCreateRequest req = new SvgIconCreateRequest(); + req.setSvgName(SVG_NAME); + req.setSvgData(SVG_DATA); + iconService.saveSvgIcon(req); + Path filePath = iconsSvgPath.resolve(SVG_NAME + ".svg"); + assertTrue(Files.exists(filePath)); + assertEquals(SVG_DATA, iconService.getSvgIcon(SVG_NAME)); + } + + @Test + void saveSvgIcon_validXmlSvg_savesFileAndCaches() { + SvgIconCreateRequest req = new SvgIconCreateRequest(); + req.setSvgName(SVG_NAME); + req.setSvgData(SVG_DATA_XML); + iconService.saveSvgIcon(req); + Path filePath = iconsSvgPath.resolve(SVG_NAME + ".svg"); + assertTrue(Files.exists(filePath)); + assertEquals(SVG_DATA_XML, iconService.getSvgIcon(SVG_NAME)); + } + + @Test + void saveSvgIcon_invalidSvg_throwsException() { + SvgIconCreateRequest req = new SvgIconCreateRequest(); + req.setSvgName(SVG_NAME); + req.setSvgData(INVALID_SVG_DATA); + assertThrows(APIException.class, () -> iconService.saveSvgIcon(req)); + } + + @Test + void saveSvgIcon_missingEndTag_throwsException() { + SvgIconCreateRequest req = new SvgIconCreateRequest(); + req.setSvgName(SVG_NAME); + req.setSvgData(INVALID_SVG_DATA_NO_END); + assertThrows(APIException.class, () -> iconService.saveSvgIcon(req)); + } + + @Test + void getSvgIcon_existing_returnsContent() { + SvgIconCreateRequest req = new SvgIconCreateRequest(); + req.setSvgName(SVG_NAME); + req.setSvgData(SVG_DATA); + iconService.saveSvgIcon(req); + String svg = iconService.getSvgIcon(SVG_NAME); + assertEquals(SVG_DATA, svg); + } + + @Test + void getSvgIcon_nonExisting_throwsException() { + assertThrows(APIException.class, () -> iconService.getSvgIcon("nonexistent")); + } + + @Test + void deleteSvgIcon_existing_deletesFileAndCache() { + SvgIconCreateRequest req = new SvgIconCreateRequest(); + req.setSvgName(SVG_NAME); + req.setSvgData(SVG_DATA); + iconService.saveSvgIcon(req); + iconService.deleteSvgIcon(SVG_NAME); + Path filePath = iconsSvgPath.resolve(SVG_NAME + ".svg"); + assertFalse(Files.exists(filePath)); + assertThrows(APIException.class, () -> iconService.getSvgIcon(SVG_NAME)); + } + + @Test + void deleteSvgIcon_nonExisting_throwsException() { + assertThrows(APIException.class, () -> iconService.deleteSvgIcon("nonexistent")); + } + + @Test + void getIconNames_pagination_returnsCorrectPage() { + for (int i = 0; i < 5; i++) { + SvgIconCreateRequest req = new SvgIconCreateRequest(); + req.setSvgName("icon" + i); + req.setSvgData(SVG_DATA); + iconService.saveSvgIcon(req); + } + Page page = iconService.getIconNames(0, 2); + assertEquals(2, page.getContent().size()); + assertEquals(5, page.getTotalElements()); + assertEquals(List.of("icon0", "icon1"), page.getContent()); + Page page2 = iconService.getIconNames(2, 2); + assertEquals(1, page2.getContent().size()); + assertEquals(List.of("icon4"), page2.getContent()); + } + + @Test + void getIconNames_invalidPageParams_throwsException() { + assertThrows(APIException.class, () -> iconService.getIconNames(-1, 2)); + assertThrows(APIException.class, () -> iconService.getIconNames(0, 0)); + } + + @Test + void normalizeFilename_invalid_throwsException() { + assertThrows(APIException.class, () -> iconService.getSvgIcon("")); + assertThrows(APIException.class, () -> iconService.getSvgIcon(null)); + } + + @Test + void cacheEviction_worksWhenMaxSizeExceeded() { + iconService.getSvgCache().clear(); + + for (int i = 0; i < 1002; i++) { + SvgIconCreateRequest req = new SvgIconCreateRequest(); + req.setSvgName("icon" + i); + req.setSvgData(SVG_DATA); + iconService.saveSvgIcon(req); + } + assertEquals(1002, iconService.getIconNames(0, 2000).getContent().size()); + assertTrue(iconService.getSvgCache().size() <= 1000); + } + + @Test + void updateExistingIcon_doesNotIncreaseCache() { + SvgIconCreateRequest req = new SvgIconCreateRequest(); + req.setSvgName(SVG_NAME); + req.setSvgData(SVG_DATA); + iconService.saveSvgIcon(req); + + int initialSize = iconService.getSvgCache().size(); + + req.setSvgData(""); + assertThrows(APIException.class, () -> iconService.saveSvgIcon(req)); + + assertEquals(initialSize, iconService.getSvgCache().size()); + } +} diff --git a/booklore-ui/src/app/features/book/components/shelf-creator/shelf-creator.component.html b/booklore-ui/src/app/features/book/components/shelf-creator/shelf-creator.component.html index 5a08e34f..6d21d6d0 100644 --- a/booklore-ui/src/app/features/book/components/shelf-creator/shelf-creator.component.html +++ b/booklore-ui/src/app/features/book/components/shelf-creator/shelf-creator.component.html @@ -60,11 +60,20 @@ } @else {
- +
Selected Icon - {{ selectedIcon }} + + @if (selectedIcon.type === 'PRIME_NG') { + {{ selectedIcon.value }} + } @else { + {{ selectedIcon.value }} + } +
diff --git a/booklore-ui/src/app/features/book/components/shelf-creator/shelf-creator.component.scss b/booklore-ui/src/app/features/book/components/shelf-creator/shelf-creator.component.scss index 247c6999..45f04e7f 100644 --- a/booklore-ui/src/app/features/book/components/shelf-creator/shelf-creator.component.scss +++ b/booklore-ui/src/app/features/book/components/shelf-creator/shelf-creator.component.scss @@ -249,9 +249,19 @@ border-radius: 8px; box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2); - i { - font-size: 1.25rem; + app-icon-display { + display: flex; + align-items: center; + justify-content: center; color: var(--primary-contrast-color); + + ::ng-deep i { + color: var(--primary-contrast-color); + } + + ::ng-deep img { + filter: brightness(0) invert(1); + } } } @@ -281,10 +291,6 @@ .icon-preview { width: 36px; height: 36px; - - i { - font-size: 1.125rem; - } } .icon-info { diff --git a/booklore-ui/src/app/features/book/components/shelf-creator/shelf-creator.component.ts b/booklore-ui/src/app/features/book/components/shelf-creator/shelf-creator.component.ts index 299dc773..f01a2484 100644 --- a/booklore-ui/src/app/features/book/components/shelf-creator/shelf-creator.component.ts +++ b/booklore-ui/src/app/features/book/components/shelf-creator/shelf-creator.component.ts @@ -2,12 +2,13 @@ import {Component, inject} from '@angular/core'; import {DynamicDialogRef} from 'primeng/dynamicdialog'; import {MessageService} from 'primeng/api'; import {ShelfService} from '../../service/shelf.service'; -import {IconPickerService} from '../../../../shared/service/icon-picker.service'; +import {IconPickerService, IconSelection} from '../../../../shared/service/icon-picker.service'; import {Shelf} from '../../model/shelf.model'; import {FormsModule} from '@angular/forms'; import {Button} from 'primeng/button'; import {InputText} from 'primeng/inputtext'; import {Tooltip} from 'primeng/tooltip'; +import {IconDisplayComponent} from '../../../../shared/components/icon-display/icon-display.component'; @Component({ selector: 'app-shelf-creator', @@ -17,7 +18,8 @@ import {Tooltip} from 'primeng/tooltip'; FormsModule, Button, InputText, - Tooltip + Tooltip, + IconDisplayComponent ], styleUrl: './shelf-creator.component.scss', }) @@ -28,24 +30,7 @@ export class ShelfCreatorComponent { private iconPickerService = inject(IconPickerService); shelfName: string = ''; - selectedIcon: string | null = null; - - saveNewShelf(): void { - const newShelf: Partial = { - name: this.shelfName, - icon: this.selectedIcon ? this.selectedIcon.replace('pi pi-', '') : 'heart' - }; - this.shelfService.createShelf(newShelf as Shelf).subscribe({ - next: () => { - this.messageService.add({severity: 'info', summary: 'Success', detail: `Shelf created: ${this.shelfName}`}); - this.dynamicDialogRef.close(true); - }, - error: (e) => { - this.messageService.add({severity: 'error', summary: 'Error', detail: 'Failed to create shelf'}); - console.error('Error creating shelf:', e); - } - }); - } + selectedIcon: IconSelection | null = null; openIconPicker(): void { this.iconPickerService.open().subscribe(icon => { @@ -62,4 +47,26 @@ export class ShelfCreatorComponent { cancel(): void { this.dynamicDialogRef.close(); } + + createShelf(): void { + const iconValue = this.selectedIcon?.value || 'bookmark'; + const iconType = this.selectedIcon?.type || 'PRIME_NG'; + + const newShelf: Partial = { + name: this.shelfName, + icon: iconValue, + iconType: iconType + }; + + this.shelfService.createShelf(newShelf as Shelf).subscribe({ + next: () => { + this.messageService.add({severity: 'info', summary: 'Success', detail: `Shelf created: ${this.shelfName}`}); + this.dynamicDialogRef.close(true); + }, + error: (e) => { + this.messageService.add({severity: 'error', summary: 'Error', detail: 'Failed to create shelf'}); + console.error('Error creating shelf:', e); + } + }); + } } diff --git a/booklore-ui/src/app/features/book/components/shelf-edit-dialog/shelf-edit-dialog.component.html b/booklore-ui/src/app/features/book/components/shelf-edit-dialog/shelf-edit-dialog.component.html index b85c2c68..b9d3c01d 100644 --- a/booklore-ui/src/app/features/book/components/shelf-edit-dialog/shelf-edit-dialog.component.html +++ b/booklore-ui/src/app/features/book/components/shelf-edit-dialog/shelf-edit-dialog.component.html @@ -36,7 +36,11 @@ @if (selectedIcon) {
- +
diff --git a/booklore-ui/src/app/features/book/components/shelf-edit-dialog/shelf-edit-dialog.component.ts b/booklore-ui/src/app/features/book/components/shelf-edit-dialog/shelf-edit-dialog.component.ts index bd72adc9..f11fe2a4 100644 --- a/booklore-ui/src/app/features/book/components/shelf-edit-dialog/shelf-edit-dialog.component.ts +++ b/booklore-ui/src/app/features/book/components/shelf-edit-dialog/shelf-edit-dialog.component.ts @@ -1,13 +1,14 @@ import {Component, inject, OnInit} from '@angular/core'; import {ShelfService} from '../../service/shelf.service'; -import {DialogService, DynamicDialogConfig, DynamicDialogRef} from 'primeng/dynamicdialog'; +import {DynamicDialogConfig, DynamicDialogRef} from 'primeng/dynamicdialog'; import {Button} from 'primeng/button'; import {InputText} from 'primeng/inputtext'; import {FormsModule, ReactiveFormsModule} from '@angular/forms'; import {Shelf} from '../../model/shelf.model'; import {MessageService} from 'primeng/api'; -import {IconPickerService} from '../../../../shared/service/icon-picker.service'; +import {IconPickerService, IconSelection} from '../../../../shared/service/icon-picker.service'; +import {IconDisplayComponent} from '../../../../shared/components/icon-display/icon-display.component'; @Component({ selector: 'app-shelf-edit-dialog', @@ -15,7 +16,8 @@ import {IconPickerService} from '../../../../shared/service/icon-picker.service' Button, InputText, ReactiveFormsModule, - FormsModule + FormsModule, + IconDisplayComponent ], templateUrl: './shelf-edit-dialog.component.html', standalone: true, @@ -30,7 +32,7 @@ export class ShelfEditDialogComponent implements OnInit { private iconPickerService = inject(IconPickerService); shelfName: string = ''; - selectedIcon: string | null = null; + selectedIcon: IconSelection | null = null; shelf!: Shelf | undefined; ngOnInit(): void { @@ -38,7 +40,11 @@ export class ShelfEditDialogComponent implements OnInit { this.shelf = this.shelfService.getShelfById(shelfId); if (this.shelf) { this.shelfName = this.shelf.name; - this.selectedIcon = 'pi pi-' + this.shelf.icon; + if (this.shelf.iconType === 'PRIME_NG') { + this.selectedIcon = {type: 'PRIME_NG', value: `pi pi-${this.shelf.icon}`}; + } else { + this.selectedIcon = {type: 'CUSTOM_SVG', value: this.shelf.icon}; + } } } @@ -55,9 +61,13 @@ export class ShelfEditDialogComponent implements OnInit { } save() { + const iconValue = this.selectedIcon?.value || 'bookmark'; + const iconType = this.selectedIcon?.type || 'PRIME_NG'; + const shelf: Shelf = { name: this.shelfName, - icon: this.selectedIcon?.replace('pi pi-', '') || 'heart' + icon: iconValue, + iconType: iconType }; this.shelfService.updateShelf(shelf, this.shelf?.id).subscribe({ diff --git a/booklore-ui/src/app/features/book/model/library.model.ts b/booklore-ui/src/app/features/book/model/library.model.ts index 0ee72bc7..9544a179 100644 --- a/booklore-ui/src/app/features/book/model/library.model.ts +++ b/booklore-ui/src/app/features/book/model/library.model.ts @@ -7,6 +7,7 @@ export interface Library { id?: number; name: string; icon: string; + iconType?: 'PRIME_NG' | 'CUSTOM_SVG'; watch: boolean; fileNamingPattern?: string; sort?: SortOption; diff --git a/booklore-ui/src/app/features/book/model/shelf.model.ts b/booklore-ui/src/app/features/book/model/shelf.model.ts index 2c0e914f..0942e6f9 100644 --- a/booklore-ui/src/app/features/book/model/shelf.model.ts +++ b/booklore-ui/src/app/features/book/model/shelf.model.ts @@ -4,5 +4,6 @@ export interface Shelf { id?: number; name: string; icon: string; + iconType?: 'PRIME_NG' | 'CUSTOM_SVG'; sort?: SortOption; } diff --git a/booklore-ui/src/app/features/library-creator/library-creator.component.html b/booklore-ui/src/app/features/library-creator/library-creator.component.html index 520d0071..2ed127d6 100644 --- a/booklore-ui/src/app/features/library-creator/library-creator.component.html +++ b/booklore-ui/src/app/features/library-creator/library-creator.component.html @@ -94,11 +94,20 @@ } @else {
- +
Selected Icon - {{ selectedIcon }} + + @if (selectedIcon.type === 'PRIME_NG') { + {{ selectedIcon.value }} + } @else { + {{ selectedIcon.value }} (Custom) + } +
({path: folder})), watch: this.watch, scanMode: this.scanMode, @@ -167,7 +178,8 @@ export class LibraryCreatorComponent implements OnInit { } else { const library: Library = { name: this.chosenLibraryName, - icon: this.selectedIcon?.replace('pi pi-', '') || 'heart', + icon: iconValue, + iconType: iconType, paths: this.folders.map(folder => ({path: folder})), watch: this.watch, scanMode: this.scanMode, diff --git a/booklore-ui/src/app/features/magic-shelf/component/magic-shelf-component.html b/booklore-ui/src/app/features/magic-shelf/component/magic-shelf-component.html index aaeba0ee..b6b882b5 100644 --- a/booklore-ui/src/app/features/magic-shelf/component/magic-shelf-component.html +++ b/booklore-ui/src/app/features/magic-shelf/component/magic-shelf-component.html @@ -29,13 +29,22 @@
- - +
+ @if (selectedIcon) { + + + Click to change + } @else { +
+ + Select Icon +
+ } +
@if (isAdmin) { diff --git a/booklore-ui/src/app/features/magic-shelf/component/magic-shelf-component.scss b/booklore-ui/src/app/features/magic-shelf/component/magic-shelf-component.scss index 045c8d21..2846141e 100644 --- a/booklore-ui/src/app/features/magic-shelf/component/magic-shelf-component.scss +++ b/booklore-ui/src/app/features/magic-shelf/component/magic-shelf-component.scss @@ -152,6 +152,48 @@ color: var(--text-color); font-size: 0.9rem; } + + .icon-display-container { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.625rem 1rem; + border: 1px solid var(--border-color); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + background: var(--card-background); + + &:hover { + border-color: var(--primary-color); + } + + .selected-icon { + flex-shrink: 0; + } + + .icon-label { + font-size: 0.95rem; + color: var(--text-secondary-color); + white-space: nowrap; + } + + .icon-placeholder { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text-secondary-color); + + i { + font-size: 1.125rem; + color: var(--primary-color) + } + + span { + font-size: 0.875rem; + } + } + } } @media (max-width: 768px) { diff --git a/booklore-ui/src/app/features/magic-shelf/component/magic-shelf-component.ts b/booklore-ui/src/app/features/magic-shelf/component/magic-shelf-component.ts index 111a5767..a4fd53df 100644 --- a/booklore-ui/src/app/features/magic-shelf/component/magic-shelf-component.ts +++ b/booklore-ui/src/app/features/magic-shelf/component/magic-shelf-component.ts @@ -15,9 +15,10 @@ import {DynamicDialogConfig, DynamicDialogRef} from 'primeng/dynamicdialog'; import {MultiSelect} from 'primeng/multiselect'; import {AutoComplete} from 'primeng/autocomplete'; import {EMPTY_CHECK_OPERATORS, MULTI_VALUE_OPERATORS, parseValue, removeNulls, serializeDateRules} from '../service/magic-shelf-utils'; -import {IconPickerService} from '../../../shared/service/icon-picker.service'; +import {IconPickerService, IconSelection} from '../../../shared/service/icon-picker.service'; import {CheckboxModule} from "primeng/checkbox"; import {UserService} from "../../settings/user-management/user.service"; +import {IconDisplayComponent} from '../../../shared/components/icon-display/icon-display.component'; export type RuleOperator = | 'equals' @@ -154,7 +155,8 @@ const FIELD_CONFIGS: Record = { InputNumber, MultiSelect, AutoComplete, - CheckboxModule + CheckboxModule, + IconDisplayComponent ] }) export class MagicShelfComponent implements OnInit { @@ -210,6 +212,8 @@ export class MagicShelfComponent implements OnInit { userService = inject(UserService); private iconPicker = inject(IconPickerService); + selectedIcon: IconSelection | null = null; + trackByFn(ruleCtrl: AbstractControl, index: number): any { return ruleCtrl; } @@ -222,12 +226,20 @@ export class MagicShelfComponent implements OnInit { if (id) { this.shelfId = id; this.magicShelfService.getShelf(id).subscribe((data) => { + const iconValue = data?.icon ?? null; + this.form = new FormGroup({ name: new FormControl(data?.name ?? null, {nonNullable: true, validators: [Validators.required]}), - icon: new FormControl(data?.icon ?? null, {nonNullable: true, validators: [Validators.required]}), + icon: new FormControl(iconValue, {nonNullable: true, validators: [Validators.required]}), isPublic: new FormControl(data?.isPublic ?? false), group: data?.filterJson ? this.buildGroupFromData(JSON.parse(data.filterJson)) : this.createGroup() }); + + if (iconValue) { + this.selectedIcon = iconValue.startsWith('pi ') + ? {type: 'PRIME_NG', value: iconValue} + : {type: 'CUSTOM_SVG', value: iconValue}; + } }); } else { this.form = new FormGroup({ @@ -409,7 +421,11 @@ export class MagicShelfComponent implements OnInit { openIconPicker() { this.iconPicker.open().subscribe(icon => { if (icon) { - this.form.get('icon')?.setValue(icon); + this.selectedIcon = icon; + const iconValue = icon.type === 'CUSTOM_SVG' + ? icon.value + : icon.value; + this.form.get('icon')?.setValue(iconValue); } }); } @@ -462,14 +478,14 @@ export class MagicShelfComponent implements OnInit { id: this.shelfId ?? undefined, name: value.name, icon: value.icon, - isPublic: !!value.isPublic, // Ensure it's a boolean + iconType: this.selectedIcon?.type, + isPublic: !!value.isPublic, group: cleanedGroup }).subscribe({ next: (savedShelf) => { this.messageService.add({severity: 'success', summary: 'Success', detail: 'Magic shelf saved successfully.'}); if (savedShelf?.id) { this.shelfId = savedShelf.id; - // Update the form with the saved data to reflect changes immediately this.form.patchValue({ name: savedShelf.name, icon: savedShelf.icon, diff --git a/booklore-ui/src/app/features/magic-shelf/service/magic-shelf.service.ts b/booklore-ui/src/app/features/magic-shelf/service/magic-shelf.service.ts index dce6997d..ed49b882 100644 --- a/booklore-ui/src/app/features/magic-shelf/service/magic-shelf.service.ts +++ b/booklore-ui/src/app/features/magic-shelf/service/magic-shelf.service.ts @@ -12,6 +12,7 @@ export interface MagicShelf { id?: number | null; name: string; icon?: string; + iconType?: 'PRIME_NG' | 'CUSTOM_SVG'; filterJson: string; isPublic?: boolean; } @@ -93,11 +94,12 @@ export class MagicShelfService { ); } - saveShelf(data: { id?: number; name: string | null; icon: string | null; group: any, isPublic?: boolean | null }): Observable { + saveShelf(data: { id?: number; name: string | null; icon: string | null; iconType?: 'PRIME_NG' | 'CUSTOM_SVG'; group: any, isPublic?: boolean | null }): Observable { const payload: MagicShelf = { id: data.id, name: data.name ?? '', icon: data.icon ?? 'pi pi-book', + iconType: data.iconType, filterJson: JSON.stringify(data.group), isPublic: data.isPublic ?? false }; diff --git a/booklore-ui/src/app/features/metadata/component/cover-search/cover-search.component.ts b/booklore-ui/src/app/features/metadata/component/cover-search/cover-search.component.ts index ee4c753e..cf330d7a 100644 --- a/booklore-ui/src/app/features/metadata/component/cover-search/cover-search.component.ts +++ b/booklore-ui/src/app/features/metadata/component/cover-search/cover-search.component.ts @@ -69,7 +69,6 @@ export class CoverSearchComponent implements OnInit { .pipe(finalize(() => this.loading = false)) .subscribe({ next: (images) => { - console.log('API response received:', images); this.coverImages = images.sort((a, b) => a.index - b.index); this.hasSearched = true; }, diff --git a/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.ts b/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.ts index 0038ee58..37a236d4 100644 --- a/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.ts +++ b/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.ts @@ -616,7 +616,7 @@ export class CbxReaderComponent implements OnInit { next: (seriesBooks) => { const sortedBySeriesNumber = this.sortBooksBySeriesNumber(seriesBooks); const currentBookIndex = sortedBySeriesNumber.findIndex(b => b.id === book.id); - + if (currentBookIndex === -1) { console.warn('[SeriesNav] Current book not found in series'); return; @@ -627,14 +627,6 @@ export class CbxReaderComponent implements OnInit { this.previousBookInSeries = hasPreviousBook ? sortedBySeriesNumber[currentBookIndex - 1] : null; this.nextBookInSeries = hasNextBook ? sortedBySeriesNumber[currentBookIndex + 1] : null; - - console.log('[SeriesNav] Navigation loaded:', { - series: book.metadata?.seriesName, - totalBooks: seriesBooks.length, - currentPosition: currentBookIndex + 1, - hasPrevious: hasPreviousBook, - hasNext: hasNextBook - }); }, error: (err) => { console.error('[SeriesNav] Failed to load series information:', err); @@ -652,22 +644,22 @@ export class CbxReaderComponent implements OnInit { getBookDisplayTitle(book: Book | null): string { if (!book) return ''; - + const parts: string[] = []; - + if (book.metadata?.seriesNumber) { parts.push(`#${book.metadata.seriesNumber}`); } - + const title = book.metadata?.title || book.fileName; if (title) { parts.push(title); } - + if (book.metadata?.subtitle) { parts.push(book.metadata.subtitle); } - + return parts.join(' - '); } diff --git a/booklore-ui/src/app/shared/components/directory-picker/directory-picker.component.scss b/booklore-ui/src/app/shared/components/directory-picker/directory-picker.component.scss index 9763d33a..5726a26b 100644 --- a/booklore-ui/src/app/shared/components/directory-picker/directory-picker.component.scss +++ b/booklore-ui/src/app/shared/components/directory-picker/directory-picker.component.scss @@ -8,8 +8,8 @@ } .directory-picker { - width: 550px; - max-width: 550px; + width: 650px; + max-width: 650px; display: flex; flex-direction: column; @@ -370,7 +370,7 @@ display: flex; align-items: center; gap: 0.75rem; - padding: 0.75rem; + padding: 0.75rem 1rem; background: var(--ground-background); border: 1px solid var(--border-color); border-radius: 8px; diff --git a/booklore-ui/src/app/shared/components/icon-display/icon-display.component.ts b/booklore-ui/src/app/shared/components/icon-display/icon-display.component.ts new file mode 100644 index 00000000..7c03f933 --- /dev/null +++ b/booklore-ui/src/app/shared/components/icon-display/icon-display.component.ts @@ -0,0 +1,63 @@ +import {Component, inject, Input} from '@angular/core'; +import {IconSelection} from '../../service/icon-picker.service'; +import {UrlHelperService} from '../../service/url-helper.service'; +import {NgClass, NgStyle} from '@angular/common'; + +@Component({ + selector: 'app-icon-display', + standalone: true, + imports: [NgClass, NgStyle], + template: ` + @if (icon) { + @if (icon.type === 'PRIME_NG') { + + } @else { + + } + } + `, + styles: [` + :host { + display: inline-flex; + align-items: center; + justify-content: center; + } + `] +}) +export class IconDisplayComponent { + @Input() icon: IconSelection | null = null; + @Input() iconClass: string = 'icon'; + @Input() iconStyle: Record = {}; + @Input() size: string = '24px'; + @Input() alt: string = 'Icon'; + + private urlHelper = inject(UrlHelperService); + + getPrimeNgIconClass(iconValue: string): string { + if (iconValue.startsWith('pi pi-')) { + return iconValue; + } + if (iconValue.startsWith('pi-')) { + return `pi ${iconValue}`; + } + return `pi pi-${iconValue}`; + } + + getIconUrl(iconName: string): string { + return this.urlHelper.getIconUrl(iconName); + } + + getImageStyle(): Record { + return { + width: this.size, + height: this.size, + objectFit: 'contain', + ...this.iconStyle + }; + } +} diff --git a/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.html b/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.html index 824d6744..f31037c1 100644 --- a/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.html +++ b/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.html @@ -1,12 +1,126 @@ - + + + Prime Icons + SVG Icons + Add SVG Icon + + + + -
- @for (icon of filteredIcons(); track icon) { -
- -
- } -
+
+ @for (icon of filteredIcons(); track icon) { +
+ +
+ } +
+
+ +
+ @if (isLoadingSvgIcons) { +
Loading icons...
+ } @else if (svgIconsError) { +
{{ svgIconsError }}
+ } @else { + + +
+ @for (iconName of filteredSvgIcons(); track iconName) { +
+ +
+ } +
+ + @if (totalSvgPages > 1) { +
+ + + Page {{ currentSvgPage + 1 }} of {{ totalSvgPages }} + + +
+ } + +
+
+ + Drag here to delete icon +
+
+ } +
+
+ +
+ + + + + @if (svgContent && svgPreview) { +
+

Preview

+
+
+ } + + @if (errorMessage) { +
{{ errorMessage }}
+ } + +
+ + +
+
+
+
+
diff --git a/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.scss b/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.scss index 5052a82f..cf68332a 100644 --- a/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.scss +++ b/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.scss @@ -1,14 +1,19 @@ .icon-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(25px, 1fr)); - gap: 30px; + gap: 38px; + padding-top: 10px; } .icon-item { display: flex; - justify-content: center; - align-items: center; cursor: pointer; + justify-content: space-evenly; + align-items: flex-start; +} + +.icon-item > i { + font-size: 1.25rem; } .icon-search { @@ -18,6 +23,7 @@ border: 1px solid var(--text-secondary-color); border-radius: 4px; margin-bottom: 15px; + margin-top: 15px; box-sizing: border-box; } @@ -30,3 +36,192 @@ color: var(--text-secondary-color); opacity: 1; } + +.svg-paste-container { + display: flex; + flex-direction: column; + gap: 15px; +} + +.svg-textarea { + width: 100%; + padding: 12px; + font-size: 14px; + font-family: monospace; + border: 1px solid var(--text-secondary-color); + border-radius: 4px; + resize: vertical; + box-sizing: border-box; + + &:focus { + border-color: var(--primary-color); + outline: none; + } +} + +.svg-preview-section { + display: flex; + flex-direction: column; + gap: 10px; +} + +.preview-title { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--text-primary-color); +} + +.svg-preview { + border: 1px solid var(--text-secondary-color); + border-radius: 4px; + padding: 10px; + background-color: var(--ground-background); + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; + max-height: 225px; + overflow: auto; + + ::ng-deep svg { + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + } +} + +.button-container { + display: flex; + justify-content: flex-end; +} + +.error-message { + color: #e74c3c; + font-size: 14px; + font-weight: 500; + padding: 8px; + background-color: #ffe6e6; + border-radius: 4px; +} + +.svg-name-input { + width: 100%; + padding: 8px 12px; + font-size: 15px; + border: 1px solid var(--text-secondary-color); + border-radius: 4px; + margin-bottom: 8px; + box-sizing: border-box; + + &:focus { + border-color: var(--primary-color); + outline: none; + } +} + +.svg-browse-container { + .loading-message { + text-align: center; + padding: 2rem; + color: #666; + } +} + +.svg-icon-item { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + gap: 0.5rem; + border: 1px solid transparent; + border-radius: 4px; + transition: all 0.2s; + height: 24px; + width: 24px; + + .svg-icon-image { + height: 20px; + width: 20px; + object-fit: contain; + } +} + +.pagination-controls { + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + padding: 1rem; + margin-top: 1rem; + border-top: 1px solid #e0e0e0; + + .pagination-button { + padding: 0.5rem 1rem; + border: 1px solid #ddd; + background: white; + border-radius: 4px; + cursor: pointer; + + &:hover:not(:disabled) { + background: #f5f5f5; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + + .pagination-info { + color: #666; + font-size: 0.9rem; + } +} + +.svg-trash-area { + position: fixed; + right: 32px; + bottom: 32px; + z-index: 100; + display: flex; + align-items: center; + gap: 10px; + background: var(--p-surface-800); + border: 2px dashed var(--border-color); + border-radius: 50px; + padding: 12px 22px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.30); + color: var(--text-secondary-color); + font-size: 16px; + transition: border-color 0.2s, background 0.2s, color 0.2s; + + i.pi-trash { + font-size: 22px; + color: #ff3b27; + margin-right: 8px; + transition: color 0.2s; + } + + span { + font-size: 15px; + font-weight: 500; + color: #bbb; + transition: color 0.2s; + } + + &.trash-hover { + border-color: #e74c3c; + background: #3a2323; + color: #e74c3c; + + i.pi-trash { + color: #ff7675; + } + + span { + color: #ff7675; + } + } +} diff --git a/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.ts b/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.ts index dbbff34f..441bd0b4 100644 --- a/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.ts +++ b/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.ts @@ -1,64 +1,88 @@ -import {Component, EventEmitter, inject, Output} from '@angular/core'; +import {Component, inject, OnInit} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {DynamicDialogRef} from 'primeng/dynamicdialog'; +import {IconService} from '../../services/icon.service'; +import {DomSanitizer, SafeHtml} from '@angular/platform-browser'; +import {UrlHelperService} from '../../service/url-helper.service'; +import {MessageService} from 'primeng/api'; +import {IconCategoriesHelper} from '../../helpers/icon-categories.helper'; +import {Button} from 'primeng/button'; +import {TabsModule} from 'primeng/tabs'; @Component({ selector: 'app-icon-picker-component', imports: [ - FormsModule + FormsModule, + Button, + TabsModule ], templateUrl: './icon-picker-component.html', styleUrl: './icon-picker-component.scss' }) -export class IconPickerComponent { +export class IconPickerComponent implements OnInit { - iconCategories: string[] = [ - "address-book", "align-center", "align-justify", "align-left", "align-right", "amazon", "android", - "angle-double-down", "angle-double-left", "angle-double-right", "angle-double-up", "angle-down", "angle-left", - "angle-right", "angle-up", "apple", "arrow-circle-down", "arrow-circle-left", "arrow-circle-right", "arrow-circle-up", - "arrow-down", "arrow-down-left", "arrow-down-left-and-arrow-up-right-to-center", "arrow-down-right", "arrow-left", - "arrow-right", "arrow-right-arrow-left", "arrow-up", "arrow-up-left", "arrow-up-right", "arrow-up-right-and-arrow-down-left-from-center", - "arrows-alt", "arrows-h", "arrows-v", "asterisk", "at", "backward", "ban", "barcode", "bars", "bell", "bell-slash", - "bitcoin", "bolt", "book", "bookmark", "bookmark-fill", "box", "briefcase", "building", "building-columns", "bullseye", - "calculator", "calendar", "calendar-clock", "calendar-minus", "calendar-plus", "calendar-times", "camera", "car", - "caret-down", "caret-left", "caret-right", "caret-up", "cart-arrow-down", "cart-minus", "cart-plus", "chart-bar", - "chart-line", "chart-pie", "chart-scatter", "check", "check-circle", "check-square", "chevron-circle-down", - "chevron-circle-left", "chevron-circle-right", "chevron-circle-up", "chevron-down", "chevron-left", "chevron-right", - "chevron-up", "circle", "circle-fill", "circle-off", "circle-on", "clipboard", "clock", "clone", "cloud", "cloud-download", - "cloud-upload", "code", "cog", "comment", "comments", "compass", "copy", "credit-card", "crown", "database", "delete-left", - "desktop", "directions", "directions-alt", "discord", "dollar", "download", "eject", "ellipsis-h", "ellipsis-v", - "envelope", "equals", "eraser", "ethereum", "euro", "exclamation-circle", "exclamation-triangle", "expand", - "external-link", "eye", "eye-slash", "face-smile", "facebook", "fast-backward", "fast-forward", "file", "file-arrow-up", - "file-check", "file-edit", "file-excel", "file-export", "file-import", "file-o", "file-pdf", "file-plus", "file-word", - "filter", "filter-fill", "filter-slash", "flag", "flag-fill", "folder", "folder-open", "folder-plus", "forward", "gauge", - "gift", "github", "globe", "google", "graduation-cap", "hammer", "hashtag", "headphones", "heart", "heart-fill", "history", - "home", "hourglass", "id-card", "image", "images", "inbox", "indian-rupee", "info", "info-circle", "instagram", "key", - "language", "lightbulb", "link", "linkedin", "list", "list-check", "lock", "lock-open", "map", "map-marker", "mars", "megaphone", - "microchip", "microchip-ai", "microphone", "microsoft", "minus", "minus-circle", "mobile", "money-bill", "moon", "objects-column", - "palette", "paperclip", "pause", "pause-circle", "paypal", "pen-to-square", "pencil", "percentage", "phone", "pinterest", "play", - "play-circle", "plus", "plus-circle", "pound", "power-off", "prime", "print", "qrcode", "question", "question-circle", "receipt", - "reddit", "refresh", "replay", "reply", "save", "search", "search-minus", "search-plus", "send", "server", "share-alt", "shield", - "shop", "shopping-bag", "shopping-cart", "sign-in", "sign-out", "sitemap", "slack", "sliders-h", "sliders-v", "sort", - "sort-alpha-down", "sort-alpha-down-alt", "sort-alpha-up", "sort-alpha-up-alt", "sort-alt", "sort-alt-slash", - "sort-amount-down", "sort-amount-down-alt", "sort-amount-up", "sort-amount-up-alt", "sort-down", "sort-down-fill", - "sort-numeric-down", "sort-numeric-down-alt", "sort-numeric-up", "sort-numeric-up-alt", "sort-up", "sort-up-fill", "sparkles", - "spinner", "spinner-dotted", "star", "star-fill", "star-half", "star-half-fill", "step-backward", "step-backward-alt", - "step-forward", "step-forward-alt", "stop", "stop-circle", "stopwatch", "sun", "sync", "table", "tablet", "tag", "tags", - "telegram", "th-large", "thumbs-down", "thumbs-down-fill", "thumbs-up", "thumbs-up-fill", "thumbtack", "ticket", "tiktok", - "times", "times-circle", "trash", "trophy", "truck", "turkish-lira", "twitch", "twitter", "undo", "unlock", "upload", "user", - "user-edit", "user-minus", "user-plus", "users", "venus", "verified", "video", "vimeo", "volume-down", "volume-off", - "volume-up", "wallet", "warehouse", "wave-pulse", "whatsapp", "wifi", "window-maximize", "window-minimize", "wrench", - "youtube" - ]; + private readonly SVG_PAGE_SIZE = 50; + private readonly MAX_ICON_NAME_LENGTH = 255; + private readonly MAX_SVG_SIZE = 1048576; // 1MB + private readonly ICON_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/; + private readonly ERROR_MESSAGES = { + NO_CONTENT: 'Please paste SVG content', + NO_NAME: 'Please provide a name for the icon', + INVALID_NAME: 'Icon name can only contain alphanumeric characters and hyphens', + NAME_TOO_LONG: `Icon name must not exceed ${this.MAX_ICON_NAME_LENGTH} characters`, + INVALID_SVG: 'Invalid SVG content. Please paste valid SVG code.', + MISSING_SVG_TAG: 'Content must include tag', + SVG_TOO_LARGE: 'SVG content must not exceed 1MB', + PARSE_ERROR: 'Failed to parse SVG content', + LOAD_ICONS_ERROR: 'Failed to load SVG icons. Please try again.', + SAVE_ERROR: 'Failed to save SVG. Please try again.', + DELETE_ERROR: 'Failed to delete icon. Please try again.' + }; + + ref = inject(DynamicDialogRef); + iconService = inject(IconService); + sanitizer = inject(DomSanitizer); + urlHelper = inject(UrlHelperService); + messageService = inject(MessageService); searchText: string = ''; selectedIcon: string | null = null; - icons: string[] = this.createIconList(this.iconCategories); + icons: string[] = IconCategoriesHelper.createIconList(); - ref = inject(DynamicDialogRef); + private _activeTabIndex: string = '0'; - createIconList(categories: string[]): string[] { - return categories.map(iconName => `pi pi-${iconName}`); + get activeTabIndex(): string { + return this._activeTabIndex; + } + + set activeTabIndex(value: string) { + this._activeTabIndex = value; + if (value === '1' && this.svgIcons.length === 0 && !this.isLoadingSvgIcons) { + this.loadSvgIcons(0); + } + } + + svgContent: string = ''; + svgName: string = ''; + svgPreview: SafeHtml | null = null; + isLoading: boolean = false; + errorMessage: string = ''; + + svgIcons: string[] = []; + svgSearchText: string = ''; + currentSvgPage: number = 0; + totalSvgPages: number = 0; + isLoadingSvgIcons: boolean = false; + svgIconsError: string = ''; + selectedSvgIcon: string | null = null; + + draggedSvgIcon: string | null = null; + isTrashHover: boolean = false; + + ngOnInit(): void { + if (this.activeTabIndex === '1') { + this.loadSvgIcons(0); + } } filteredIcons(): string[] { @@ -66,12 +90,192 @@ export class IconPickerComponent { return this.icons.filter(icon => icon.toLowerCase().includes(this.searchText.toLowerCase())); } - selectIcon(icon: string) { - this.selectedIcon = icon; - this.ref.close(icon); + filteredSvgIcons(): string[] { + if (!this.svgSearchText) return this.svgIcons; + return this.svgIcons.filter(icon => icon.toLowerCase().includes(this.svgSearchText.toLowerCase())); } - cancel() { - this.ref.close(); + selectIcon(icon: string): void { + this.selectedIcon = icon; + this.ref.close({type: 'PRIME_NG', value: icon}); + } + + loadSvgIcons(page: number): void { + this.isLoadingSvgIcons = true; + this.svgIconsError = ''; + + this.iconService.getIconNames(page, this.SVG_PAGE_SIZE).subscribe({ + next: (response) => { + this.svgIcons = response.content; + this.currentSvgPage = response.number; + this.totalSvgPages = response.totalPages; + this.isLoadingSvgIcons = false; + }, + error: () => { + this.isLoadingSvgIcons = false; + this.svgIconsError = this.ERROR_MESSAGES.LOAD_ICONS_ERROR; + } + }); + } + + getSvgIconUrl(iconName: string): string { + return this.urlHelper.getIconUrl(iconName); + } + + onImageError(event: Event): void { + const img = event.target as HTMLImageElement; + img.src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"%3E%3Ccircle cx="12" cy="12" r="10"/%3E%3Cline x1="15" y1="9" x2="9" y2="15"/%3E%3Cline x1="9" y1="9" x2="15" y2="15"/%3E%3C/svg%3E'; + } + + selectSvgIcon(iconName: string): void { + this.selectedSvgIcon = iconName; + this.ref.close({type: 'CUSTOM_SVG', value: iconName}); + } + + onSvgContentChange(): void { + this.errorMessage = ''; + + if (!this.svgContent.trim()) { + this.svgPreview = null; + return; + } + + const trimmedContent = this.svgContent.trim(); + if (!trimmedContent.includes(' { + this.isLoading = false; + this.handleSuccessfulSave(); + }, + error: (error) => { + this.isLoading = false; + this.errorMessage = error.error?.details?.join(', ') + || error.error?.message + || this.ERROR_MESSAGES.SAVE_ERROR; + } + }); + } + + private validateSvgInput(): string | null { + if (!this.svgContent.trim()) { + return this.ERROR_MESSAGES.NO_CONTENT; + } + + if (!this.svgName.trim()) { + return this.ERROR_MESSAGES.NO_NAME; + } + + if (!this.ICON_NAME_PATTERN.test(this.svgName)) { + return this.ERROR_MESSAGES.INVALID_NAME; + } + + if (this.svgName.length > this.MAX_ICON_NAME_LENGTH) { + return this.ERROR_MESSAGES.NAME_TOO_LONG; + } + + if (!this.svgContent.trim().includes(' this.MAX_SVG_SIZE) { + return this.ERROR_MESSAGES.SVG_TOO_LARGE; + } + + return null; + } + + private handleSuccessfulSave(): void { + this.activeTabIndex = '1'; + if (!this.svgIcons.includes(this.svgName)) { + this.svgIcons.unshift(this.svgName); + } + this.selectedSvgIcon = this.svgName; + this.resetSvgForm(); + } + + private resetSvgForm(): void { + this.svgSearchText = ''; + this.svgContent = ''; + this.svgName = ''; + this.svgPreview = null; + } + + onSvgIconDragStart(iconName: string): void { + this.draggedSvgIcon = iconName; + } + + onSvgIconDragEnd(): void { + this.draggedSvgIcon = null; + this.isTrashHover = false; + } + + onTrashDragOver(event: DragEvent): void { + event.preventDefault(); + this.isTrashHover = true; + } + + onTrashDragLeave(event: DragEvent): void { + event.preventDefault(); + this.isTrashHover = false; + } + + onTrashDrop(event: DragEvent): void { + event.preventDefault(); + this.isTrashHover = false; + + if (!this.draggedSvgIcon) { + return; + } + + this.deleteSvgIcon(this.draggedSvgIcon); + this.draggedSvgIcon = null; + } + + private deleteSvgIcon(iconName: string): void { + this.isLoadingSvgIcons = true; + + this.iconService.deleteSvgIcon(iconName).subscribe({ + next: () => { + this.messageService.add({ + severity: 'success', + summary: 'Icon Deleted', + detail: 'SVG icon deleted successfully.', + life: 2500 + }); + this.loadSvgIcons(this.currentSvgPage); + }, + error: (error) => { + this.isLoadingSvgIcons = false; + this.messageService.add({ + severity: 'error', + summary: 'Delete Failed', + detail: error.error?.message || this.ERROR_MESSAGES.DELETE_ERROR, + life: 4000 + }); + } + }); } } diff --git a/booklore-ui/src/app/shared/helpers/icon-categories.helper.ts b/booklore-ui/src/app/shared/helpers/icon-categories.helper.ts new file mode 100644 index 00000000..a20b50c7 --- /dev/null +++ b/booklore-ui/src/app/shared/helpers/icon-categories.helper.ts @@ -0,0 +1,44 @@ +export class IconCategoriesHelper { + static readonly CATEGORIES: string[] = [ + "address-book", "align-center", "align-justify", "align-left", "align-right", "amazon", "android", + "angle-double-down", "angle-double-left", "angle-double-right", "angle-double-up", "angle-down", "angle-left", + "angle-right", "angle-up", "apple", "arrow-circle-down", "arrow-circle-left", "arrow-circle-right", "arrow-circle-up", + "arrow-down", "arrow-down-left", "arrow-down-left-and-arrow-up-right-to-center", "arrow-down-right", "arrow-left", + "arrow-right", "arrow-right-arrow-left", "arrow-up", "arrow-up-left", "arrow-up-right", "arrow-up-right-and-arrow-down-left-from-center", + "arrows-alt", "arrows-h", "arrows-v", "asterisk", "at", "backward", "ban", "barcode", "bars", "bell", "bell-slash", + "bitcoin", "bolt", "book", "bookmark", "bookmark-fill", "box", "briefcase", "building", "building-columns", "bullseye", + "calculator", "calendar", "calendar-clock", "calendar-minus", "calendar-plus", "calendar-times", "camera", "car", + "caret-down", "caret-left", "caret-right", "caret-up", "cart-arrow-down", "cart-minus", "cart-plus", "chart-bar", + "chart-line", "chart-pie", "chart-scatter", "check", "check-circle", "check-square", "chevron-circle-down", + "chevron-circle-left", "chevron-circle-right", "chevron-circle-up", "chevron-down", "chevron-left", "chevron-right", + "chevron-up", "circle", "circle-fill", "circle-off", "circle-on", "clipboard", "clock", "clone", "cloud", "cloud-download", + "cloud-upload", "code", "cog", "comment", "comments", "compass", "copy", "credit-card", "crown", "database", "delete-left", + "desktop", "directions", "directions-alt", "discord", "dollar", "download", "eject", "ellipsis-h", "ellipsis-v", + "envelope", "equals", "eraser", "ethereum", "euro", "exclamation-circle", "exclamation-triangle", "expand", + "external-link", "eye", "eye-slash", "face-smile", "facebook", "fast-backward", "fast-forward", "file", "file-arrow-up", + "file-check", "file-edit", "file-excel", "file-export", "file-import", "file-o", "file-pdf", "file-plus", "file-word", + "filter", "filter-fill", "filter-slash", "flag", "flag-fill", "folder", "folder-open", "folder-plus", "forward", "gauge", + "gift", "github", "globe", "google", "graduation-cap", "hammer", "hashtag", "headphones", "heart", "heart-fill", "history", + "home", "hourglass", "id-card", "image", "images", "inbox", "indian-rupee", "info", "info-circle", "instagram", "key", + "language", "lightbulb", "link", "linkedin", "list", "list-check", "lock", "lock-open", "map", "map-marker", "mars", "megaphone", + "microchip", "microchip-ai", "microphone", "microsoft", "minus", "minus-circle", "mobile", "money-bill", "moon", "objects-column", + "palette", "paperclip", "pause", "pause-circle", "paypal", "pen-to-square", "pencil", "percentage", "phone", "pinterest", "play", + "play-circle", "plus", "plus-circle", "pound", "power-off", "prime", "print", "qrcode", "question", "question-circle", "receipt", + "reddit", "refresh", "replay", "reply", "save", "search", "search-minus", "search-plus", "send", "server", "share-alt", "shield", + "shop", "shopping-bag", "shopping-cart", "sign-in", "sign-out", "sitemap", "slack", "sliders-h", "sliders-v", "sort", + "sort-alpha-down", "sort-alpha-down-alt", "sort-alpha-up", "sort-alpha-up-alt", "sort-alt", "sort-alt-slash", + "sort-amount-down", "sort-amount-down-alt", "sort-amount-up", "sort-amount-up-alt", "sort-down", "sort-down-fill", + "sort-numeric-down", "sort-numeric-down-alt", "sort-numeric-up", "sort-numeric-up-alt", "sort-up", "sort-up-fill", "sparkles", + "spinner", "spinner-dotted", "star", "star-fill", "star-half", "star-half-fill", "step-backward", "step-backward-alt", + "step-forward", "step-forward-alt", "stop", "stop-circle", "stopwatch", "sun", "sync", "table", "tablet", "tag", "tags", + "telegram", "th-large", "thumbs-down", "thumbs-down-fill", "thumbs-up", "thumbs-up-fill", "thumbtack", "ticket", "tiktok", + "times", "times-circle", "trash", "trophy", "truck", "turkish-lira", "twitch", "twitter", "undo", "unlock", "upload", "user", + "user-edit", "user-minus", "user-plus", "users", "venus", "verified", "video", "vimeo", "volume-down", "volume-off", + "volume-up", "wallet", "warehouse", "wave-pulse", "whatsapp", "wifi", "window-maximize", "window-minimize", "wrench", + "youtube" + ]; + + static createIconList(): string[] { + return this.CATEGORIES.map(iconName => `pi pi-${iconName}`); + } +} diff --git a/booklore-ui/src/app/shared/layout/component/layout-menu/app.menu.component.ts b/booklore-ui/src/app/shared/layout/component/layout-menu/app.menu.component.ts index 9162be03..5af12eb8 100644 --- a/booklore-ui/src/app/shared/layout/component/layout-menu/app.menu.component.ts +++ b/booklore-ui/src/app/shared/layout/component/layout-menu/app.menu.component.ts @@ -107,7 +107,8 @@ export class AppMenuComponent implements OnInit { menu: this.libraryShelfMenuService.initializeLibraryMenuItems(library), label: library.name, type: 'Library', - icon: 'pi pi-' + library.icon, + icon: library.icon, + iconType: (library.iconType || 'PRIME_NG') as 'PRIME_NG' | 'CUSTOM_SVG', routerLink: [`/library/${library.id}/books`], bookCount$: this.libraryService.getBookCount(library.id ?? 0), })), @@ -129,7 +130,8 @@ export class AppMenuComponent implements OnInit { items: sortedShelves.map((shelf) => ({ label: shelf.name, type: 'magicShelfItem', - icon: 'pi pi-' + shelf.icon, + icon: shelf.icon || 'pi pi-book', + iconType: (shelf.iconType || 'PRIME_NG') as 'PRIME_NG' | 'CUSTOM_SVG', menu: this.libraryShelfMenuService.initializeMagicShelfMenuItems(shelf), routerLink: [`/magic-shelf/${shelf.id}/books`], bookCount$: this.magicShelfService.getBookCount(shelf.id ?? 0), @@ -154,7 +156,8 @@ export class AppMenuComponent implements OnInit { menu: this.libraryShelfMenuService.initializeShelfMenuItems(shelf), label: shelf.name, type: 'Shelf', - icon: 'pi pi-' + shelf.icon, + icon: shelf.icon, + iconType: (shelf.iconType || 'PRIME_NG') as 'PRIME_NG' | 'CUSTOM_SVG', routerLink: [`/shelf/${shelf.id}/books`], bookCount$: this.shelfService.getBookCount(shelf.id ?? 0), })); @@ -163,6 +166,7 @@ export class AppMenuComponent implements OnInit { label: 'Unshelved', type: 'Shelf', icon: 'pi pi-inbox', + iconType: 'PRIME_NG' as 'PRIME_NG' | 'CUSTOM_SVG', routerLink: ['/unshelved-books'], bookCount$: this.shelfService.getUnshelvedBookCount?.() ?? of(0), }; @@ -172,7 +176,8 @@ export class AppMenuComponent implements OnInit { items.push({ label: koboShelf.name, type: 'Shelf', - icon: 'pi pi-' + koboShelf.icon, + icon: koboShelf.icon, + iconType: (koboShelf.iconType || 'PRIME_NG') as 'PRIME_NG' | 'CUSTOM_SVG', routerLink: [`/shelf/${koboShelf.id}/books`], bookCount$: this.shelfService.getBookCount(koboShelf.id ?? 0), }); diff --git a/booklore-ui/src/app/shared/layout/component/layout-menu/app.menuitem.component.html b/booklore-ui/src/app/shared/layout/component/layout-menu/app.menuitem.component.html index c1f2db15..6818163c 100644 --- a/booklore-ui/src/app/shared/layout/component/layout-menu/app.menuitem.component.html +++ b/booklore-ui/src/app/shared/layout/component/layout-menu/app.menuitem.component.html @@ -53,7 +53,11 @@ tabindex="0" pRipple > - + {{ item.label }} diff --git a/booklore-ui/src/app/shared/layout/component/layout-menu/app.menuitem.component.scss b/booklore-ui/src/app/shared/layout/component/layout-menu/app.menuitem.component.scss index 56fe1804..2cd1cb9d 100644 --- a/booklore-ui/src/app/shared/layout/component/layout-menu/app.menuitem.component.scss +++ b/booklore-ui/src/app/shared/layout/component/layout-menu/app.menuitem.component.scss @@ -71,6 +71,7 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + margin-left: 0.5rem; } .menu-item-end-content { diff --git a/booklore-ui/src/app/shared/layout/component/layout-menu/app.menuitem.component.ts b/booklore-ui/src/app/shared/layout/component/layout-menu/app.menuitem.component.ts index 5efbbc93..7ed172a0 100644 --- a/booklore-ui/src/app/shared/layout/component/layout-menu/app.menuitem.component.ts +++ b/booklore-ui/src/app/shared/layout/component/layout-menu/app.menuitem.component.ts @@ -10,8 +10,9 @@ import {Button} from 'primeng/button'; import {Menu} from 'primeng/menu'; import {UserService} from '../../../../features/settings/user-management/user.service'; import {DialogLauncherService} from '../../../services/dialog-launcher.service'; -import {ShelfCreatorComponent} from '../../../../features/book/components/shelf-creator/shelf-creator.component'; import {BookDialogHelperService} from '../../../../features/book/components/book-browser/BookDialogHelperService'; +import {IconDisplayComponent} from '../../../components/icon-display/icon-display.component'; +import {IconSelection} from '../../../service/icon-picker.service'; @Component({ selector: '[app-menuitem]', @@ -24,7 +25,8 @@ import {BookDialogHelperService} from '../../../../features/book/components/book Ripple, AsyncPipe, Button, - Menu + Menu, + IconDisplayComponent ], animations: [ trigger('children', [ @@ -169,6 +171,15 @@ export class AppMenuitemComponent implements OnInit, OnDestroy { } } + getIconSelection(): IconSelection | null { + if (!this.item.icon) return null; + + return { + type: this.item.iconType || 'PRIME_NG', + value: this.item.icon + }; + } + @HostBinding('class.active-menuitem') get activeClass() { return this.active && !this.root; diff --git a/booklore-ui/src/app/shared/layout/component/theme-configurator/background-upload.service.ts b/booklore-ui/src/app/shared/layout/component/theme-configurator/background-upload.service.ts index 76888569..4cf86d2f 100644 --- a/booklore-ui/src/app/shared/layout/component/theme-configurator/background-upload.service.ts +++ b/booklore-ui/src/app/shared/layout/component/theme-configurator/background-upload.service.ts @@ -23,10 +23,4 @@ export class BackgroundUploadService { map(resp => resp?.url) ); } - - resetToDefault(): Observable { - return this.http.delete(this.baseUrl).pipe( - tap(() => console.log('Background reset to default')) - ); - } } diff --git a/booklore-ui/src/app/shared/service/icon-picker.service.ts b/booklore-ui/src/app/shared/service/icon-picker.service.ts index b430c2f9..e837cc65 100644 --- a/booklore-ui/src/app/shared/service/icon-picker.service.ts +++ b/booklore-ui/src/app/shared/service/icon-picker.service.ts @@ -3,11 +3,16 @@ import {DialogService, DynamicDialogRef} from 'primeng/dynamicdialog'; import {IconPickerComponent} from '../components/icon-picker/icon-picker-component'; import {Observable} from 'rxjs'; +export interface IconSelection { + type: 'PRIME_NG' | 'CUSTOM_SVG'; + value: string; +} + @Injectable({providedIn: 'root'}) export class IconPickerService { private dialog = inject(DialogService); - open(): Observable { + open(): Observable { const isMobile = window.innerWidth <= 768; const ref: DynamicDialogRef | null = this.dialog.open(IconPickerComponent, { header: 'Choose an Icon', @@ -22,6 +27,6 @@ export class IconPickerService { minWidth: isMobile ? '90vw' : '800px', } }); - return ref!.onClose as Observable; + return ref!.onClose as Observable; } } diff --git a/booklore-ui/src/app/shared/service/url-helper.service.ts b/booklore-ui/src/app/shared/service/url-helper.service.ts index 665fd1b8..e74aae74 100644 --- a/booklore-ui/src/app/shared/service/url-helper.service.ts +++ b/booklore-ui/src/app/shared/service/url-helper.service.ts @@ -100,4 +100,9 @@ export class UrlHelperService { } }); } + + getIconUrl(iconName: string): string { + const url = `${this.mediaBaseUrl}/icon/${iconName}`; + return this.appendToken(url); + } } diff --git a/booklore-ui/src/app/shared/services/icon.service.ts b/booklore-ui/src/app/shared/services/icon.service.ts new file mode 100644 index 00000000..95b48932 --- /dev/null +++ b/booklore-ui/src/app/shared/services/icon.service.ts @@ -0,0 +1,39 @@ +import {inject, Injectable} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {Observable} from 'rxjs'; +import {API_CONFIG} from '../../core/config/api-config'; + +interface PageResponse { + content: T[]; + number: number; + size: number; + totalElements: number; + totalPages: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class IconService { + + private readonly baseUrl = `${API_CONFIG.BASE_URL}/api/v1/icons`; + + private http = inject(HttpClient); + + saveSvgIcon(svgContent: string, svgName: string): Observable { + return this.http.post(this.baseUrl, { + svgData: svgContent, + svgName: svgName + }); + } + + getIconNames(page: number = 0, size: number = 50): Observable> { + return this.http.get>(this.baseUrl, { + params: { page: page.toString(), size: size.toString() } + }); + } + + deleteSvgIcon(svgName: string): Observable { + return this.http.delete(`${this.baseUrl}/${encodeURIComponent(svgName)}`); + } +} diff --git a/booklore-ui/src/assets/layout/styles/layout/_menu.scss b/booklore-ui/src/assets/layout/styles/layout/_menu.scss index f4ce4b85..defad595 100644 --- a/booklore-ui/src/assets/layout/styles/layout/_menu.scss +++ b/booklore-ui/src/assets/layout/styles/layout/_menu.scss @@ -78,10 +78,6 @@ border-radius: variables.$borderRadius; transition: background-color variables.$transitionDuration, box-shadow variables.$transitionDuration; - .layout-menuitem-icon { - margin-right: .5rem; - } - .layout-submenu-toggler { font-size: 75%; margin-left: auto;