Icon support for library and shelf

This commit is contained in:
aditya.chandel
2024-12-26 16:55:11 -07:00
parent 6e8d978d6e
commit aba11ecaa8
28 changed files with 205 additions and 84 deletions

View File

@@ -11,7 +11,6 @@ import lombok.AllArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.List;

View File

@@ -14,6 +14,7 @@ public class LibraryDTO {
private Long id;
private String name;
private Sort sort;
private String icon;
private List<String> paths;
}

View File

@@ -13,5 +13,6 @@ import java.time.Instant;
public class ShelfDTO {
private Long id;
private String name;
private String icon;
private Sort sort;
}

View File

@@ -1,6 +1,8 @@
package com.adityachandel.booklore.model.dto.request;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import lombok.Builder;
import lombok.Data;
@@ -10,6 +12,10 @@ import java.util.List;
@Builder
@JsonIgnoreProperties(ignoreUnknown = true)
public class CreateLibraryRequest {
@NotBlank
private String name;
@NotBlank
private String icon;
@NotEmpty
private List<String> paths;
}

View File

@@ -12,4 +12,7 @@ public class ShelfCreateRequest {
@NotBlank(message = "Shelf name must not be empty.")
private String name;
@NotBlank(message = "Shelf icon must not be empty.")
private String icon;
}

View File

