Add support for uploading and assigning custom SVG icons to libraries… (#1788)

* Add support for uploading and assigning custom SVG icons to libraries and shelves

* Add few default icons
This commit is contained in:
Aditya Chandel
2025-12-07 15:16:22 -07:00
committed by GitHub
parent 04b9f88510
commit ad0a99bcbc
74 changed files with 1613 additions and 171 deletions

View File

@@ -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

View File

@@ -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<String> 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);
}
}

View File

@@ -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<Page<String>> getIconNames(
@Parameter(description = "Page number") @RequestParam(defaultValue = "0") int page,
@Parameter(description = "Page size") @RequestParam(defaultValue = "50") int size) {
Page<String> 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();
}
}

View File

@@ -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<Library> 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<Library> 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));
}

View File

@@ -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;

View File

@@ -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<LibraryPath> paths;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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<LibraryPath> paths;
private boolean watch;
private LibraryScanMode scanMode;
private BookFileType defaultBookFormat;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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",

View File

@@ -0,0 +1,7 @@
package com.adityachandel.booklore.model.enums;
public enum IconType {
PRIME_NG,
CUSTOM_SVG
}

View File

@@ -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<String, String> 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 = "<svg";
private static final String XML_DECLARATION = "<?xml";
private static final String SVG_END_TAG = "</svg>";
@PostConstruct
public void init() {
try {
Path iconsPath = getIconsSvgPath();
if (Files.exists(iconsPath)) {
loadIconsIntoCache();
log.info("Loaded {} SVG icons into cache", svgCache.size());
} else {
Files.createDirectories(iconsPath);
log.info("Created icons directory: {}", iconsPath);
}
} catch (IOException e) {
log.error("Failed to initialize IconService: {}", e.getMessage(), e);
}
}
private void loadIconsIntoCache() throws IOException {
Path iconsPath = getIconsSvgPath();
try (Stream<Path> 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<String> 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<Path> paths = Files.list(iconsPath)) {
List<String> 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<String> createPage(List<String> allIcons, int page, int size) {
int totalElements = allIcons.size();
int fromIndex = page * size;
int toIndex = Math.min(fromIndex + size, totalElements);
List<String> 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 <svg or <?xml");
}
if (!trimmed.contains(SVG_END_TAG)) {
throw ApiError.INVALID_INPUT.createException("Invalid SVG format: missing closing </svg> 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<String, String> getSvgCache() {
return svgCache;
}
}

View File

@@ -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);

View File

@@ -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));
}

View File

@@ -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())

View File

@@ -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);
}
}
}

View File

@@ -17,5 +17,6 @@ public class AppMigrationStartup {
appMigrationService.populateMetadataScoresOnce();
appMigrationService.populateFileHashesOnce();
appMigrationService.populateCoversAndResizeThumbnails();
appMigrationService.moveIconsToDataFolder();
}
}

View File

@@ -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
// ========================================

View File

@@ -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;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-atom-icon lucide-atom"><circle cx="12" cy="12" r="1"/><path d="M20.2 20.2c2.04-2.03.02-7.36-4.5-11.9-4.54-4.52-9.87-6.54-11.9-4.5-2.04 2.03-.02 7.36 4.5 11.9 4.54 4.52 9.87 6.54 11.9 4.5Z"/><path d="M15.7 15.7c4.52-4.54 6.54-9.87 4.5-11.9-2.03-2.04-7.36-.02-11.9 4.5-4.52 4.54-6.54 9.87-4.5 11.9 2.03 2.04 7.36.02 11.9-4.5Z"/></svg>

After

Width:  |  Height:  |  Size: 529 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-banana-icon lucide-banana"><path d="M4 13c3.5-2 8-2 10 2a5.5 5.5 0 0 1 8 5"/><path d="M5.15 17.89c5.52-1.52 8.65-6.89 7-12C11.55 4 11.5 2 13 2c3.22 0 5 5.5 5 8 0 6.5-4.2 12-10.49 12C5.11 22 2 22 2 20c0-1.5 1.14-1.55 3.15-2.11Z"/></svg>

