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

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

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

View File

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

View File

@@ -249,9 +249,19 @@
border-radius: 8px;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2);
i {
font-size: 1.25rem;
app-icon-display {
display: flex;
align-items: center;
justify-content: center;
color: var(--primary-contrast-color);
::ng-deep i {
color: var(--primary-contrast-color);
}
::ng-deep img {
filter: brightness(0) invert(1);
}
}
}
@@ -281,10 +291,6 @@
.icon-preview {
width: 36px;
height: 36px;
i {
font-size: 1.125rem;
}
}
.icon-info {

View File

@@ -2,12 +2,13 @@ import {Component, inject} from '@angular/core';
import {DynamicDialogRef} from 'primeng/dynamicdialog';
import {MessageService} from 'primeng/api';
import {ShelfService} from '../../service/shelf.service';
import {IconPickerService} from '../../../../shared/service/icon-picker.service';
import {IconPickerService, IconSelection} from '../../../../shared/service/icon-picker.service';
import {Shelf} from '../../model/shelf.model';
import {FormsModule} from '@angular/forms';
import {Button} from 'primeng/button';
import {InputText} from 'primeng/inputtext';
import {Tooltip} from 'primeng/tooltip';
import {IconDisplayComponent} from '../../../../shared/components/icon-display/icon-display.component';
@Component({
selector: 'app-shelf-creator',
@@ -17,7 +18,8 @@ import {Tooltip} from 'primeng/tooltip';
FormsModule,
Button,
InputText,
Tooltip
Tooltip,
IconDisplayComponent
],
styleUrl: './shelf-creator.component.scss',
})
@@ -28,24 +30,7 @@ export class ShelfCreatorComponent {
private iconPickerService = inject(IconPickerService);
shelfName: string = '';
selectedIcon: string | null = null;
saveNewShelf(): void {
const newShelf: Partial<Shelf> = {
name: this.shelfName,
icon: this.selectedIcon ? this.selectedIcon.replace('pi pi-', '') : 'heart'
};
this.shelfService.createShelf(newShelf as Shelf).subscribe({
next: () => {
this.messageService.add({severity: 'info', summary: 'Success', detail: `Shelf created: ${this.shelfName}`});
this.dynamicDialogRef.close(true);
},
error: (e) => {
this.messageService.add({severity: 'error', summary: 'Error', detail: 'Failed to create shelf'});
console.error('Error creating shelf:', e);
}
});
}
selectedIcon: IconSelection | null = null;
openIconPicker(): void {
this.iconPickerService.open().subscribe(icon => {
@@ -62,4 +47,26 @@ export class ShelfCreatorComponent {
cancel(): void {
this.dynamicDialogRef.close();
}
createShelf(): void {
const iconValue = this.selectedIcon?.value || 'bookmark';
const iconType = this.selectedIcon?.type || 'PRIME_NG';
const newShelf: Partial<Shelf> = {
name: this.shelfName,
icon: iconValue,
iconType: iconType
};
this.shelfService.createShelf(newShelf as Shelf).subscribe({
next: () => {
this.messageService.add({severity: 'info', summary: 'Success', detail: `Shelf created: ${this.shelfName}`});
this.dynamicDialogRef.close(true);
},
error: (e) => {
this.messageService.add({severity: 'error', summary: 'Error', detail: 'Failed to create shelf'});
console.error('Error creating shelf:', e);
}
});
}
}

View File

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

View File

@@ -1,13 +1,14 @@
import {Component, inject, OnInit} from '@angular/core';
import {ShelfService} from '../../service/shelf.service';
import {DialogService, DynamicDialogConfig, DynamicDialogRef} from 'primeng/dynamicdialog';
import {DynamicDialogConfig, DynamicDialogRef} from 'primeng/dynamicdialog';
import {Button} from 'primeng/button';
import {InputText} from 'primeng/inputtext';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {Shelf} from '../../model/shelf.model';
import {MessageService} from 'primeng/api';
import {IconPickerService} from '../../../../shared/service/icon-picker.service';
import {IconPickerService, IconSelection} from '../../../../shared/service/icon-picker.service';
import {IconDisplayComponent} from '../../../../shared/components/icon-display/icon-display.component';
@Component({
selector: 'app-shelf-edit-dialog',
@@ -15,7 +16,8 @@ import {IconPickerService} from '../../../../shared/service/icon-picker.service'
Button,
InputText,
ReactiveFormsModule,
FormsModule
FormsModule,
IconDisplayComponent
],
templateUrl: './shelf-edit-dialog.component.html',
standalone: true,
@@ -30,7 +32,7 @@ export class ShelfEditDialogComponent implements OnInit {
private iconPickerService = inject(IconPickerService);
shelfName: string = '';
selectedIcon: string | null = null;
selectedIcon: IconSelection | null = null;
shelf!: Shelf | undefined;
ngOnInit(): void {
@@ -38,7 +40,11 @@ export class ShelfEditDialogComponent implements OnInit {
this.shelf = this.shelfService.getShelfById(shelfId);
if (this.shelf) {
this.shelfName = this.shelf.name;
this.selectedIcon = 'pi pi-' + this.shelf.icon;
if (this.shelf.iconType === 'PRIME_NG') {
this.selectedIcon = {type: 'PRIME_NG', value: `pi pi-${this.shelf.icon}`};
} else {
this.selectedIcon = {type: 'CUSTOM_SVG', value: this.shelf.icon};
}
}
}
@@ -55,9 +61,13 @@ export class ShelfEditDialogComponent implements OnInit {
}
save() {
const iconValue = this.selectedIcon?.value || 'bookmark';
const iconType = this.selectedIcon?.type || 'PRIME_NG';
const shelf: Shelf = {
name: this.shelfName,
icon: this.selectedIcon?.replace('pi pi-', '') || 'heart'
icon: iconValue,
iconType: iconType
};
this.shelfService.updateShelf(shelf, this.shelf?.id).subscribe({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -152,6 +152,48 @@
color: var(--text-color);
font-size: 0.9rem;
}
.icon-display-container {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 1rem;
border: 1px solid var(--border-color);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
background: var(--card-background);
&:hover {
border-color: var(--primary-color);
}
.selected-icon {
flex-shrink: 0;
}
.icon-label {
font-size: 0.95rem;
color: var(--text-secondary-color);
white-space: nowrap;
}
.icon-placeholder {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--text-secondary-color);
i {
font-size: 1.125rem;
color: var(--primary-color)
}
span {
font-size: 0.875rem;
}
}
}
}
@media (max-width: 768px) {

View File

@@ -15,9 +15,10 @@ import {DynamicDialogConfig, DynamicDialogRef} from 'primeng/dynamicdialog';
import {MultiSelect} from 'primeng/multiselect';
import {AutoComplete} from 'primeng/autocomplete';
import {EMPTY_CHECK_OPERATORS, MULTI_VALUE_OPERATORS, parseValue, removeNulls, serializeDateRules} from '../service/magic-shelf-utils';
import {IconPickerService} from '../../../shared/service/icon-picker.service';
import {IconPickerService, IconSelection} from '../../../shared/service/icon-picker.service';
import {CheckboxModule} from "primeng/checkbox";
import {UserService} from "../../settings/user-management/user.service";
import {IconDisplayComponent} from '../../../shared/components/icon-display/icon-display.component';
export type RuleOperator =
| 'equals'
@@ -154,7 +155,8 @@ const FIELD_CONFIGS: Record<RuleField, FullFieldConfig> = {
InputNumber,
MultiSelect,
AutoComplete,
CheckboxModule
CheckboxModule,
IconDisplayComponent
]
})
export class MagicShelfComponent implements OnInit {
@@ -210,6 +212,8 @@ export class MagicShelfComponent implements OnInit {
userService = inject(UserService);
private iconPicker = inject(IconPickerService);
selectedIcon: IconSelection | null = null;
trackByFn(ruleCtrl: AbstractControl, index: number): any {
return ruleCtrl;
}
@@ -222,12 +226,20 @@ export class MagicShelfComponent implements OnInit {
if (id) {
this.shelfId = id;
this.magicShelfService.getShelf(id).subscribe((data) => {
const iconValue = data?.icon ?? null;
this.form = new FormGroup({
name: new FormControl<string | null>(data?.name ?? null, {nonNullable: true, validators: [Validators.required]}),
icon: new FormControl<string | null>(data?.icon ?? null, {nonNullable: true, validators: [Validators.required]}),
icon: new FormControl<string | null>(iconValue, {nonNullable: true, validators: [Validators.required]}),
isPublic: new FormControl<boolean>(data?.isPublic ?? false),
group: data?.filterJson ? this.buildGroupFromData(JSON.parse(data.filterJson)) : this.createGroup()
});
if (iconValue) {
this.selectedIcon = iconValue.startsWith('pi ')
? {type: 'PRIME_NG', value: iconValue}
: {type: 'CUSTOM_SVG', value: iconValue};
}
});
} else {
this.form = new FormGroup({
@@ -409,7 +421,11 @@ export class MagicShelfComponent implements OnInit {
openIconPicker() {
this.iconPicker.open().subscribe(icon => {
if (icon) {
this.form.get('icon')?.setValue(icon);
this.selectedIcon = icon;
const iconValue = icon.type === 'CUSTOM_SVG'
? icon.value
: icon.value;
this.form.get('icon')?.setValue(iconValue);
}
});
}
@@ -462,14 +478,14 @@ export class MagicShelfComponent implements OnInit {
id: this.shelfId ?? undefined,
name: value.name,
icon: value.icon,
isPublic: !!value.isPublic, // Ensure it's a boolean
iconType: this.selectedIcon?.type,
isPublic: !!value.isPublic,
group: cleanedGroup
}).subscribe({
next: (savedShelf) => {
this.messageService.add({severity: 'success', summary: 'Success', detail: 'Magic shelf saved successfully.'});
if (savedShelf?.id) {
this.shelfId = savedShelf.id;
// Update the form with the saved data to reflect changes immediately
this.form.patchValue({
name: savedShelf.name,
icon: savedShelf.icon,

View File

@@ -12,6 +12,7 @@ export interface MagicShelf {
id?: number | null;
name: string;
icon?: string;
iconType?: 'PRIME_NG' | 'CUSTOM_SVG';
filterJson: string;
isPublic?: boolean;
}
@@ -93,11 +94,12 @@ export class MagicShelfService {
);
}
saveShelf(data: { id?: number; name: string | null; icon: string | null; group: any, isPublic?: boolean | null }): Observable<MagicShelf> {
saveShelf(data: { id?: number; name: string | null; icon: string | null; iconType?: 'PRIME_NG' | 'CUSTOM_SVG'; group: any, isPublic?: boolean | null }): Observable<MagicShelf> {
const payload: MagicShelf = {
id: data.id,
name: data.name ?? '',
icon: data.icon ?? 'pi pi-book',
iconType: data.iconType,
filterJson: JSON.stringify(data.group),
isPublic: data.isPublic ?? false
};

View File

@@ -69,7 +69,6 @@ export class CoverSearchComponent implements OnInit {
.pipe(finalize(() => this.loading = false))
.subscribe({
next: (images) => {
console.log('API response received:', images);
this.coverImages = images.sort((a, b) => a.index - b.index);
this.hasSearched = true;
},

View File

@@ -616,7 +616,7 @@ export class CbxReaderComponent implements OnInit {
next: (seriesBooks) => {
const sortedBySeriesNumber = this.sortBooksBySeriesNumber(seriesBooks);
const currentBookIndex = sortedBySeriesNumber.findIndex(b => b.id === book.id);
if (currentBookIndex === -1) {
console.warn('[SeriesNav] Current book not found in series');
return;
@@ -627,14 +627,6 @@ export class CbxReaderComponent implements OnInit {
this.previousBookInSeries = hasPreviousBook ? sortedBySeriesNumber[currentBookIndex - 1] : null;
this.nextBookInSeries = hasNextBook ? sortedBySeriesNumber[currentBookIndex + 1] : null;
console.log('[SeriesNav] Navigation loaded:', {
series: book.metadata?.seriesName,
totalBooks: seriesBooks.length,
currentPosition: currentBookIndex + 1,
hasPrevious: hasPreviousBook,
hasNext: hasNextBook
});
},
error: (err) => {
console.error('[SeriesNav] Failed to load series information:', err);
@@ -652,22 +644,22 @@ export class CbxReaderComponent implements OnInit {
getBookDisplayTitle(book: Book | null): string {
if (!book) return '';
const parts: string[] = [];
if (book.metadata?.seriesNumber) {
parts.push(`#${book.metadata.seriesNumber}`);
}
const title = book.metadata?.title || book.fileName;
if (title) {
parts.push(title);
}
if (book.metadata?.subtitle) {
parts.push(book.metadata.subtitle);
}
return parts.join(' - ');
}

View File

@@ -8,8 +8,8 @@
}
.directory-picker {
width: 550px;
max-width: 550px;
width: 650px;
max-width: 650px;
display: flex;
flex-direction: column;
@@ -370,7 +370,7 @@
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
padding: 0.75rem 1rem;
background: var(--ground-background);
border: 1px solid var(--border-color);
border-radius: 8px;

View File

@@ -0,0 +1,63 @@
import {Component, inject, Input} from '@angular/core';
import {IconSelection} from '../../service/icon-picker.service';
import {UrlHelperService} from '../../service/url-helper.service';
import {NgClass, NgStyle} from '@angular/common';
@Component({
selector: 'app-icon-display',
standalone: true,
imports: [NgClass, NgStyle],
template: `
@if (icon) {
@if (icon.type === 'PRIME_NG') {
<i [class]="getPrimeNgIconClass(icon.value)" [ngClass]="iconClass" [ngStyle]="iconStyle"></i>
} @else {
<img
[src]="getIconUrl(icon.value)"
[alt]="alt"
[ngClass]="iconClass"
[ngStyle]="getImageStyle()"
/>
}
}
`,
styles: [`
:host {
display: inline-flex;
align-items: center;
justify-content: center;
}
`]
})
export class IconDisplayComponent {
@Input() icon: IconSelection | null = null;
@Input() iconClass: string = 'icon';
@Input() iconStyle: Record<string, string> = {};
@Input() size: string = '24px';
@Input() alt: string = 'Icon';
private urlHelper = inject(UrlHelperService);
getPrimeNgIconClass(iconValue: string): string {
if (iconValue.startsWith('pi pi-')) {
return iconValue;
}
if (iconValue.startsWith('pi-')) {
return `pi ${iconValue}`;
}
return `pi pi-${iconValue}`;
}
getIconUrl(iconName: string): string {
return this.urlHelper.getIconUrl(iconName);
}
getImageStyle(): Record<string, string> {
return {
width: this.size,
height: this.size,
objectFit: 'contain',
...this.iconStyle
};
}
}

View File

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

View File

@@ -1,14 +1,19 @@
.icon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(25px, 1fr));
gap: 30px;
gap: 38px;
padding-top: 10px;
}
.icon-item {
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
justify-content: space-evenly;
align-items: flex-start;
}
.icon-item > i {
font-size: 1.25rem;
}
.icon-search {
@@ -18,6 +23,7 @@
border: 1px solid var(--text-secondary-color);
border-radius: 4px;
margin-bottom: 15px;
margin-top: 15px;
box-sizing: border-box;
}
@@ -30,3 +36,192 @@
color: var(--text-secondary-color);
opacity: 1;
}
.svg-paste-container {
display: flex;
flex-direction: column;
gap: 15px;
}
.svg-textarea {
width: 100%;
padding: 12px;
font-size: 14px;
font-family: monospace;
border: 1px solid var(--text-secondary-color);
border-radius: 4px;
resize: vertical;
box-sizing: border-box;
&:focus {
border-color: var(--primary-color);
outline: none;
}
}
.svg-preview-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.preview-title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--text-primary-color);
}
.svg-preview {
border: 1px solid var(--text-secondary-color);
border-radius: 4px;
padding: 10px;
background-color: var(--ground-background);
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
max-height: 225px;
overflow: auto;
::ng-deep svg {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
}
}
.button-container {
display: flex;
justify-content: flex-end;
}
.error-message {
color: #e74c3c;
font-size: 14px;
font-weight: 500;
padding: 8px;
background-color: #ffe6e6;
border-radius: 4px;
}
.svg-name-input {
width: 100%;
padding: 8px 12px;
font-size: 15px;
border: 1px solid var(--text-secondary-color);
border-radius: 4px;
margin-bottom: 8px;
box-sizing: border-box;
&:focus {
border-color: var(--primary-color);
outline: none;
}
}
.svg-browse-container {
.loading-message {
text-align: center;
padding: 2rem;
color: #666;
}
}
.svg-icon-item {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
gap: 0.5rem;
border: 1px solid transparent;
border-radius: 4px;
transition: all 0.2s;
height: 24px;
width: 24px;
.svg-icon-image {
height: 20px;
width: 20px;
object-fit: contain;
}
}
.pagination-controls {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
padding: 1rem;
margin-top: 1rem;
border-top: 1px solid #e0e0e0;
.pagination-button {
padding: 0.5rem 1rem;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
&:hover:not(:disabled) {
background: #f5f5f5;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.pagination-info {
color: #666;
font-size: 0.9rem;
}
}
.svg-trash-area {
position: fixed;
right: 32px;
bottom: 32px;
z-index: 100;
display: flex;
align-items: center;
gap: 10px;
background: var(--p-surface-800);
border: 2px dashed var(--border-color);
border-radius: 50px;
padding: 12px 22px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.30);
color: var(--text-secondary-color);
font-size: 16px;
transition: border-color 0.2s, background 0.2s, color 0.2s;
i.pi-trash {
font-size: 22px;
color: #ff3b27;
margin-right: 8px;
transition: color 0.2s;
}
span {
font-size: 15px;
font-weight: 500;
color: #bbb;
transition: color 0.2s;
}
&.trash-hover {
border-color: #e74c3c;
background: #3a2323;
color: #e74c3c;
i.pi-trash {
color: #ff7675;
}
span {
color: #ff7675;
}
}
}

View File

@@ -1,64 +1,88 @@
import {Component, EventEmitter, inject, Output} from '@angular/core';
import {Component, inject, OnInit} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {DynamicDialogRef} from 'primeng/dynamicdialog';
import {IconService} from '../../services/icon.service';
import {DomSanitizer, SafeHtml} from '@angular/platform-browser';
import {UrlHelperService} from '../../service/url-helper.service';
import {MessageService} from 'primeng/api';
import {IconCategoriesHelper} from '../../helpers/icon-categories.helper';
import {Button} from 'primeng/button';
import {TabsModule} from 'primeng/tabs';
@Component({
selector: 'app-icon-picker-component',
imports: [
FormsModule
FormsModule,
Button,
TabsModule
],
templateUrl: './icon-picker-component.html',
styleUrl: './icon-picker-component.scss'
})
export class IconPickerComponent {
export class IconPickerComponent implements OnInit {
iconCategories: string[] = [
"address-book", "align-center", "align-justify", "align-left", "align-right", "amazon", "android",
"angle-double-down", "angle-double-left", "angle-double-right", "angle-double-up", "angle-down", "angle-left",
"angle-right", "angle-up", "apple", "arrow-circle-down", "arrow-circle-left", "arrow-circle-right", "arrow-circle-up",
"arrow-down", "arrow-down-left", "arrow-down-left-and-arrow-up-right-to-center", "arrow-down-right", "arrow-left",
"arrow-right", "arrow-right-arrow-left", "arrow-up", "arrow-up-left", "arrow-up-right", "arrow-up-right-and-arrow-down-left-from-center",
"arrows-alt", "arrows-h", "arrows-v", "asterisk", "at", "backward", "ban", "barcode", "bars", "bell", "bell-slash",
"bitcoin", "bolt", "book", "bookmark", "bookmark-fill", "box", "briefcase", "building", "building-columns", "bullseye",
"calculator", "calendar", "calendar-clock", "calendar-minus", "calendar-plus", "calendar-times", "camera", "car",
"caret-down", "caret-left", "caret-right", "caret-up", "cart-arrow-down", "cart-minus", "cart-plus", "chart-bar",
"chart-line", "chart-pie", "chart-scatter", "check", "check-circle", "check-square", "chevron-circle-down",
"chevron-circle-left", "chevron-circle-right", "chevron-circle-up", "chevron-down", "chevron-left", "chevron-right",
"chevron-up", "circle", "circle-fill", "circle-off", "circle-on", "clipboard", "clock", "clone", "cloud", "cloud-download",
"cloud-upload", "code", "cog", "comment", "comments", "compass", "copy", "credit-card", "crown", "database", "delete-left",
"desktop", "directions", "directions-alt", "discord", "dollar", "download", "eject", "ellipsis-h", "ellipsis-v",
"envelope", "equals", "eraser", "ethereum", "euro", "exclamation-circle", "exclamation-triangle", "expand",
"external-link", "eye", "eye-slash", "face-smile", "facebook", "fast-backward", "fast-forward", "file", "file-arrow-up",
"file-check", "file-edit", "file-excel", "file-export", "file-import", "file-o", "file-pdf", "file-plus", "file-word",
"filter", "filter-fill", "filter-slash", "flag", "flag-fill", "folder", "folder-open", "folder-plus", "forward", "gauge",
"gift", "github", "globe", "google", "graduation-cap", "hammer", "hashtag", "headphones", "heart", "heart-fill", "history",
"home", "hourglass", "id-card", "image", "images", "inbox", "indian-rupee", "info", "info-circle", "instagram", "key",
"language", "lightbulb", "link", "linkedin", "list", "list-check", "lock", "lock-open", "map", "map-marker", "mars", "megaphone",
"microchip", "microchip-ai", "microphone", "microsoft", "minus", "minus-circle", "mobile", "money-bill", "moon", "objects-column",
"palette", "paperclip", "pause", "pause-circle", "paypal", "pen-to-square", "pencil", "percentage", "phone", "pinterest", "play",
"play-circle", "plus", "plus-circle", "pound", "power-off", "prime", "print", "qrcode", "question", "question-circle", "receipt",
"reddit", "refresh", "replay", "reply", "save", "search", "search-minus", "search-plus", "send", "server", "share-alt", "shield",
"shop", "shopping-bag", "shopping-cart", "sign-in", "sign-out", "sitemap", "slack", "sliders-h", "sliders-v", "sort",
"sort-alpha-down", "sort-alpha-down-alt", "sort-alpha-up", "sort-alpha-up-alt", "sort-alt", "sort-alt-slash",
"sort-amount-down", "sort-amount-down-alt", "sort-amount-up", "sort-amount-up-alt", "sort-down", "sort-down-fill",
"sort-numeric-down", "sort-numeric-down-alt", "sort-numeric-up", "sort-numeric-up-alt", "sort-up", "sort-up-fill", "sparkles",
"spinner", "spinner-dotted", "star", "star-fill", "star-half", "star-half-fill", "step-backward", "step-backward-alt",
"step-forward", "step-forward-alt", "stop", "stop-circle", "stopwatch", "sun", "sync", "table", "tablet", "tag", "tags",
"telegram", "th-large", "thumbs-down", "thumbs-down-fill", "thumbs-up", "thumbs-up-fill", "thumbtack", "ticket", "tiktok",
"times", "times-circle", "trash", "trophy", "truck", "turkish-lira", "twitch", "twitter", "undo", "unlock", "upload", "user",
"user-edit", "user-minus", "user-plus", "users", "venus", "verified", "video", "vimeo", "volume-down", "volume-off",
"volume-up", "wallet", "warehouse", "wave-pulse", "whatsapp", "wifi", "window-maximize", "window-minimize", "wrench",
"youtube"
];
private readonly SVG_PAGE_SIZE = 50;
private readonly MAX_ICON_NAME_LENGTH = 255;
private readonly MAX_SVG_SIZE = 1048576; // 1MB
private readonly ICON_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
private readonly ERROR_MESSAGES = {
NO_CONTENT: 'Please paste SVG content',
NO_NAME: 'Please provide a name for the icon',
INVALID_NAME: 'Icon name can only contain alphanumeric characters and hyphens',
NAME_TOO_LONG: `Icon name must not exceed ${this.MAX_ICON_NAME_LENGTH} characters`,
INVALID_SVG: 'Invalid SVG content. Please paste valid SVG code.',
MISSING_SVG_TAG: 'Content must include <svg> tag',
SVG_TOO_LARGE: 'SVG content must not exceed 1MB',
PARSE_ERROR: 'Failed to parse SVG content',
LOAD_ICONS_ERROR: 'Failed to load SVG icons. Please try again.',
SAVE_ERROR: 'Failed to save SVG. Please try again.',
DELETE_ERROR: 'Failed to delete icon. Please try again.'
};
ref = inject(DynamicDialogRef);
iconService = inject(IconService);
sanitizer = inject(DomSanitizer);
urlHelper = inject(UrlHelperService);
messageService = inject(MessageService);
searchText: string = '';
selectedIcon: string | null = null;
icons: string[] = this.createIconList(this.iconCategories);
icons: string[] = IconCategoriesHelper.createIconList();
ref = inject(DynamicDialogRef);
private _activeTabIndex: string = '0';
createIconList(categories: string[]): string[] {
return categories.map(iconName => `pi pi-${iconName}`);
get activeTabIndex(): string {
return this._activeTabIndex;
}
set activeTabIndex(value: string) {
this._activeTabIndex = value;
if (value === '1' && this.svgIcons.length === 0 && !this.isLoadingSvgIcons) {
this.loadSvgIcons(0);
}
}
svgContent: string = '';
svgName: string = '';
svgPreview: SafeHtml | null = null;
isLoading: boolean = false;
errorMessage: string = '';
svgIcons: string[] = [];
svgSearchText: string = '';
currentSvgPage: number = 0;
totalSvgPages: number = 0;
isLoadingSvgIcons: boolean = false;
svgIconsError: string = '';
selectedSvgIcon: string | null = null;
draggedSvgIcon: string | null = null;
isTrashHover: boolean = false;
ngOnInit(): void {
if (this.activeTabIndex === '1') {
this.loadSvgIcons(0);
}
}
filteredIcons(): string[] {
@@ -66,12 +90,192 @@ export class IconPickerComponent {
return this.icons.filter(icon => icon.toLowerCase().includes(this.searchText.toLowerCase()));
}
selectIcon(icon: string) {
this.selectedIcon = icon;
this.ref.close(icon);
filteredSvgIcons(): string[] {
if (!this.svgSearchText) return this.svgIcons;
return this.svgIcons.filter(icon => icon.toLowerCase().includes(this.svgSearchText.toLowerCase()));
}
cancel() {
this.ref.close();
selectIcon(icon: string): void {
this.selectedIcon = icon;
this.ref.close({type: 'PRIME_NG', value: icon});
}
loadSvgIcons(page: number): void {
this.isLoadingSvgIcons = true;
this.svgIconsError = '';
this.iconService.getIconNames(page, this.SVG_PAGE_SIZE).subscribe({
next: (response) => {
this.svgIcons = response.content;
this.currentSvgPage = response.number;
this.totalSvgPages = response.totalPages;
this.isLoadingSvgIcons = false;
},
error: () => {
this.isLoadingSvgIcons = false;
this.svgIconsError = this.ERROR_MESSAGES.LOAD_ICONS_ERROR;
}
});
}
getSvgIconUrl(iconName: string): string {
return this.urlHelper.getIconUrl(iconName);
}
onImageError(event: Event): void {
const img = event.target as HTMLImageElement;
img.src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"%3E%3Ccircle cx="12" cy="12" r="10"/%3E%3Cline x1="15" y1="9" x2="9" y2="15"/%3E%3Cline x1="9" y1="9" x2="15" y2="15"/%3E%3C/svg%3E';
}
selectSvgIcon(iconName: string): void {
this.selectedSvgIcon = iconName;
this.ref.close({type: 'CUSTOM_SVG', value: iconName});
}
onSvgContentChange(): void {
this.errorMessage = '';
if (!this.svgContent.trim()) {
this.svgPreview = null;
return;
}
const trimmedContent = this.svgContent.trim();
if (!trimmedContent.includes('<svg')) {
this.svgPreview = null;
this.errorMessage = this.ERROR_MESSAGES.MISSING_SVG_TAG;
return;
}
try {
this.svgPreview = this.sanitizer.bypassSecurityTrustHtml(this.svgContent);
} catch {
this.svgPreview = null;
this.errorMessage = this.ERROR_MESSAGES.PARSE_ERROR;
}
}
saveSvg(): void {
const validationError = this.validateSvgInput();
if (validationError) {
this.errorMessage = validationError;
return;
}
this.isLoading = true;
this.errorMessage = '';
this.iconService.saveSvgIcon(this.svgContent, this.svgName).subscribe({
next: () => {
this.isLoading = false;
this.handleSuccessfulSave();
},
error: (error) => {
this.isLoading = false;
this.errorMessage = error.error?.details?.join(', ')
|| error.error?.message
|| this.ERROR_MESSAGES.SAVE_ERROR;
}
});
}
private validateSvgInput(): string | null {
if (!this.svgContent.trim()) {
return this.ERROR_MESSAGES.NO_CONTENT;
}
if (!this.svgName.trim()) {
return this.ERROR_MESSAGES.NO_NAME;
}
if (!this.ICON_NAME_PATTERN.test(this.svgName)) {
return this.ERROR_MESSAGES.INVALID_NAME;
}
if (this.svgName.length > this.MAX_ICON_NAME_LENGTH) {
return this.ERROR_MESSAGES.NAME_TOO_LONG;
}
if (!this.svgContent.trim().includes('<svg')) {
return this.ERROR_MESSAGES.INVALID_SVG;
}
if (this.svgContent.length > this.MAX_SVG_SIZE) {
return this.ERROR_MESSAGES.SVG_TOO_LARGE;
}
return null;
}
private handleSuccessfulSave(): void {
this.activeTabIndex = '1';
if (!this.svgIcons.includes(this.svgName)) {
this.svgIcons.unshift(this.svgName);
}
this.selectedSvgIcon = this.svgName;
this.resetSvgForm();
}
private resetSvgForm(): void {
this.svgSearchText = '';
this.svgContent = '';
this.svgName = '';
this.svgPreview = null;
}
onSvgIconDragStart(iconName: string): void {
this.draggedSvgIcon = iconName;
}
onSvgIconDragEnd(): void {
this.draggedSvgIcon = null;
this.isTrashHover = false;
}
onTrashDragOver(event: DragEvent): void {
event.preventDefault();
this.isTrashHover = true;
}
onTrashDragLeave(event: DragEvent): void {
event.preventDefault();
this.isTrashHover = false;
}
onTrashDrop(event: DragEvent): void {
event.preventDefault();
this.isTrashHover = false;
if (!this.draggedSvgIcon) {
return;
}
this.deleteSvgIcon(this.draggedSvgIcon);
this.draggedSvgIcon = null;
}
private deleteSvgIcon(iconName: string): void {
this.isLoadingSvgIcons = true;
this.iconService.deleteSvgIcon(iconName).subscribe({
next: () => {
this.messageService.add({
severity: 'success',
summary: 'Icon Deleted',
detail: 'SVG icon deleted successfully.',
life: 2500
});
this.loadSvgIcons(this.currentSvgPage);
},
error: (error) => {
this.isLoadingSvgIcons = false;
this.messageService.add({
severity: 'error',
summary: 'Delete Failed',
detail: error.error?.message || this.ERROR_MESSAGES.DELETE_ERROR,
life: 4000
});
}
});
}
}

View File

@@ -0,0 +1,44 @@
export class IconCategoriesHelper {
static readonly CATEGORIES: string[] = [
"address-book", "align-center", "align-justify", "align-left", "align-right", "amazon", "android",
"angle-double-down", "angle-double-left", "angle-double-right", "angle-double-up", "angle-down", "angle-left",
"angle-right", "angle-up", "apple", "arrow-circle-down", "arrow-circle-left", "arrow-circle-right", "arrow-circle-up",
"arrow-down", "arrow-down-left", "arrow-down-left-and-arrow-up-right-to-center", "arrow-down-right", "arrow-left",
"arrow-right", "arrow-right-arrow-left", "arrow-up", "arrow-up-left", "arrow-up-right", "arrow-up-right-and-arrow-down-left-from-center",
"arrows-alt", "arrows-h", "arrows-v", "asterisk", "at", "backward", "ban", "barcode", "bars", "bell", "bell-slash",
"bitcoin", "bolt", "book", "bookmark", "bookmark-fill", "box", "briefcase", "building", "building-columns", "bullseye",
"calculator", "calendar", "calendar-clock", "calendar-minus", "calendar-plus", "calendar-times", "camera", "car",
"caret-down", "caret-left", "caret-right", "caret-up", "cart-arrow-down", "cart-minus", "cart-plus", "chart-bar",
"chart-line", "chart-pie", "chart-scatter", "check", "check-circle", "check-square", "chevron-circle-down",
"chevron-circle-left", "chevron-circle-right", "chevron-circle-up", "chevron-down", "chevron-left", "chevron-right",
"chevron-up", "circle", "circle-fill", "circle-off", "circle-on", "clipboard", "clock", "clone", "cloud", "cloud-download",
"cloud-upload", "code", "cog", "comment", "comments", "compass", "copy", "credit-card", "crown", "database", "delete-left",
"desktop", "directions", "directions-alt", "discord", "dollar", "download", "eject", "ellipsis-h", "ellipsis-v",
"envelope", "equals", "eraser", "ethereum", "euro", "exclamation-circle", "exclamation-triangle", "expand",
"external-link", "eye", "eye-slash", "face-smile", "facebook", "fast-backward", "fast-forward", "file", "file-arrow-up",
"file-check", "file-edit", "file-excel", "file-export", "file-import", "file-o", "file-pdf", "file-plus", "file-word",
"filter", "filter-fill", "filter-slash", "flag", "flag-fill", "folder", "folder-open", "folder-plus", "forward", "gauge",
"gift", "github", "globe", "google", "graduation-cap", "hammer", "hashtag", "headphones", "heart", "heart-fill", "history",
"home", "hourglass", "id-card", "image", "images", "inbox", "indian-rupee", "info", "info-circle", "instagram", "key",
"language", "lightbulb", "link", "linkedin", "list", "list-check", "lock", "lock-open", "map", "map-marker", "mars", "megaphone",
"microchip", "microchip-ai", "microphone", "microsoft", "minus", "minus-circle", "mobile", "money-bill", "moon", "objects-column",
"palette", "paperclip", "pause", "pause-circle", "paypal", "pen-to-square", "pencil", "percentage", "phone", "pinterest", "play",
"play-circle", "plus", "plus-circle", "pound", "power-off", "prime", "print", "qrcode", "question", "question-circle", "receipt",
"reddit", "refresh", "replay", "reply", "save", "search", "search-minus", "search-plus", "send", "server", "share-alt", "shield",
"shop", "shopping-bag", "shopping-cart", "sign-in", "sign-out", "sitemap", "slack", "sliders-h", "sliders-v", "sort",
"sort-alpha-down", "sort-alpha-down-alt", "sort-alpha-up", "sort-alpha-up-alt", "sort-alt", "sort-alt-slash",
"sort-amount-down", "sort-amount-down-alt", "sort-amount-up", "sort-amount-up-alt", "sort-down", "sort-down-fill",
"sort-numeric-down", "sort-numeric-down-alt", "sort-numeric-up", "sort-numeric-up-alt", "sort-up", "sort-up-fill", "sparkles",
"spinner", "spinner-dotted", "star", "star-fill", "star-half", "star-half-fill", "step-backward", "step-backward-alt",
"step-forward", "step-forward-alt", "stop", "stop-circle", "stopwatch", "sun", "sync", "table", "tablet", "tag", "tags",
"telegram", "th-large", "thumbs-down", "thumbs-down-fill", "thumbs-up", "thumbs-up-fill", "thumbtack", "ticket", "tiktok",
"times", "times-circle", "trash", "trophy", "truck", "turkish-lira", "twitch", "twitter", "undo", "unlock", "upload", "user",
"user-edit", "user-minus", "user-plus", "users", "venus", "verified", "video", "vimeo", "volume-down", "volume-off",
"volume-up", "wallet", "warehouse", "wave-pulse", "whatsapp", "wifi", "window-maximize", "window-minimize", "wrench",
"youtube"
];
static createIconList(): string[] {
return this.CATEGORIES.map(iconName => `pi pi-${iconName}`);
}
}

View File

@@ -107,7 +107,8 @@ export class AppMenuComponent implements OnInit {
menu: this.libraryShelfMenuService.initializeLibraryMenuItems(library),
label: library.name,
type: 'Library',
icon: 'pi pi-' + library.icon,
icon: library.icon,
iconType: (library.iconType || 'PRIME_NG') as 'PRIME_NG' | 'CUSTOM_SVG',
routerLink: [`/library/${library.id}/books`],
bookCount$: this.libraryService.getBookCount(library.id ?? 0),
})),
@@ -129,7 +130,8 @@ export class AppMenuComponent implements OnInit {
items: sortedShelves.map((shelf) => ({
label: shelf.name,
type: 'magicShelfItem',
icon: 'pi pi-' + shelf.icon,
icon: shelf.icon || 'pi pi-book',
iconType: (shelf.iconType || 'PRIME_NG') as 'PRIME_NG' | 'CUSTOM_SVG',
menu: this.libraryShelfMenuService.initializeMagicShelfMenuItems(shelf),
routerLink: [`/magic-shelf/${shelf.id}/books`],
bookCount$: this.magicShelfService.getBookCount(shelf.id ?? 0),
@@ -154,7 +156,8 @@ export class AppMenuComponent implements OnInit {
menu: this.libraryShelfMenuService.initializeShelfMenuItems(shelf),
label: shelf.name,
type: 'Shelf',
icon: 'pi pi-' + shelf.icon,
icon: shelf.icon,
iconType: (shelf.iconType || 'PRIME_NG') as 'PRIME_NG' | 'CUSTOM_SVG',
routerLink: [`/shelf/${shelf.id}/books`],
bookCount$: this.shelfService.getBookCount(shelf.id ?? 0),
}));
@@ -163,6 +166,7 @@ export class AppMenuComponent implements OnInit {
label: 'Unshelved',
type: 'Shelf',
icon: 'pi pi-inbox',
iconType: 'PRIME_NG' as 'PRIME_NG' | 'CUSTOM_SVG',
routerLink: ['/unshelved-books'],
bookCount$: this.shelfService.getUnshelvedBookCount?.() ?? of(0),
};
@@ -172,7 +176,8 @@ export class AppMenuComponent implements OnInit {
items.push({
label: koboShelf.name,
type: 'Shelf',
icon: 'pi pi-' + koboShelf.icon,
icon: koboShelf.icon,
iconType: (koboShelf.iconType || 'PRIME_NG') as 'PRIME_NG' | 'CUSTOM_SVG',
routerLink: [`/shelf/${koboShelf.id}/books`],
bookCount$: this.shelfService.getBookCount(koboShelf.id ?? 0),
});

View File

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

View File

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

View File

@@ -10,8 +10,9 @@ import {Button} from 'primeng/button';
import {Menu} from 'primeng/menu';
import {UserService} from '../../../../features/settings/user-management/user.service';
import {DialogLauncherService} from '../../../services/dialog-launcher.service';
import {ShelfCreatorComponent} from '../../../../features/book/components/shelf-creator/shelf-creator.component';
import {BookDialogHelperService} from '../../../../features/book/components/book-browser/BookDialogHelperService';
import {IconDisplayComponent} from '../../../components/icon-display/icon-display.component';
import {IconSelection} from '../../../service/icon-picker.service';
@Component({
selector: '[app-menuitem]',
@@ -24,7 +25,8 @@ import {BookDialogHelperService} from '../../../../features/book/components/book
Ripple,
AsyncPipe,
Button,
Menu
Menu,
IconDisplayComponent
],
animations: [
trigger('children', [
@@ -169,6 +171,15 @@ export class AppMenuitemComponent implements OnInit, OnDestroy {
}
}
getIconSelection(): IconSelection | null {
if (!this.item.icon) return null;
return {
type: this.item.iconType || 'PRIME_NG',
value: this.item.icon
};
}
@HostBinding('class.active-menuitem')
get activeClass() {
return this.active && !this.root;

View File

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

View File

@@ -3,11 +3,16 @@ import {DialogService, DynamicDialogRef} from 'primeng/dynamicdialog';
import {IconPickerComponent} from '../components/icon-picker/icon-picker-component';
import {Observable} from 'rxjs';
export interface IconSelection {
type: 'PRIME_NG' | 'CUSTOM_SVG';
value: string;
}
@Injectable({providedIn: 'root'})
export class IconPickerService {
private dialog = inject(DialogService);
open(): Observable<string> {
open(): Observable<IconSelection> {
const isMobile = window.innerWidth <= 768;
const ref: DynamicDialogRef | null = this.dialog.open(IconPickerComponent, {
header: 'Choose an Icon',
@@ -22,6 +27,6 @@ export class IconPickerService {
minWidth: isMobile ? '90vw' : '800px',
}
});
return ref!.onClose as Observable<string>;
return ref!.onClose as Observable<IconSelection>;
}
}

View File

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

View File

@@ -0,0 +1,39 @@
import {inject, Injectable} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {Observable} from 'rxjs';
import {API_CONFIG} from '../../core/config/api-config';
interface PageResponse<T> {
content: T[];
number: number;
size: number;
totalElements: number;
totalPages: number;
}
@Injectable({
providedIn: 'root'
})
export class IconService {
private readonly baseUrl = `${API_CONFIG.BASE_URL}/api/v1/icons`;
private http = inject(HttpClient);
saveSvgIcon(svgContent: string, svgName: string): Observable<any> {
return this.http.post(this.baseUrl, {
svgData: svgContent,
svgName: svgName
});
}
getIconNames(page: number = 0, size: number = 50): Observable<PageResponse<string>> {
return this.http.get<PageResponse<string>>(this.baseUrl, {
params: { page: page.toString(), size: size.toString() }
});
}
deleteSvgIcon(svgName: string): Observable<any> {
return this.http.delete(`${this.baseUrl}/${encodeURIComponent(svgName)}`);
}
}

View File

@@ -78,10 +78,6 @@
border-radius: variables.$borderRadius;
transition: background-color variables.$transitionDuration, box-shadow variables.$transitionDuration;
.layout-menuitem-icon {
margin-right: .5rem;
}
.layout-submenu-toggler {
font-size: 75%;
margin-left: auto;