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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.adityachandel.booklore.model.enums;
|
||||
|
||||
public enum IconType {
|
||||
PRIME_NG,
|
||||
CUSTOM_SVG
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -17,5 +17,6 @@ public class AppMigrationStartup {
|
||||
appMigrationService.populateMetadataScoresOnce();
|
||||
appMigrationService.populateFileHashesOnce();
|
||||
appMigrationService.populateCoversAndResizeThumbnails();
|
||||
appMigrationService.moveIconsToDataFolder();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ========================================
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface Library {
|
||||
id?: number;
|
||||
name: string;
|
||||
icon: string;
|
||||
iconType?: 'PRIME_NG' | 'CUSTOM_SVG';
|
||||
watch: boolean;
|
||||
fileNamingPattern?: string;
|
||||
sort?: SortOption;
|
||||
|
||||
@@ -4,5 +4,6 @@ export interface Shelf {
|
||||
id?: number;
|
||||
name: string;
|
||||
icon: string;
|
||||
iconType?: 'PRIME_NG' | 'CUSTOM_SVG';
|
||||
sort?: SortOption;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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(' - ');
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
44
booklore-ui/src/app/shared/helpers/icon-categories.helper.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.menu-item-end-content {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,4 +100,9 @@ export class UrlHelperService {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getIconUrl(iconName: string): string {
|
||||
const url = `${this.mediaBaseUrl}/icon/${iconName}`;
|
||||
return this.appendToken(url);
|
||||
}
|
||||
}
|
||||
|
||||
39
booklore-ui/src/app/shared/services/icon.service.ts
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||