After

Width:  |  Height:  |  Size: 432 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-beef-icon lucide-beef"><path d="M16.4 13.7A6.5 6.5 0 1 0 6.28 6.6c-1.1 3.13-.78 3.9-3.18 6.08A3 3 0 0 0 5 18c4 0 8.4-1.8 11.4-4.3"/><path d="m18.5 6 2.19 4.5a6.48 6.48 0 0 1-2.29 7.2C15.4 20.2 11 22 7 22a3 3 0 0 1-2.68-1.66L2.4 16.5"/><circle cx="12.5" cy="8.5" r="2.5"/></svg>

After

Width:  |  Height:  |  Size: 474 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-brain-icon lucide-brain"><path d="M12 18V5"/><path d="M15 13a4.17 4.17 0 0 1-3-4 4.17 4.17 0 0 1-3 4"/><path d="M17.598 6.5A3 3 0 1 0 12 5a3 3 0 1 0-5.598 1.5"/><path d="M17.997 5.125a4 4 0 0 1 2.526 5.77"/><path d="M18 18a4 4 0 0 0 2-7.464"/><path d="M19.967 17.483A4 4 0 1 1 12 18a4 4 0 1 1-7.967-.517"/><path d="M6 18a4 4 0 0 1-2-7.464"/><path d="M6.003 5.125a4 4 0 0 0-2.526 5.77"/></svg>

After

Width:  |  Height:  |  Size: 589 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chef-hat-icon lucide-chef-hat"><path d="M17 21a1 1 0 0 0 1-1v-5.35c0-.457.316-.844.727-1.041a4 4 0 0 0-2.134-7.589 5 5 0 0 0-9.186 0 4 4 0 0 0-2.134 7.588c.411.198.727.585.727 1.041V20a1 1 0 0 0 1 1Z"/><path d="M6 17h12"/></svg>

After

Width:  |  Height:  |  Size: 425 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-drama-icon lucide-drama"><path d="M10 11h.01"/><path d="M14 6h.01"/><path d="M18 6h.01"/><path d="M6.5 13.1h.01"/><path d="M22 5c0 9-4 12-6 12s-6-3-6-12c0-2 2-3 6-3s6 1 6 3"/><path d="M17.4 9.9c-.8.8-2 .8-2.8 0"/><path d="M10.1 7.1C9 7.2 7.7 7.7 6 8.6c-3.5 2-4.7 3.9-3.7 5.6 4.5 7.8 9.5 8.4 11.2 7.4.9-.5 1.9-2.1 1.9-4.7"/><path d="M9.1 16.5c.3-1.1 1.4-1.7 2.4-1.4"/></svg>

After

Width:  |  Height:  |  Size: 570 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ferris-wheel-icon lucide-ferris-wheel"><circle cx="12" cy="12" r="2"/><path d="M12 2v4"/><path d="m6.8 15-3.5 2"/><path d="m20.7 7-3.5 2"/><path d="M6.8 9 3.3 7"/><path d="m20.7 17-3.5-2"/><path d="m9 22 3-8 3 8"/><path d="M8 22h8"/><path d="M18 18.7a9 9 0 1 0-12 0"/></svg>

After

Width:  |  Height:  |  Size: 471 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-flame-kindling-icon lucide-flame-kindling"><path d="M12 2c1 3 2.5 3.5 3.5 4.5A5 5 0 0 1 17 10a5 5 0 1 1-10 0c0-.3 0-.6.1-.9a2 2 0 1 0 3.3-2C8 4.5 11 2 12 2Z"/><path d="m5 22 14-4"/><path d="m5 18 14 4"/></svg>

After

Width:  |  Height:  |  Size: 406 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ghost-icon lucide-ghost"><path d="M9 10h.01"/><path d="M15 10h.01"/><path d="M12 2a8 8 0 0 0-8 8v12l3-3 2.5 2.5L12 19l2.5 2.5L17 19l3 3V10a8 8 0 0 0-8-8z"/></svg>

After

