Allow uploading multiple SVG icons and render them directly as inline SVGs instead of using <img> tags (#1796)

This commit is contained in:
Aditya Chandel
2025-12-09 13:38:39 -07:00
committed by GitHub
parent c945c95e17
commit e2062b5dc6
12 changed files with 719 additions and 152 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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