@@ -31,4 +31,6 @@ public class Library {
@OneToMany(mappedBy = "library", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Book> books;
private String icon;
}

View File

@@ -28,4 +28,6 @@ public class Shelf {
@ManyToMany(mappedBy = "shelves", fetch = FetchType.LAZY)
private Set<Book> books = new HashSet<>();
private String icon;
}

View File

@@ -29,7 +29,11 @@ public class LibraryService {
private final LibraryProcessingService libraryProcessingService;
public LibraryDTO createLibrary(CreateLibraryRequest request) {
Library library = Library.builder().name(request.getName()).paths(request.getPaths()).build();
Library library = Library.builder()
.name(request.getName())
.paths(request.getPaths())
.icon(request.getIcon())
.build();
library = libraryRepository.save(library);
Long libraryId = library.getId();
Thread.startVirtualThread(() -> {

View File

@@ -28,7 +28,7 @@ public class ShelfService {
if (exists) {
throw ApiError.SHELF_ALREADY_EXISTS.createException(request.getName());
}
Shelf shelf = Shelf.builder().name(request.getName()).build();
Shelf shelf = Shelf.builder().icon(request.getIcon()).name(request.getName()).build();
return ShelfTransformer.convertToShelfDTO(shelfRepository.save(shelf));
}

View File

@@ -11,6 +11,7 @@ public class LibraryTransformer {
.id(library.getId())
.name(library.getName())
.sort(library.getSort())
.icon(library.getIcon())
.paths(library.getPaths())
.build();
}

View File

@@ -10,6 +10,7 @@ public class ShelfTransformer {
.id(shelf.getId())
.name(shelf.getName())
.sort(shelf.getSort())
.icon(shelf.getIcon())
.build();
}
}

View File

@@ -2,8 +2,9 @@ CREATE TABLE IF NOT EXISTS library
(
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) UNIQUE NOT NULL,
paths TEXT,
sort VARCHAR(255) NULL
sort VARCHAR(255) NULL,
icon VARCHAR(64) NOT NULL,
paths TEXT
);
CREATE TABLE IF NOT EXISTS book
@@ -85,7 +86,8 @@ CREATE TABLE IF NOT EXISTS shelf
(
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
sort VARCHAR(255) NULL
sort VARCHAR(255) NULL,
icon VARCHAR(64) NOT NULL
);
CREATE TABLE IF NOT EXISTS book_shelf_mapping

View File

@@ -1,2 +1 @@
INSERT INTO booklore.shelf (name) VALUES ('Favorites');
INSERT INTO booklore.shelf (name) VALUES ('Read Later');
INSERT INTO booklore.shelf (name, icon) VALUES ('Favorites', 'heart');

View File

@@ -36,6 +36,7 @@ import {SpeedDialModule} from 'primeng/speeddial';
import {RouteReuseStrategy} from '@angular/router';
import {CustomReuseStrategy} from './custom-reuse-strategy';
import {MenuModule} from 'primeng/menu';
import {IconPickerComponent} from './book/component/icon-picker/icon-picker.component';
@NgModule({
declarations: [
@@ -71,6 +72,7 @@ import {MenuModule} from 'primeng/menu';
BookCardComponent,
SpeedDialModule,
MenuModule,
IconPickerComponent,
],
providers: [
DialogService,

View File

@@ -8,7 +8,7 @@
label="Add a Library"
icon="pi pi-plus"
styleClass="p-button-rounded p-button-outlined"
(click)="createNewLibrary($event)">
(click)="createNewLibrary()">
</p-button>
</div>

View File

@@ -1,5 +1,5 @@
import {Component} from '@angular/core';
import {Button, ButtonDirective} from 'primeng/button';
import {Button} from 'primeng/button';
import {AsyncPipe, NgIf} from '@angular/common';
import {LibraryCreatorComponent} from '../library-creator/library-creator.component';
import {DialogService, DynamicDialogRef} from 'primeng/dynamicdialog';
@@ -7,7 +7,6 @@ import {DashboardScrollerComponent} from '../dashboard-scroller/dashboard-scroll
import {LibraryService} from '../../service/library.service';
import {Observable} from 'rxjs';
import {map} from 'rxjs/operators';
import {IconPickerComponent} from '../icon-picker/icon-picker.component';
@Component({
selector: 'app-home-page',
@@ -17,8 +16,7 @@ import {IconPickerComponent} from '../icon-picker/icon-picker.component';
Button,
NgIf,
DashboardScrollerComponent,
AsyncPipe,
IconPickerComponent
AsyncPipe
],
providers: [DialogService],
})
@@ -32,21 +30,13 @@ export class DashboardComponent {
);
}
createNewLibrary(event: MouseEvent) {
const buttonRect = (event.target as HTMLElement).getBoundingClientRect();
const dialogWidthPercentage = 50;
const viewportWidth = window.innerWidth;
const dialogWidth = (dialogWidthPercentage / 100) * viewportWidth;
const leftPosition = buttonRect.left + (buttonRect.width / 2) - (dialogWidth / 2);
createNewLibrary() {
this.ref = this.dialogService.open(LibraryCreatorComponent, {
header: 'Create New Library',
modal: true,
width: `${dialogWidthPercentage}%`,
height: 'auto',
style: {
position: 'absolute',
top: `${buttonRect.bottom + 10}px`,
left: `${Math.max(leftPosition, 0)}px`
},
width: '50%',
height: '50%',
style: {bottom: '15%'}
});
}
}

View File

@@ -9,7 +9,6 @@
justify-content: center;
align-items: center;
cursor: pointer;
font-size: 36px;
}
.icon-search {

View File

@@ -1,4 +1,4 @@
import {Component} from '@angular/core';
import {Component, EventEmitter, Output} from '@angular/core';
import {DialogModule} from 'primeng/dialog';
import {NgForOf} from '@angular/common';
import {FormsModule} from '@angular/forms';
@@ -14,10 +14,9 @@ import {FormsModule} from '@angular/forms';
],
})
export class IconPickerComponent {
iconDialogVisible: boolean = true;
iconDialogVisible: boolean = false;
selectedIcon: string | null = null;
searchText: string = '';
iconCategories: string[] = [
"address-book", "align-center", "align-justify", "align-left", "align-right", "android",
"angle-double-down", "angle-double-left", "angle-double-right", "angle-double-up", "angle-down", "angle-left",
@@ -53,6 +52,8 @@ export class IconPickerComponent {
icons: string[] = this.createIconList(this.iconCategories);
@Output() iconSelected = new EventEmitter<string>();
createIconList(categories: string[]): string[] {
return categories.map(iconName => `pi pi-${iconName}`);
}
@@ -64,14 +65,14 @@ export class IconPickerComponent {
return this.icons.filter(icon => icon.toLowerCase().includes(this.searchText.toLowerCase()));
}
openIconDialog() {
open() {
this.iconDialogVisible = true;
}
selectIcon(icon: string) {
this.selectedIcon = icon;
this.iconDialogVisible = false;
console.log('Selected Icon:', icon);
this.iconSelected.emit(icon);
}
}

View File

@@ -28,7 +28,7 @@ export class AppMenuComponent implements OnInit {
separator: false,
items: libraries.map((library) => ({
label: library.name,
icon: 'pi pi-fw pi-book',
icon: 'pi pi-' + library.icon,
routerLink: [`/library/${library.id}/books`],
})),
},
@@ -42,7 +42,7 @@ export class AppMenuComponent implements OnInit {
separator: false,
items: shelves.map((shelf) => ({
label: shelf.name,
icon: 'pi pi-fw pi-heart',
icon: 'pi pi-' + shelf.icon,
routerLink: [`/shelf/${shelf.id}/books`],
})),
},

View File

@@ -40,7 +40,7 @@ export class AppTopBarComponent implements OnDestroy {
this.ref = this.dialogService.open(LibraryCreatorComponent, {
header: 'Create New Library',
modal: true,
width: '50%',
width: '40%',
height: '50%',
style: {bottom: '15%'}
});

View File

@@ -2,27 +2,35 @@
<p-stepperPanel header="Library Name">
<ng-template pTemplate="content" let-nextCallback="nextCallback" let-index="index">
<div class="flex flex-column h-21rem">
<div
class="border-2 border-dashed surface-border border-round surface-ground flex-auto flex justify-content-center align-items-center font-medium">
<input
type="text"
pInputText
[(ngModel)]="value"
placeholder="Enter library name..."/>
<div class="border-2 border-dashed surface-border border-round surface-ground flex-auto flex justify-content-center align-items-center font-medium">
<div class="library-name-icon-parent">
<div class="library-name-div">
<div>Library Name: </div>
<input type="text" pInputText [(ngModel)]="value" placeholder="Enter library name..."/>
</div>
<div class="library-icon-div">
<div>Library Icon:</div>
<div *ngIf="!selectedIcon">
<p-button label="Select Icon" icon="pi pi-search" (onClick)="openIconPicker()"></p-button>
</div>
<div *ngIf="selectedIcon" class="selected-icon-container">
<i [class]="selectedIcon" style="font-size: 1.5rem; margin-right: 0.5rem;"></i>
<p-button icon="pi pi-times" (onClick)="clearSelectedIcon()" [rounded]="true" [text]="true" [outlined]="true" class="remove-icon-button" severity="danger"></p-button>
</div>
</div>
</div>
<app-icon-picker (iconSelected)="onIconSelected($event)"></app-icon-picker>
</div>
</div>
<div class="flex pt-4 justify-content-end">
<p-button
label="Next"
icon="pi pi-arrow-right"
iconPos="right"
(onClick)="nextCallback.emit()"/>
<p-button label="Next" icon="pi pi-arrow-right" iconPos="right" (onClick)="nextCallback.emit()"/>
</div>
</ng-template>
</p-stepperPanel>
<p-stepperPanel header="Media Location">
<ng-template pTemplate="content" let-prevCallback="prevCallback" let-index="index">
<div class="flex flex-column">
<div class="flex flex-column xxx">
<div
class="border-2 border-dashed surface-border border-round surface-ground flex-auto flex align-items-start"
style="padding-top: 2.5rem;">

View File

@@ -1,26 +1,51 @@
.container {
.library-name-icon-parent {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
gap: 1rem;
}
.library-name-div {
display: flex;
align-items: center;
position: relative;
height: 100%;
gap: 0.5rem;
}
.textbox {
padding: 10px;
margin-bottom: 20px;
.library-icon-div {
display: flex;
align-items: center;
gap: 0.5rem;
}
.button {
position: absolute;
bottom: 10px;
left: 10px;
.library-name-div label,
.library-icon-div label {
margin-right: 0.5rem;
}
.tr-custom {
padding: 0.25rem 0.5rem !important;
border: 0px !important;
.library-name-div input {
width: 200px;
}
.library-icon-div p-button {
margin-left: 0.8rem;
}
.p-stepperPanel {
display: flex;
flex-direction: column;
padding: 1rem;
}
.p-stepperPanel .flex {
display: flex;
}
.p-stepperPanel .justify-content-end {
justify-content: flex-end;
}
.p-stepperPanel .mt-3 {
margin-top: 1rem;
}
.p-table-custom {
@@ -30,15 +55,37 @@
overflow: scroll;
}
.td-x-custom {
text-align: right;
.tr-custom {
padding: 0.25rem 0.5rem !important;
border: 0px !important;
}
.mt-3 {
margin-top: 1rem;
.td-x-custom {
text-align: right;
}
.w-100 {
width: 100%;
}
.p-button {
margin-left: 0.5rem;
}
.selected-icon-container {
display: flex;
align-items: center;
margin-left: 1rem;
i {
color: var(--text-color);
}
.remove-icon-button {
margin-left: 0.5rem;
}
}
.xxx {
min-height: 295px;
}

View File

@@ -1,9 +1,10 @@
import {Component} from '@angular/core';
import {Component, ViewChild} from '@angular/core';
import {DialogService, DynamicDialogRef} from 'primeng/dynamicdialog';
import {DirectoryPickerComponent} from '../directory-picker/directory-picker.component';
import {MessageService} from 'primeng/api';
import {Router} from '@angular/router';
import {LibraryService} from '../../service/library.service';
import {IconPickerComponent} from '../icon-picker/icon-picker.component';
@Component({
selector: 'app-library-creator',
@@ -16,13 +17,15 @@ export class LibraryCreatorComponent {
value: string = '';
folders: string[] = [];
ref: DynamicDialogRef | undefined;
@ViewChild(IconPickerComponent) iconPicker: IconPickerComponent | undefined;
selectedIcon: string | null = null;
constructor(
private dialogService: DialogService,
private dynamicDialogRef: DynamicDialogRef,
private libraryService: LibraryService,
private router: Router,
) {
private router: Router) {
}
show() {
@@ -50,10 +53,25 @@ export class LibraryCreatorComponent {
this.folders.splice(index, 1);
}
openIconPicker() {
if (this.iconPicker) {
this.iconPicker.open();
}
}
onIconSelected(icon: string) {
this.selectedIcon = icon;
}
clearSelectedIcon() {
this.selectedIcon = null;
}
addLibrary() {
const newLibrary = {
name: this.value,
paths: this.folders,
icon: this.selectedIcon ? this.selectedIcon.replace('pi pi-', '') : 'heart'
};
this.libraryService.createLibrary(newLibrary).subscribe({
next: (createdLibrary) => {
@@ -93,4 +111,5 @@ export class LibraryCreatorComponent {
);
}
}*/
}

View File

@@ -1,7 +1,7 @@
<div class="dialog-container">
<div class="checkbox-list">
<div *ngFor="let shelf of shelves$ | async" class="flex items-center">
<p-checkbox [inputId]="shelf.name" name="group" [value]="shelf" [(ngModel)]="selectedShelves" />
<p-checkbox [inputId]="shelf.name" name="group" [value]="shelf" [(ngModel)]="selectedShelves"/>
<label [for]="shelf.id" class="ml-2">{{ shelf.name }}</label>
</div>
</div>
@@ -17,13 +17,23 @@
</div>
</div>
</div>
<p-dialog [(visible)]="displayShelfDialog" header="Create Shelf" [modal]="true" [closable]="true" (onHide)="closeShelfDialog()" [style]="{ width: '400px', height: 'auto' }">
<app-icon-picker (iconSelected)="onIconSelected($event)"></app-icon-picker>
<p-dialog [(visible)]="displayShelfDialog" header="Create Shelf" [modal]="true" [draggable]="false" [closable]="true" (onHide)="closeShelfDialog()" [style]="{ width: '400px', height: 'auto' }">
<div class="p-fluid">
<div class="field">
<label for="shelfName">Shelf Name</label>
<input id="shelfName" type="text" pInputText [(ngModel)]="shelfName"/>
</div>
<div class="field">
<label>Shelf Icon</label>
<div *ngIf="!selectedIcon">
<p-button label="Select Icon" icon="pi pi-search" (onClick)="openIconPicker()"></p-button>
</div>
<div *ngIf="selectedIcon" class="mt-2 flex align-items-center">
<i [class]="selectedIcon" class="mr-2"></i>
<p-button label="Remove" icon="pi pi-times" [text]="true" [outlined]="true" severity="warning" (onClick)="clearSelectedIcon()"></p-button>
</div>
</div>
</div>
<ng-template pTemplate="footer">
<p-button label="Cancel" severity="secondary" (onClick)="closeShelfDialog()" class="p-button-text"></p-button>

View File

@@ -1,4 +1,4 @@
import {Component, OnInit} from '@angular/core';
import {Component, OnInit, ViewChild} from '@angular/core';
import {DynamicDialogConfig, DynamicDialogRef} from 'primeng/dynamicdialog';
import {Book} from '../../model/book.model';
import {MessageService} from 'primeng/api';
@@ -7,6 +7,7 @@ import {Observable} from 'rxjs';
import {BookService} from '../../service/book.service';
import {map, tap} from 'rxjs/operators';
import {Shelf} from '../../model/shelf.model';
import {IconPickerComponent} from '../icon-picker/icon-picker.component';
@Component({
selector: 'app-shelf-assigner',
@@ -22,6 +23,8 @@ export class ShelfAssignerComponent implements OnInit {
shelfName: string = '';
bookIds: Set<number> = new Set();
isMultiBooks: boolean = false;
selectedIcon: string | null = null;
@ViewChild(IconPickerComponent) iconPicker: IconPickerComponent | undefined;
constructor(
private shelfService: ShelfService,
@@ -46,7 +49,11 @@ export class ShelfAssignerComponent implements OnInit {
}
saveNewShelf(): void {
this.shelfService.createShelf(this.shelfName).subscribe(
const newShelf = {
name: this.shelfName,
icon: this.selectedIcon ? this.selectedIcon.replace('pi pi-', '') : 'heart'
};
this.shelfService.createShelf(newShelf).subscribe(
() => {
this.messageService.add({severity: 'info', summary: 'Success', detail: 'Shelf created: ' + this.shelfName});
this.displayShelfDialog = false;
@@ -99,4 +106,19 @@ export class ShelfAssignerComponent implements OnInit {
closeDialog(): void {
this.dynamicDialogRef.close();
}
openIconPicker() {
if (this.iconPicker) {
this.iconPicker.open();
}
}
clearSelectedIcon() {
this.selectedIcon = null;
}
onIconSelected(icon: string) {
this.selectedIcon = icon;
}
}

View File

@@ -3,6 +3,7 @@ import {SortOption} from './sort.model';
export interface Library {
id?: number;
name: string;
icon: string;
sort?: SortOption;
paths: string[];
}

View File

@@ -3,5 +3,6 @@ import {SortOption} from './sort.model';
export interface Shelf {
id?: number;
name: string;
icon: string;
sort?: SortOption;
}

View File

@@ -25,8 +25,8 @@ export class ShelfService {
});
}
createShelf(name: string): Observable<Shelf> {
return this.http.post<Shelf>(this.url, {name}).pipe(
createShelf(shelf: Shelf): Observable<Shelf> {
return this.http.post<Shelf>(this.url, shelf).pipe(
map(newShelf => {
this.shelves.next([...this.shelves.value, newShelf])
return newShelf;
@@ -56,14 +56,14 @@ export class ShelfService {
deleteShelf(shelfId: number) {
return this.http.delete<void>(`${this.url}/${shelfId}`).pipe(
tap(() => {
this.bookService.removeBooksByLibraryId(shelfId);
let shelves = this.shelves.value.filter(shelf => shelf.id !== shelfId);
this.shelves.next(shelves);
}),
catchError(error => {
return of();
})
tap(() => {
this.bookService.removeBooksByLibraryId(shelfId);
let shelves = this.shelves.value.filter(shelf => shelf.id !== shelfId);
this.shelves.next(shelves);
}),
catchError(error => {
return of();
})
);
}
}