Width:  |  Height:  |  Size: 359 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-hamburger-icon lucide-hamburger"><path d="M12 16H4a2 2 0 1 1 0-4h16a2 2 0 1 1 0 4h-4.25"/><path d="M5 12a2 2 0 0 1-2-2 9 7 0 0 1 18 0 2 2 0 0 1-2 2"/><path d="M5 16a2 2 0 0 0-2 2 3 3 0 0 0 3 3h12a3 3 0 0 0 3-3 2 2 0 0 0-2-2q0 0 0 0"/><path d="m6.67 12 6.13 4.6a2 2 0 0 0 2.8-.4l3.15-4.2"/></svg>

After

Width:  |  Height:  |  Size: 492 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-plane-icon lucide-plane"><path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></svg>

After

Width:  |  Height:  |  Size: 437 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-rocket-icon lucide-rocket"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg>

After

Width:  |  Height:  |  Size: 539 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-roller-coaster-icon lucide-roller-coaster"><path d="M6 19V5"/><path d="M10 19V6.8"/><path d="M14 19v-7.8"/><path d="M18 5v4"/><path d="M18 19v-6"/><path d="M22 19V9"/><path d="M2 19V9a4 4 0 0 1 4-4c2 0 4 1.33 6 4s4 4 6 4a4 4 0 1 0-3-6.65"/></svg>

After

Width:  |  Height:  |  Size: 443 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-rose-icon lucide-rose"><path d="M17 10h-1a4 4 0 1 1 4-4v.534"/><path d="M17 6h1a4 4 0 0 1 1.42 7.74l-2.29.87a6 6 0 0 1-5.339-10.68l2.069-1.31"/><path d="M4.5 17c2.8-.5 4.4 0 5.5.8s1.8 2.2 2.3 3.7c-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2"/><path d="M9.77 12C4 15 2 22 2 22"/><circle cx="17" cy="8" r="2"/></svg>

After

Width:  |  Height:  |  Size: 506 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-skull-icon lucide-skull"><path d="m12.5 17-.5-1-.5 1h1z"/><path d="M15 22a1 1 0 0 0 1-1v-1a2 2 0 0 0 1.56-3.25 8 8 0 1 0-11.12 0A2 2 0 0 0 8 20v1a1 1 0 0 0 1 1z"/><circle cx="15" cy="12" r="1"/><circle cx="9" cy="12" r="1"/></svg>

After

Width:  |  Height:  |  Size: 427 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-snail-icon lucide-snail"><path d="M2 13a6 6 0 1 0 12 0 4 4 0 1 0-8 0 2 2 0 0 0 4 0"/><circle cx="10" cy="13" r="8"/><path d="M2 21h12c4.4 0 8-3.6 8-8V7a2 2 0 1 0-4 0v6"/><path d="M18 3 19.1 5.2"/><path d="M22 3 20.9 5.2"/></svg>

After

Width:  |  Height:  |  Size: 425 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-swords-icon lucide-swords"><polyline points="14.5 17.5 3 6 3 3 6 3 17.5 14.5"/><line x1="13" x2="19" y1="19" y2="13"/><line x1="16" x2="20" y1="16" y2="20"/><line x1="19" x2="21" y1="21" y2="19"/><polyline points="14.5 6.5 18 3 21 3 21 6 17.5 9.5"/><line x1="5" x2="9" y1="14" y2="18"/><line x1="7" x2="4" y1="17" y2="20"/><line x1="3" x2="5" y1="19" y2="21"/></svg>

After

Width:  |  Height:  |  Size: 563 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-tent-tree-icon lucide-tent-tree"><circle cx="4" cy="4" r="2"/><path d="m14 5 3-3 3 3"/><path d="m14 10 3-3 3 3"/><path d="M17 14V2"/><path d="M17 14H7l-5 8h20Z"/><path d="M8 14v8"/><path d="m9 14 5 8"/></svg>

After

