diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMediaController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMediaController.java index 31ec5844..651483b8 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMediaController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMediaController.java @@ -119,14 +119,4 @@ public class BookMediaController { return ResponseEntity.internalServerError().build(); } } - - @Operation(summary = "Get SVG icon", description = "Retrieve an SVG icon by its name.") - @ApiResponse(responseCode = "200", description = "SVG icon returned successfully") - @GetMapping("/icon/{name}") - public ResponseEntity getSvgIcon(@Parameter(description = "Name of the icon") @PathVariable String name) { - String svgData = iconService.getSvgIcon(name); - return ResponseEntity.ok() - .contentType(MediaType.valueOf("image/svg+xml")) - .body(svgData); - } } \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/IconController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/IconController.java index 03799c06..fee758a9 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/IconController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/IconController.java @@ -1,6 +1,8 @@ package com.adityachandel.booklore.controller; +import com.adityachandel.booklore.model.dto.request.SvgIconBatchRequest; import com.adityachandel.booklore.model.dto.request.SvgIconCreateRequest; +import com.adityachandel.booklore.model.dto.response.SvgIconBatchResponse; import com.adityachandel.booklore.service.IconService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -28,6 +30,24 @@ public class IconController { return ResponseEntity.ok().build(); } + @Operation(summary = "Save multiple SVG icons", description = "Saves multiple SVG icons to the system in batch.") + @ApiResponse(responseCode = "200", description = "Batch save completed with detailed results") + @PostMapping("/batch") + public ResponseEntity saveBatchSvgIcons(@Valid @RequestBody SvgIconBatchRequest request) { + SvgIconBatchResponse response = iconService.saveBatchSvgIcons(request.getIcons()); + return ResponseEntity.ok(response); + } + + @Operation(summary = "Get SVG icon content", description = "Retrieve the SVG content of an icon by its name.") + @ApiResponse(responseCode = "200", description = "SVG icon content retrieved successfully") + @GetMapping("/{svgName}/content") + public ResponseEntity getSvgIconContent(@Parameter(description = "SVG icon name") @PathVariable String svgName) { + String svgContent = iconService.getSvgIcon(svgName); + return ResponseEntity.ok() + .header("Content-Type", "image/svg+xml") + .body(svgContent); + } + @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 diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/SvgIconBatchRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/SvgIconBatchRequest.java new file mode 100644 index 00000000..2e81ee57 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/SvgIconBatchRequest.java @@ -0,0 +1,20 @@ +package com.adityachandel.booklore.model.dto.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SvgIconBatchRequest { + + @NotEmpty(message = "Icons list cannot be empty") + @Valid + private List icons; +} + diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/SvgIconBatchResponse.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/SvgIconBatchResponse.java new file mode 100644 index 00000000..14e1b80d --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/SvgIconBatchResponse.java @@ -0,0 +1,30 @@ +package com.adityachandel.booklore.model.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SvgIconBatchResponse { + private int totalRequested; + private int successCount; + private int failureCount; + private List results; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class IconSaveResult { + private String iconName; + private boolean success; + private String errorMessage; + } +} + diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/IconService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/IconService.java index dc19db33..14e29566 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/IconService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/IconService.java @@ -3,6 +3,7 @@ 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 com.adityachandel.booklore.model.dto.response.SvgIconBatchResponse; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -16,6 +17,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.concurrent.ConcurrentHashMap; @@ -97,6 +99,44 @@ public class IconService { } } + public SvgIconBatchResponse saveBatchSvgIcons(List requests) { + if (requests == null || requests.isEmpty()) { + throw ApiError.INVALID_INPUT.createException("Icons list cannot be empty"); + } + + List results = new ArrayList<>(); + int successCount = 0; + int failureCount = 0; + + for (SvgIconCreateRequest request : requests) { + try { + saveSvgIcon(request); + results.add(SvgIconBatchResponse.IconSaveResult.builder() + .iconName(request.getSvgName()) + .success(true) + .build()); + successCount++; + } catch (Exception e) { + log.warn("Failed to save icon '{}': {}", request.getSvgName(), e.getMessage()); + results.add(SvgIconBatchResponse.IconSaveResult.builder() + .iconName(request.getSvgName()) + .success(false) + .errorMessage(e.getMessage()) + .build()); + failureCount++; + } + } + + log.info("Batch save completed: {} successful, {} failed", successCount, failureCount); + + return SvgIconBatchResponse.builder() + .totalRequested(requests.size()) + .successCount(successCount) + .failureCount(failureCount) + .results(results) + .build(); + } + public String getSvgIcon(String name) { String filename = normalizeFilename(name); String cachedSvg = svgCache.get(filename); diff --git a/booklore-ui/src/app/shared/components/icon-display/icon-display.component.ts b/booklore-ui/src/app/shared/components/icon-display/icon-display.component.ts index 7c03f933..0e339c53 100644 --- a/booklore-ui/src/app/shared/components/icon-display/icon-display.component.ts +++ b/booklore-ui/src/app/shared/components/icon-display/icon-display.component.ts @@ -1,7 +1,9 @@ -import {Component, inject, Input} from '@angular/core'; +import {Component, inject, Input, OnInit, OnChanges, SimpleChanges} from '@angular/core'; import {IconSelection} from '../../service/icon-picker.service'; -import {UrlHelperService} from '../../service/url-helper.service'; import {NgClass, NgStyle} from '@angular/common'; +import {IconCacheService} from '../../services/icon-cache.service'; +import {IconService} from '../../services/icon.service'; +import {DomSanitizer, SafeHtml} from '@angular/platform-browser'; @Component({ selector: 'app-icon-display', @@ -12,12 +14,12 @@ import {NgClass, NgStyle} from '@angular/common'; @if (icon.type === 'PRIME_NG') { } @else { - + [ngStyle]="getSvgStyle()" + > } } `, @@ -27,16 +29,58 @@ import {NgClass, NgStyle} from '@angular/common'; align-items: center; justify-content: center; } + + .svg-icon-inline { + display: inline-flex; + align-items: center; + justify-content: center; + } + + .svg-icon-inline :deep(svg) { + width: 100%; + height: 100%; + display: block; + } `] }) -export class IconDisplayComponent { +export class IconDisplayComponent implements OnInit, OnChanges { @Input() icon: IconSelection | null = null; @Input() iconClass: string = 'icon'; @Input() iconStyle: Record = {}; - @Input() size: string = '24px'; + @Input() size: string = '16px'; @Input() alt: string = 'Icon'; - private urlHelper = inject(UrlHelperService); + private iconCache = inject(IconCacheService); + private iconService = inject(IconService); + private sanitizer = inject(DomSanitizer); + + ngOnInit(): void { + this.loadIconIfNeeded(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['icon'] && this.icon?.type === 'CUSTOM_SVG') { + this.loadIconIfNeeded(); + } + } + + private loadIconIfNeeded(): void { + if (this.icon?.type === 'CUSTOM_SVG') { + this.iconService.getSanitizedSvgContent(this.icon.value).subscribe({ + error: () => { + if (this.icon?.type === 'CUSTOM_SVG') { + const errorSvg = ''; + const sanitized = this.sanitizer.bypassSecurityTrustHtml(errorSvg); + this.iconCache.cacheIcon(this.icon.value, errorSvg, sanitized); + } + } + }); + } + } + + getSvgContent(iconName: string): SafeHtml | null { + return this.iconCache.getCachedSanitized(iconName) || null; + } getPrimeNgIconClass(iconValue: string): string { if (iconValue.startsWith('pi pi-')) { @@ -48,15 +92,10 @@ export class IconDisplayComponent { return `pi pi-${iconValue}`; } - getIconUrl(iconName: string): string { - return this.urlHelper.getIconUrl(iconName); - } - - getImageStyle(): Record { + getSvgStyle(): Record { return { width: this.size, height: this.size, - objectFit: 'contain', ...this.iconStyle }; } diff --git a/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.html b/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.html index f31037c1..e4a3e09d 100644 --- a/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.html +++ b/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.html @@ -2,7 +2,7 @@ Prime Icons SVG Icons - Add SVG Icon + Add SVG Icon(s) @@ -40,32 +40,34 @@ (dragstart)="onSvgIconDragStart(iconName)" (dragend)="onSvgIconDragEnd()" > - +
} @if (totalSvgPages > 1) {
- + label="Prev" + icon="pi pi-chevron-left" + outlined> + Page {{ currentSvgPage + 1 }} of {{ totalSvgPages }} - + label="Next" + icon="pi pi-chevron-right" + iconPos="right" + outlined> +
} @@ -86,40 +88,95 @@
- +
+ - + - @if (svgContent && svgPreview) { -
-

Preview

-
+ @if (svgContent && svgPreview) { +
+

Preview

+
+
+ } + + @if (errorMessage) { +
{{ errorMessage }}
+ } + +
+ + +
+
+ + @if (svgEntries.length > 0) { +
+
+

+ Queued Icons ({{ svgEntries.length }}) +

+ + +
+ +
+ @for (entry of svgEntries; track entry.name; let i = $index) { +
+
+ {{ entry.name }} + +
+
+ @if (entry.error) { +
{{ entry.error }}
+ } +
+ } +
+ + @if (batchErrorMessage) { +
{{ batchErrorMessage }}
+ } + +
+ + +
} - - @if (errorMessage) { -
{{ errorMessage }}
- } - -
- - -
diff --git a/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.scss b/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.scss index cf68332a..3e6cd667 100644 --- a/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.scss +++ b/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.scss @@ -10,10 +10,10 @@ cursor: pointer; justify-content: space-evenly; align-items: flex-start; -} -.icon-item > i { - font-size: 1.25rem; + > i { + font-size: 1.25rem; + } } .icon-search { @@ -25,22 +25,30 @@ margin-bottom: 15px; margin-top: 15px; box-sizing: border-box; -} -.icon-search:focus { - border-color: var(--primary-color); - outline: none; -} + &:focus { + border-color: var(--primary-color); + outline: none; + } -.icon-search::placeholder { - color: var(--text-secondary-color); - opacity: 1; + &::placeholder { + color: var(--text-secondary-color); + opacity: 1; + } } .svg-paste-container { + display: flex; + flex-direction: column; + gap: 20px; +} + +.svg-input-section { display: flex; flex-direction: column; gap: 15px; + padding-bottom: 20px; + border-bottom: 1px solid var(--border-color); } .svg-textarea { @@ -129,25 +137,6 @@ } } -.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; @@ -155,36 +144,16 @@ 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; - } - } + border-top: 1px solid var(--border-color); .pagination-info { color: #666; font-size: 0.9rem; + font-weight: 500; } } .svg-trash-area { - position: fixed; - right: 32px; - bottom: 32px; - z-index: 100; display: flex; align-items: center; gap: 10px; @@ -194,7 +163,6 @@ 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 { @@ -225,3 +193,117 @@ } } } + +.svg-entries-section { + display: flex; + flex-direction: column; + gap: 15px; + + .entries-header { + display: flex; + justify-content: space-between; + align-items: center; + + .entries-title { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--text-primary-color); + } + } + + .entries-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 15px; + max-height: 400px; + overflow-y: auto; + padding: 10px 0; + } + + .entry-card { + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 12px; + background-color: var(--surface-ground); + transition: box-shadow 0.2s; + + &:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + } + + &.has-error { + border-color: #e74c3c; + background-color: #fff5f5; + } + + .entry-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + + .entry-name { + font-size: 13px; + font-weight: 600; + color: var(--text-primary-color); + word-break: break-all; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .entry-remove { + background: none; + border: none; + cursor: pointer; + padding: 4px; + color: #e74c3c; + transition: color 0.2s; + + &:hover { + color: #c0392b; + } + + i { + font-size: 14px; + } + } + } + + .entry-preview { + display: flex; + justify-content: center; + align-items: center; + min-height: 80px; + max-height: 100px; + background-color: var(--ground-background); + border-radius: 4px; + padding: 8px; + overflow: hidden; + + ::ng-deep svg { + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + } + } + + .entry-error { + margin-top: 8px; + color: #e74c3c; + font-size: 12px; + font-weight: 500; + line-height: 1.4; + word-break: break-word; + } + } + + .batch-actions { + display: flex; + justify-content: flex-end; + padding-top: 10px; + border-top: 1px solid var(--border-color); + } +} diff --git a/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.ts b/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.ts index 441bd0b4..4b378896 100644 --- a/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.ts +++ b/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.ts @@ -2,6 +2,7 @@ import {Component, inject, OnInit} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {DynamicDialogRef} from 'primeng/dynamicdialog'; import {IconService} from '../../services/icon.service'; +import {IconCacheService} from '../../services/icon-cache.service'; import {DomSanitizer, SafeHtml} from '@angular/platform-browser'; import {UrlHelperService} from '../../service/url-helper.service'; import {MessageService} from 'primeng/api'; @@ -9,6 +10,26 @@ import {IconCategoriesHelper} from '../../helpers/icon-categories.helper'; import {Button} from 'primeng/button'; import {TabsModule} from 'primeng/tabs'; +interface SvgEntry { + name: string; + content: string; + preview: SafeHtml | null; + error: string; +} + +interface IconSaveResult { + iconName: string; + success: boolean; + errorMessage: string; +} + +interface SvgIconBatchResponse { + totalRequested: number; + successCount: number; + failureCount: number; + results: IconSaveResult[]; +} + @Component({ selector: 'app-icon-picker-component', imports: [ @@ -35,12 +56,12 @@ export class IconPickerComponent implements OnInit { 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); + iconCache = inject(IconCacheService); sanitizer = inject(DomSanitizer); urlHelper = inject(UrlHelperService); messageService = inject(MessageService); @@ -65,9 +86,12 @@ export class IconPickerComponent implements OnInit { svgContent: string = ''; svgName: string = ''; svgPreview: SafeHtml | null = null; - isLoading: boolean = false; errorMessage: string = ''; + svgEntries: SvgEntry[] = []; + isSavingBatch: boolean = false; + batchErrorMessage: string = ''; + svgIcons: string[] = []; svgSearchText: string = ''; currentSvgPage: number = 0; @@ -110,6 +134,8 @@ export class IconPickerComponent implements OnInit { this.currentSvgPage = response.number; this.totalSvgPages = response.totalPages; this.isLoadingSvgIcons = false; + + this.preloadSvgContent(response.content); }, error: () => { this.isLoadingSvgIcons = false; @@ -118,13 +144,28 @@ export class IconPickerComponent implements OnInit { }); } - getSvgIconUrl(iconName: string): string { - return this.urlHelper.getIconUrl(iconName); + private preloadSvgContent(iconNames: string[]): void { + iconNames.forEach(iconName => { + if (!this.iconCache.isCached(iconName)) { + this.loadSvgContent(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'; + private loadSvgContent(iconName: string): void { + this.iconService.getSanitizedSvgContent(iconName).subscribe({ + next: () => { + }, + error: () => { + const errorSvg = ''; + const sanitized = this.sanitizer.bypassSecurityTrustHtml(errorSvg); + this.iconCache.cacheIcon(iconName, errorSvg, sanitized); + } + }); + } + + getSvgContent(iconName: string): SafeHtml | null { + return this.iconCache.getCachedSanitized(iconName) || null; } selectSvgIcon(iconName: string): void { @@ -155,30 +196,105 @@ export class IconPickerComponent implements OnInit { } } - saveSvg(): void { + addSvgEntry(): void { const validationError = this.validateSvgInput(); if (validationError) { this.errorMessage = validationError; return; } - this.isLoading = true; - this.errorMessage = ''; + const existingIndex = this.svgEntries.findIndex(entry => entry.name === this.svgName); + if (existingIndex !== -1) { + this.svgEntries[existingIndex] = { + name: this.svgName, + content: this.svgContent, + preview: this.svgPreview, + error: '' + }; + } else { + this.svgEntries.push({ + name: this.svgName, + content: this.svgContent, + preview: this.svgPreview, + error: '' + }); + } - this.iconService.saveSvgIcon(this.svgContent, this.svgName).subscribe({ - next: () => { - this.isLoading = false; - this.handleSuccessfulSave(); + this.resetSvgForm(); + this.errorMessage = ''; + } + + removeSvgEntry(index: number): void { + this.svgEntries.splice(index, 1); + } + + clearAllEntries(): void { + this.svgEntries = []; + this.batchErrorMessage = ''; + } + + saveAllSvgs(): void { + if (this.svgEntries.length === 0) { + this.batchErrorMessage = 'No SVG icons to save'; + return; + } + + this.isSavingBatch = true; + this.batchErrorMessage = ''; + + this.svgEntries.forEach(entry => entry.error = ''); + + const svgData = this.svgEntries.map(entry => ({ + svgName: entry.name, + svgData: entry.content + })); + + this.iconService.saveBatchSvgIcons(svgData).subscribe({ + next: (response: SvgIconBatchResponse) => { + this.isSavingBatch = false; + this.handleBatchSaveResponse(response); }, error: (error) => { - this.isLoading = false; - this.errorMessage = error.error?.details?.join(', ') - || error.error?.message - || this.ERROR_MESSAGES.SAVE_ERROR; + this.isSavingBatch = false; + this.batchErrorMessage = error.error?.message || 'Failed to save SVG icons. Please try again.'; } }); } + private handleBatchSaveResponse(response: SvgIconBatchResponse): void { + if (response.failureCount === 0) { + this.handleSuccessfulBatchSave(); + return; + } + + response.results.forEach(result => { + if (!result.success) { + const entryIndex = this.svgEntries.findIndex(entry => entry.name === result.iconName); + if (entryIndex !== -1) { + this.svgEntries[entryIndex].error = result.errorMessage; + } + } + }); + + const successfulNames = response.results + .filter(result => result.success) + .map(result => result.iconName); + + this.svgEntries = this.svgEntries.filter(entry => !successfulNames.includes(entry.name)); + + if (response.successCount > 0) { + this.messageService.add({ + severity: 'warn', + summary: 'Partial Success', + detail: `${response.successCount} of ${response.totalRequested} icon(s) saved successfully. ${response.failureCount} failed.`, + life: 5000 + }); + this.loadSvgIcons(0); + } else { + this.batchErrorMessage = `Failed to save ${response.failureCount} icon(s). Please fix the errors and try again.`; + } + } + private validateSvgInput(): string | null { if (!this.svgContent.trim()) { return this.ERROR_MESSAGES.NO_CONTENT; @@ -207,12 +323,17 @@ export class IconPickerComponent implements OnInit { return null; } - private handleSuccessfulSave(): void { + private handleSuccessfulBatchSave(): void { + this.messageService.add({ + severity: 'success', + summary: 'Icons Saved', + detail: `${this.svgEntries.length} SVG icon(s) saved successfully.`, + life: 3000 + }); + this.activeTabIndex = '1'; - if (!this.svgIcons.includes(this.svgName)) { - this.svgIcons.unshift(this.svgName); - } - this.selectedSvgIcon = this.svgName; + this.loadSvgIcons(0); + this.clearAllEntries(); this.resetSvgForm(); } diff --git a/booklore-ui/src/app/shared/layout/component/layout-menu/app.menuitem.component.html b/booklore-ui/src/app/shared/layout/component/layout-menu/app.menuitem.component.html index 6818163c..288eac81 100644 --- a/booklore-ui/src/app/shared/layout/component/layout-menu/app.menuitem.component.html +++ b/booklore-ui/src/app/shared/layout/component/layout-menu/app.menuitem.component.html @@ -56,7 +56,7 @@ {{ item.label }} diff --git a/booklore-ui/src/app/shared/services/icon-cache.service.ts b/booklore-ui/src/app/shared/services/icon-cache.service.ts new file mode 100644 index 00000000..f021081c --- /dev/null +++ b/booklore-ui/src/app/shared/services/icon-cache.service.ts @@ -0,0 +1,93 @@ +import {Injectable} from '@angular/core'; +import {SafeHtml} from '@angular/platform-browser'; +import {Observable, BehaviorSubject} from 'rxjs'; + +interface CachedIcon { + content: string; + sanitized: SafeHtml; + timestamp: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class IconCacheService { + private cache = new Map(); + private readonly CACHE_DURATION_MS = 1000 * 60 * 60; + + private cacheUpdate$ = new BehaviorSubject(null); + + getCachedContent(iconName: string): string | null { + const cached = this.cache.get(iconName); + + if (!cached) { + return null; + } + + if (Date.now() - cached.timestamp > this.CACHE_DURATION_MS) { + this.cache.delete(iconName); + return null; + } + + return cached.content; + } + + getCachedSanitized(iconName: string): SafeHtml | null { + const cached = this.cache.get(iconName); + + if (!cached) { + return null; + } + + if (Date.now() - cached.timestamp > this.CACHE_DURATION_MS) { + this.cache.delete(iconName); + return null; + } + + return cached.sanitized; + } + + cacheIcon(iconName: string, content: string, sanitized: SafeHtml): void { + this.cache.set(iconName, { + content, + sanitized, + timestamp: Date.now() + }); + this.cacheUpdate$.next(iconName); + } + + isCached(iconName: string): boolean { + const cached = this.cache.get(iconName); + + if (!cached) { + return false; + } + + if (Date.now() - cached.timestamp > this.CACHE_DURATION_MS) { + this.cache.delete(iconName); + return false; + } + + return true; + } + + invalidate(iconName: string): void { + this.cache.delete(iconName); + this.cacheUpdate$.next(null); + } + + invalidateMultiple(iconNames: string[]): void { + iconNames.forEach(name => this.cache.delete(name)); + this.cacheUpdate$.next(null); + } + + clearCache(): void { + this.cache.clear(); + this.cacheUpdate$.next(null); + } + + getCacheUpdates(): Observable { + return this.cacheUpdate$.asObservable(); + } +} + diff --git a/booklore-ui/src/app/shared/services/icon.service.ts b/booklore-ui/src/app/shared/services/icon.service.ts index 95b48932..f78d6ced 100644 --- a/booklore-ui/src/app/shared/services/icon.service.ts +++ b/booklore-ui/src/app/shared/services/icon.service.ts @@ -1,7 +1,10 @@ import {inject, Injectable} from '@angular/core'; import {HttpClient} from '@angular/common/http'; -import {Observable} from 'rxjs'; +import {Observable, of} from 'rxjs'; +import {tap} from 'rxjs/operators'; import {API_CONFIG} from '../../core/config/api-config'; +import {IconCacheService} from './icon-cache.service'; +import {DomSanitizer, SafeHtml} from '@angular/platform-browser'; interface PageResponse { content: T[]; @@ -11,6 +14,24 @@ interface PageResponse { totalPages: number; } +interface SvgIconData { + svgName: string; + svgData: string; +} + +interface IconSaveResult { + iconName: string; + success: boolean; + errorMessage: string; +} + +interface SvgIconBatchResponse { + totalRequested: number; + successCount: number; + failureCount: number; + results: IconSaveResult[]; +} + @Injectable({ providedIn: 'root' }) @@ -19,6 +40,8 @@ export class IconService { private readonly baseUrl = `${API_CONFIG.BASE_URL}/api/v1/icons`; private http = inject(HttpClient); + private iconCache = inject(IconCacheService); + private sanitizer = inject(DomSanitizer); saveSvgIcon(svgContent: string, svgName: string): Observable { return this.http.post(this.baseUrl, { @@ -29,11 +52,63 @@ export class IconService { getIconNames(page: number = 0, size: number = 50): Observable> { return this.http.get>(this.baseUrl, { - params: { page: page.toString(), size: size.toString() } + params: {page: page.toString(), size: size.toString()} + }); + } + + getSvgIconContent(iconName: string): Observable { + const cached = this.iconCache.getCachedContent(iconName); + if (cached) { + return of(cached); + } + + return this.http.get(`${this.baseUrl}/${encodeURIComponent(iconName)}/content`, { + responseType: 'text' + }).pipe( + tap(content => { + const sanitized = this.sanitizer.bypassSecurityTrustHtml(content); + this.iconCache.cacheIcon(iconName, content, sanitized); + }) + ); + } + + getSanitizedSvgContent(iconName: string): Observable { + const cached = this.iconCache.getCachedSanitized(iconName); + if (cached) { + return of(cached); + } + + return new Observable(observer => { + this.getSvgIconContent(iconName).subscribe({ + next: () => { + const sanitized = this.iconCache.getCachedSanitized(iconName); + if (sanitized) { + observer.next(sanitized); + observer.complete(); + } + }, + error: (err) => observer.error(err) + }); }); } deleteSvgIcon(svgName: string): Observable { - return this.http.delete(`${this.baseUrl}/${encodeURIComponent(svgName)}`); + return this.http.delete(`${this.baseUrl}/${encodeURIComponent(svgName)}`).pipe( + tap(() => { + this.iconCache.invalidate(svgName); + }) + ); + } + + saveBatchSvgIcons(icons: SvgIconData[]): Observable { + return this.http.post(`${this.baseUrl}/batch`, {icons}).pipe( + tap((response) => { + const successfulIcons = response.results + .filter(result => result.success) + .map(result => result.iconName); + + this.iconCache.invalidateMultiple(successfulIcons); + }) + ); } }