mirror of
https://github.com/booklore-app/booklore.git
synced 2025-12-23 22:28:11 -05:00
Allow uploading multiple SVG icons and render them directly as inline SVGs instead of using <img> tags (#1796)
This commit is contained in:
@@ -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<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);
|
||||
}
|
||||
}
|
||||
@@ -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<SvgIconBatchResponse> 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<String> 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
|
||||
|
||||
@@ -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<SvgIconCreateRequest> icons;
|
||||
}
|
||||
|
||||
@@ -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<IconSaveResult> results;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class IconSaveResult {
|
||||
private String iconName;
|
||||
private boolean success;
|
||||
private String errorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SvgIconCreateRequest> requests) {
|
||||
if (requests == null || requests.isEmpty()) {
|
||||
throw ApiError.INVALID_INPUT.createException("Icons list cannot be empty");
|
||||
}
|
||||
|
||||
List<SvgIconBatchResponse.IconSaveResult> 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);
|
||||
|
||||
@@ -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') {
|
||||
<i [class]="getPrimeNgIconClass(icon.value)" [ngClass]="iconClass" [ngStyle]="iconStyle"></i>
|
||||
} @else {
|
||||
<img
|
||||
[src]="getIconUrl(icon.value)"
|
||||
[alt]="alt"
|
||||
<div
|
||||
class="svg-icon-inline"
|
||||
[innerHTML]="getSvgContent(icon.value)"
|
||||
[ngClass]="iconClass"
|
||||
[ngStyle]="getImageStyle()"
|
||||
/>
|
||||
[ngStyle]="getSvgStyle()"
|
||||
></div>
|
||||
}
|
||||
}
|
||||
`,
|
||||
@@ -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<string, string> = {};
|
||||
@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 = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="red"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>';
|
||||
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<string, string> {
|
||||
getSvgStyle(): Record<string, string> {
|
||||
return {
|
||||
width: this.size,
|
||||
height: this.size,
|
||||
objectFit: 'contain',
|
||||
...this.iconStyle
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<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-tab value="2">Add SVG Icon(s)</p-tab>
|
||||
</p-tablist>
|
||||
<p-tabpanels>
|
||||
<p-tabpanel value="0">
|
||||
@@ -40,32 +40,34 @@
|
||||
(dragstart)="onSvgIconDragStart(iconName)"
|
||||
(dragend)="onSvgIconDragEnd()"
|
||||
>
|
||||
<img
|
||||
[src]="getSvgIconUrl(iconName)"
|
||||
[alt]="iconName"
|
||||
(error)="onImageError($event)"
|
||||
class="svg-icon-image"/>
|
||||
<div
|
||||
class="svg-icon-inline"
|
||||
[innerHTML]="getSvgContent(iconName)"
|
||||
></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (totalSvgPages > 1) {
|
||||
<div class="pagination-controls">
|
||||
<button
|
||||
(click)="loadSvgIcons(currentSvgPage - 1)"
|
||||
<p-button
|
||||
(onClick)="loadSvgIcons(currentSvgPage - 1)"
|
||||
[disabled]="currentSvgPage === 0"
|
||||
class="pagination-button">
|
||||
Previous
|
||||
</button>
|
||||
label="Prev"
|
||||
icon="pi pi-chevron-left"
|
||||
outlined>
|
||||
</p-button>
|
||||
<span class="pagination-info">
|
||||
Page {{ currentSvgPage + 1 }} of {{ totalSvgPages }}
|
||||
</span>
|
||||
<button
|
||||
(click)="loadSvgIcons(currentSvgPage + 1)"
|
||||
<p-button
|
||||
(onClick)="loadSvgIcons(currentSvgPage + 1)"
|
||||
[disabled]="currentSvgPage >= totalSvgPages - 1"
|
||||
class="pagination-button">
|
||||
Next
|
||||
</button>
|
||||
label="Next"
|
||||
icon="pi pi-chevron-right"
|
||||
iconPos="right"
|
||||
outlined>
|
||||
</p-button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -86,40 +88,95 @@
|
||||
</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"
|
||||
/>
|
||||
<div class="svg-input-section">
|
||||
<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>
|
||||
<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>
|
||||
@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)="addSvgEntry()"
|
||||
[disabled]="!svgContent || !svgName"
|
||||
label="Add to Queue"
|
||||
severity="primary"
|
||||
outlined
|
||||
icon="pi pi-plus">
|
||||
</p-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (svgEntries.length > 0) {
|
||||
<div class="svg-entries-section">
|
||||
<div class="entries-header">
|
||||
<h4 class="entries-title">
|
||||
Queued Icons ({{ svgEntries.length }})
|
||||
</h4>
|
||||
<p-button
|
||||
(onClick)="clearAllEntries()"
|
||||
label="Clear All"
|
||||
severity="danger"
|
||||
[text]="true"
|
||||
icon="pi pi-times">
|
||||
</p-button>
|
||||
</div>
|
||||
|
||||
<div class="entries-grid">
|
||||
@for (entry of svgEntries; track entry.name; let i = $index) {
|
||||
<div class="entry-card" [class.has-error]="entry.error">
|
||||
<div class="entry-header">
|
||||
<span class="entry-name">{{ entry.name }}</span>
|
||||
<button
|
||||
class="entry-remove"
|
||||
(click)="removeSvgEntry(i)"
|
||||
title="Remove">
|
||||
<i class="pi pi-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="entry-preview" [innerHTML]="entry.preview"></div>
|
||||
@if (entry.error) {
|
||||
<div class="entry-error">{{ entry.error }}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (batchErrorMessage) {
|
||||
<div class="error-message">{{ batchErrorMessage }}</div>
|
||||
}
|
||||
|
||||
<div class="batch-actions">
|
||||
<p-button
|
||||
(onClick)="saveAllSvgs()"
|
||||
[loading]="isSavingBatch"
|
||||
[disabled]="svgEntries.length === 0"
|
||||
label="Save All Icons"
|
||||
severity="success"
|
||||
icon="pi pi-save">
|
||||
</p-button>
|
||||
</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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="red"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>';
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
<app-icon-display
|
||||
[icon]="getIconSelection()"
|
||||
iconClass="layout-menuitem-icon"
|
||||
size="19px"
|
||||
size="16px"
|
||||
></app-icon-display>
|
||||
<span class="layout-menuitem-text menu-item-text">
|
||||
{{ item.label }}
|
||||
|
||||
93
booklore-ui/src/app/shared/services/icon-cache.service.ts
Normal file
93
booklore-ui/src/app/shared/services/icon-cache.service.ts
Normal file
@@ -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<string, CachedIcon>();
|
||||
private readonly CACHE_DURATION_MS = 1000 * 60 * 60;
|
||||
|
||||
private cacheUpdate$ = new BehaviorSubject<string | null>(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<string | null> {
|
||||
return this.cacheUpdate$.asObservable();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<T> {
|
||||
content: T[];
|
||||
@@ -11,6 +14,24 @@ interface PageResponse<T> {
|
||||
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<any> {
|
||||
return this.http.post(this.baseUrl, {
|
||||
@@ -29,11 +52,63 @@ export class IconService {
|
||||
|
||||
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() }
|
||||
params: {page: page.toString(), size: size.toString()}
|
||||
});
|
||||
}
|
||||
|
||||
getSvgIconContent(iconName: string): Observable<string> {
|
||||
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<SafeHtml> {
|
||||
const cached = this.iconCache.getCachedSanitized(iconName);
|
||||
if (cached) {
|
||||
return of(cached);
|
||||
}
|
||||
|
||||
return new Observable<SafeHtml>(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<any> {
|
||||
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<SvgIconBatchResponse> {
|
||||
return this.http.post<SvgIconBatchResponse>(`${this.baseUrl}/batch`, {icons}).pipe(
|
||||
tap((response) => {
|
||||
const successfulIcons = response.results
|
||||
.filter(result => result.success)
|
||||
.map(result => result.iconName);
|
||||
|
||||
this.iconCache.invalidateMultiple(successfulIcons);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user