Width:  |  Height:  |  Size: 405 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-tree-palm-icon lucide-tree-palm"><path d="M13 8c0-2.76-2.46-5-5.5-5S2 5.24 2 8h2l1-1 1 1h4"/><path d="M13 7.14A5.82 5.82 0 0 1 16.5 6c3.04 0 5.5 2.24 5.5 5h-3l-1-1-1 1h-3"/><path d="M5.89 9.71c-2.15 2.15-2.3 5.47-.35 7.43l4.24-4.25.7-.7.71-.71 2.12-2.12c-1.95-1.96-5.27-1.8-7.42.35"/><path d="M11 15.5c.5 2.5-.17 4.5-1 6.5h4c2-5.5-.5-12-1-14"/></svg>

After

Width:  |  Height:  |  Size: 547 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-turntable-icon lucide-turntable"><path d="M10 12.01h.01"/><path d="M18 8v4a8 8 0 0 1-1.07 4"/><circle cx="10" cy="12" r="4"/><rect x="2" y="4" width="20" height="16" rx="2"/></svg>

After

Width:  |  Height:  |  Size: 377 B

View File

@@ -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 = "<svg><rect width=\"100\" height=\"100\"/></svg>";
private static final String SVG_DATA_XML = "<?xml version=\"1.0\"?><svg><rect width=\"100\" height=\"100\"/></svg>";
private static final String INVALID_SVG_DATA = "<rect></rect>";
private static final String INVALID_SVG_DATA_NO_END = "<svg><rect></rect>";
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<Path> 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<Path> 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<String> page = iconService.getIconNames(0, 2);
assertEquals(2, page.getContent().size());
assertEquals(5, page.getTotalElements());
assertEquals(List.of("icon0", "icon1"), page.getContent());
Page<String> 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("<svg><circle r=\"50\"/></svg>");
assertThrows(APIException.class, () -> iconService.saveSvgIcon(req));
assertEquals(initialSize, iconService.getSvgCache().size());
}
}

View File

@@ -60,11 +60,20 @@
} @else {
<div class="selected-icon-display">
<div class="icon-preview">
<i [class]="selectedIcon"></i>
<app-icon-display
[icon]="selectedIcon"
size="24px"
/>
</div>
<div class="icon-info">
<span class="icon-label">Selected Icon</span>
<span class="icon-name">{{ selectedIcon }}</span>
<span class="icon-name">
@if (selectedIcon.type === 'PRIME_NG') {
{{ selectedIcon.value }}
} @else {
{{ selectedIcon.value }}
}
</span>
</div>
<p-button
icon="pi pi-times"
@@ -106,7 +115,7 @@
label="Create Shelf"
icon="pi pi-plus"
severity="success"
(onClick)="saveNewShelf()"
(onClick)="createShelf()"
[disabled]="!shelfName.trim()"
/>
</div>

View File

@@ -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 {

View File

@@ -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<Shelf> = {
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<Shelf> = {
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);
}
});
}
}

View File

@@ -36,7 +36,11 @@
@if (selectedIcon) {
<div class="icon-container">
<div class="icon-wrapper">
<i [class]="selectedIcon" class="icon"></i>
<app-icon-display
[icon]="selectedIcon"
size="24px"
iconClass="icon"
/>
</div>
<p-button icon="pi pi-times" (onClick)="clearSelectedIcon()" [rounded]="true" [text]="true" severity="danger"></p-button>
</div>

View File

@@ -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({

View File

@@ -7,6 +7,7 @@ export interface Library {
id?: number;
name: string;
icon: string;
iconType?: 'PRIME_NG' | 'CUSTOM_SVG';
watch: boolean;
fileNamingPattern?: string;
sort?: SortOption;

View File

@@ -4,5 +4,6 @@ export interface Shelf {
id?: number;
name: string;
icon: string;
iconType?: 'PRIME_NG' | 'CUSTOM_SVG';
sort?: SortOption;
}

View File

@@ -94,11 +94,20 @@
} @else {
<div class="selected-icon-display">
<div class="icon-preview">
<i [class]="selectedIcon"></i>
<app-icon-display
[icon]="selectedIcon"
size="24px"
/>
</div>
<div class="icon-info">
<span class="icon-label">Selected Icon</span>
<span class="icon-name">{{ selectedIcon }}</span>
<span class="icon-name">
@if (selectedIcon.type === 'PRIME_NG') {
{{ selectedIcon.value }}
} @else {
{{ selectedIcon.value }} (Custom)
}
</span>
</div>
<p-button
icon="pi pi-times"

View File

@@ -11,21 +11,22 @@ import {InputText} from 'primeng/inputtext';
import {BookFileType, Library, LibraryScanMode} from '../book/model/library.model';
import {ToggleSwitch} from 'primeng/toggleswitch';
import {Tooltip} from 'primeng/tooltip';
import {IconPickerService} from '../../shared/service/icon-picker.service';
import {IconPickerService, IconSelection} from '../../shared/service/icon-picker.service';
import {Select} from 'primeng/select';
import {Button} from 'primeng/button';
import {IconDisplayComponent} from '../../shared/components/icon-display/icon-display.component';
@Component({
selector: 'app-library-creator',
standalone: true,
templateUrl: './library-creator.component.html',
imports: [TableModule, StepPanel, FormsModule, InputText, Stepper, StepList, Step, StepPanels, ToggleSwitch, Tooltip, Select, Button],
imports: [TableModule, StepPanel, FormsModule, InputText, Stepper, StepList, Step, StepPanels, ToggleSwitch, Tooltip, Select, Button, IconDisplayComponent],
styleUrl: './library-creator.component.scss'
})
export class LibraryCreatorComponent implements OnInit {
chosenLibraryName: string = '';
folders: string[] = [];
selectedIcon: string | null = null;
selectedIcon: IconSelection | null = null;
mode!: string;
library!: Library | undefined;
@@ -61,10 +62,16 @@ export class LibraryCreatorComponent implements OnInit {
this.mode = data.mode;
this.library = this.libraryService.findLibraryById(data.libraryId);
if (this.library) {
const {name, icon, paths, watch, scanMode, defaultBookFormat} = this.library;
const {name, icon, iconType, paths, watch, scanMode, defaultBookFormat} = this.library;
this.chosenLibraryName = name;
this.editModeLibraryName = name;
this.selectedIcon = `pi pi-${icon}`;
if (iconType === 'CUSTOM_SVG') {
this.selectedIcon = {type: 'CUSTOM_SVG', value: icon};
} else {
this.selectedIcon = {type: 'PRIME_NG', value: `pi pi-${icon}`};
}
this.watch = watch;
this.scanMode = scanMode || 'FILE_AS_BOOK';
this.defaultBookFormat = defaultBookFormat || undefined;
@@ -145,10 +152,14 @@ export class LibraryCreatorComponent implements OnInit {
}
createOrUpdateLibrary(): void {
const iconValue = this.selectedIcon?.value || 'heart';
const iconType = this.selectedIcon?.type || 'PRIME_NG';
if (this.mode === 'edit') {
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,
@@ -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,

View File

@@ -29,13 +29,22 @@
<div class="icon-picker-section">
<div class="icon-picker-wrapper">
<label>Shelf Icon</label>
<p-button
[icon]="form.get('icon')?.value || 'pi pi-plus'"
(click)="openIconPicker()"
[outlined]="form.get('icon')?.value"
[severity]="form.get('icon')?.value ? 'success' : 'danger'"
[label]="form.get('icon')?.value ? 'Icon Selected' : 'Select Icon'">
</p-button>
<div class="icon-display-container" (click)="openIconPicker()">
@if (selectedIcon) {
<app-icon-display
[icon]="selectedIcon"
[size]="'16px'"
iconClass="selected-icon"
alt="Selected shelf icon">
</app-icon-display>
<span class="icon-label">Click to change</span>
} @else {
<div class="icon-placeholder">
<i class="pi pi-plus"></i>
<span>Select Icon</span>
</div>
}
</div>
</div>
</div>
@if (isAdmin) {

View File

@@ -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) {

View File

@@ -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<RuleField, FullFieldConfig> = {
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<string | null>(data?.name ?? null, {nonNullable: true, validators: [Validators.required]}),
icon: new FormControl<string | null>(data?.icon ?? null, {nonNullable: true, validators: [Validators.required]}),
icon: new FormControl<string | null>(iconValue, {nonNullable: true, validators: [Validators.required]}),
isPublic: new FormControl<boolean>(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,

View File

@@ -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<MagicShelf> {
saveShelf(data: { id?: number; name: string | null; icon: string | null; iconType?: 'PRIME_NG' | 'CUSTOM_SVG'; group: any, isPublic?: boolean | null }): Observable<MagicShelf> {
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
};

View File

@@ -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;
},

View File

@@ -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(' - ');
}

View File

@@ -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;

View File

@@ -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') {
<i [class]="getPrimeNgIconClass(icon.value)" [ngClass]="iconClass" [ngStyle]="iconStyle"></i>
} @else {
<img
[src]="getIconUrl(icon.value)"
[alt]="alt"
[ngClass]="iconClass"
[ngStyle]="getImageStyle()"
/>
}
}
`,
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<string, string> = {};
@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<string, string> {
return {
width: this.size,
height: this.size,
objectFit: 'contain',
...this.iconStyle
};
}
}

View File

@@ -1,12 +1,126 @@
<input type="text" placeholder="Search icons..." [(ngModel)]="searchText" class="icon-search"/>
<p-tabs [(value)]="activeTabIndex">
<p-tablist>
<p-tab value="0">Prime Icons</p-tab>
<p-tab value="1">SVG Icons</p-tab>
<p-tab value="2">Add SVG Icon</p-tab>
</p-tablist>
<p-tabpanels>
<p-tabpanel value="0">
<input type="text" placeholder="Search Prime icons..." [(ngModel)]="searchText" class="icon-search"/>
<div class="icon-grid">
@for (icon of filteredIcons(); track icon) {
<div
class="icon-item"
(click)="selectIcon(icon)"
[class.selected]="icon === selectedIcon">
<i [class]="icon"></i>
</div>
}
</div>
<div class="icon-grid">
@for (icon of filteredIcons(); track icon) {
<div
class="icon-item"
(click)="selectIcon(icon)"
[class.selected]="icon === selectedIcon"
[title]="icon">
<i [class]="icon"></i>
</div>
}
</div>
</p-tabpanel>
<p-tabpanel value="1">
<div class="svg-browse-container">
@if (isLoadingSvgIcons) {
<div class="loading-message">Loading icons...</div>
} @else if (svgIconsError) {
<div class="error-message">{{ svgIconsError }}</div>
} @else {
<input type="text" placeholder="Search SVG icons..." [(ngModel)]="svgSearchText" class="icon-search"/>
<div class="icon-grid">
@for (iconName of filteredSvgIcons(); track iconName) {
<div
class="icon-item svg-icon-item"
(click)="selectSvgIcon(iconName)"
[class.selected]="iconName === selectedSvgIcon"
[title]="iconName"
draggable="true"
(dragstart)="onSvgIconDragStart(iconName)"
(dragend)="onSvgIconDragEnd()"
>
<img
[src]="getSvgIconUrl(iconName)"
[alt]="iconName"
(error)="onImageError($event)"
class="svg-icon-image"/>
</div>
}
</div>
@if (totalSvgPages > 1) {
<div class="pagination-controls">
<button
(click)="loadSvgIcons(currentSvgPage - 1)"
[disabled]="currentSvgPage === 0"
class="pagination-button">
Previous
</button>
<span class="pagination-info">
Page {{ currentSvgPage + 1 }} of {{ totalSvgPages }}
</span>
<button
(click)="loadSvgIcons(currentSvgPage + 1)"
[disabled]="currentSvgPage >= totalSvgPages - 1"
class="pagination-button">
Next
</button>
</div>
}
<div style="display: flex; align-items: center; position: fixed; right: 32px; bottom: 32px; z-index: 101;">
<div
class="svg-trash-area"
[class.trash-hover]="isTrashHover"
(dragover)="onTrashDragOver($event)"
(dragleave)="onTrashDragLeave($event)"
(drop)="onTrashDrop($event)"
>
<i class="pi pi-trash"></i>
<span>Drag here to delete icon</span>
</div>
</div>
}
</div>
</p-tabpanel>
<p-tabpanel value="2">
<div class="svg-paste-container">
<input
type="text"
[(ngModel)]="svgName"
placeholder="Enter icon name for saving"
class="svg-name-input"
/>
<textarea
[(ngModel)]="svgContent"
(ngModelChange)="onSvgContentChange()"
placeholder="Paste your SVG code here..."
class="svg-textarea"
rows="8"></textarea>
@if (svgContent && svgPreview) {
<div class="svg-preview-section">
<h4 class="preview-title">Preview</h4>
<div class="svg-preview" [innerHTML]="svgPreview"></div>
</div>
}
@if (errorMessage) {
<div class="error-message">{{ errorMessage }}</div>
}
<div class="button-container">
<p-button
(onClick)="saveSvg()"
[disabled]="!svgContent || !svgName"
[loading]="isLoading"
label="Save SVG"
severity="primary">
</p-button>
</div>
</div>
</p-tabpanel>
</p-tabpanels>
</p-tabs>

View File

@@ -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;
}
}
}

View File

@@ -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 <svg> 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('<svg')) {
this.svgPreview = null;
this.errorMessage = this.ERROR_MESSAGES.MISSING_SVG_TAG;
return;
}
try {
this.svgPreview = this.sanitizer.bypassSecurityTrustHtml(this.svgContent);
} catch {
this.svgPreview = null;
this.errorMessage = this.ERROR_MESSAGES.PARSE_ERROR;
}
}
saveSvg(): void {
const validationError = this.validateSvgInput();
if (validationError) {
this.errorMessage = validationError;
return;
}
this.isLoading = true;
this.errorMessage = '';
this.iconService.saveSvgIcon(this.svgContent, this.svgName).subscribe({
next: () => {
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('<svg')) {
return this.ERROR_MESSAGES.INVALID_SVG;
}
if (this.svgContent.length > 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
});
}
});
}
}

View File

@@ -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}`);
}
}

View File

@@ -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),
});

View File

@@ -53,7 +53,11 @@
tabindex="0"
pRipple
>
<i [ngClass]="item.icon" class="layout-menuitem-icon"></i>
<app-icon-display
[icon]="getIconSelection()"
iconClass="layout-menuitem-icon"
size="19px"
></app-icon-display>
<span class="layout-menuitem-text menu-item-text">
{{ item.label }}
</span>

View File

@@ -71,6 +71,7 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-left: 0.5rem;
}
.menu-item-end-content {

View File

@@ -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;

View File

@@ -23,10 +23,4 @@ export class BackgroundUploadService {
map(resp => resp?.url)
);
}
resetToDefault(): Observable<void> {
return this.http.delete<void>(this.baseUrl).pipe(
tap(() => console.log('Background reset to default'))
);
}
}

View File

@@ -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<string> {
open(): Observable<IconSelection> {
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<string>;
return ref!.onClose as Observable<IconSelection>;
}
}

View File

@@ -100,4 +100,9 @@ export class UrlHelperService {
}
});
}
getIconUrl(iconName: string): string {
const url = `${this.mediaBaseUrl}/icon/${iconName}`;
return this.appendToken(url);
}
}

View File

@@ -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<T> {
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<any> {
return this.http.post(this.baseUrl, {
svgData: svgContent,
svgName: svgName
});
}
getIconNames(page: number = 0, size: number = 50): Observable<PageResponse<string>> {
return this.http.get<PageResponse<string>>(this.baseUrl, {
params: { page: page.toString(), size: size.toString() }
});
}
deleteSvgIcon(svgName: string): Observable<any> {
return this.http.delete(`${this.baseUrl}/${encodeURIComponent(svgName)}`);
}
}

View File

@@ -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;