mirror of
https://github.com/booklore-app/booklore.git
synced 2025-12-23 22:28:11 -05:00
feat(bookmark): improve bookmark feature by adding rename, note, color, priority functionalities (#1946)
* feat(bookmark): add bookmark editing, priority, color, notes, and improved sorting - Add UpdateBookMarkRequest DTO and bookmark editing dialog/component in frontend - Extend BookMark model/entity with color, notes, priority, updatedAt fields - Implement bookmark update API and service logic with validation - Sort bookmarks by priority and creation date - Add Flyway migrations for new columns and index - Update tests for new bookmark features Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * fix(bookmark): prevent notes length display error in edit dialog Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * fix(bookmark): reset editing state and improve dialog cancel handling Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * fix(bookmark): improve edit dialog template with Angular @if and conditional error display Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * feat(bookmark): add view dialog, search, and improved display for bookmarks in reader Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * feat(bookmark): redesign bookmarks section UI with improved layout, styling, and interactions Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * feat(bookmark): enhance view dialog UI with improved layout, styling, and priority display Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * chore(migration): rename migration files to maintain sequential versioning Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * feat(bookmark): add view and edit actions to bookmark list with improved UI and tooltips Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * feat(bookmark): add search and filter functionality to bookmark list in EPUB reader Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * feat(bookmark): update search input to use PrimeNG IconField and InputIcon components Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> --------- Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
import { Component, EventEmitter, Input, Output, OnChanges, SimpleChanges } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Dialog } from 'primeng/dialog';
|
||||
import { InputText } from 'primeng/inputtext';
|
||||
import { ColorPicker } from 'primeng/colorpicker';
|
||||
import { Textarea } from 'primeng/textarea';
|
||||
import { InputNumber } from 'primeng/inputnumber';
|
||||
import { Button } from 'primeng/button';
|
||||
import { BookMark, UpdateBookMarkRequest } from '../../../../shared/service/book-mark.service';
|
||||
import { PrimeTemplate } from 'primeng/api';
|
||||
|
||||
export interface BookmarkFormData {
|
||||
title: string;
|
||||
color: string;
|
||||
notes: string;
|
||||
priority: number | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-bookmark-edit-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
Dialog,
|
||||
InputText,
|
||||
ColorPicker,
|
||||
Textarea,
|
||||
InputNumber,
|
||||
Button,
|
||||
PrimeTemplate
|
||||
],
|
||||
template: `
|
||||
<p-dialog
|
||||
[(visible)]="visible"
|
||||
[modal]="true"
|
||||
[closable]="true"
|
||||
[style]="{width: '500px'}"
|
||||
[draggable]="false"
|
||||
[resizable]="false"
|
||||
[closeOnEscape]="true"
|
||||
[appendTo]="'body'"
|
||||
header="Edit Bookmark"
|
||||
(onHide)="onDialogHide()">
|
||||
|
||||
@if (formData) {
|
||||
<div class="p-4">
|
||||
<div class="field mb-4">
|
||||
<label for="title" class="block text-sm font-medium mb-2">Title <span class="text-red-500">*</span></label>
|
||||
<input
|
||||
pInputText
|
||||
id="title"
|
||||
type="text"
|
||||
[(ngModel)]="formData.title"
|
||||
class="w-full"
|
||||
[class.ng-invalid]="titleError"
|
||||
[class.ng-dirty]="titleError"
|
||||
placeholder="Enter bookmark title"
|
||||
[maxlength]="255"
|
||||
(ngModelChange)="titleError = false">
|
||||
@if (titleError) {
|
||||
<small class="text-red-500">Title is required</small>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="field mb-4">
|
||||
<label for="color" class="block text-sm font-medium mb-2">Color</label>
|
||||
<div class="flex align-items-center gap-2">
|
||||
<p-colorPicker
|
||||
[(ngModel)]="formData.color"
|
||||
[appendTo]="'body'"
|
||||
format="hex">
|
||||
</p-colorPicker>
|
||||
<input
|
||||
pInputText
|
||||
[(ngModel)]="formData.color"
|
||||
class="w-8rem"
|
||||
placeholder="#000000"
|
||||
pattern="^#[0-9A-Fa-f]{6}$">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field mb-4">
|
||||
<label for="notes" class="block text-sm font-medium mb-2">Notes</label>
|
||||
<textarea
|
||||
pInputTextarea
|
||||
id="notes"
|
||||
[(ngModel)]="formData.notes"
|
||||
class="w-full"
|
||||
rows="3"
|
||||
placeholder="Add notes about this bookmark"
|
||||
[maxlength]="2000">
|
||||
</textarea>
|
||||
<small class="text-muted">{{ formData.notes.length || 0 }}/2000</small>
|
||||
</div>
|
||||
|
||||
<div class="field mb-4">
|
||||
<label for="priority" class="block text-sm font-medium mb-2">Priority (1 = High, 5 = Low)</label>
|
||||
<p-inputNumber
|
||||
id="priority"
|
||||
[(ngModel)]="formData.priority"
|
||||
[min]="1"
|
||||
[max]="5"
|
||||
[showButtons]="true"
|
||||
buttonLayout="horizontal"
|
||||
spinnerMode="horizontal"
|
||||
decrementButtonClass="p-button-secondary"
|
||||
incrementButtonClass="p-button-secondary"
|
||||
decrementButtonIcon="pi pi-minus"
|
||||
incrementButtonIcon="pi pi-plus">
|
||||
</p-inputNumber>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ng-template pTemplate="footer">
|
||||
<div class="flex justify-content-between">
|
||||
<p-button
|
||||
label="Cancel"
|
||||
icon="pi pi-times"
|
||||
(click)="onCancel()"
|
||||
[text]="true"
|
||||
severity="secondary">
|
||||
</p-button>
|
||||
<p-button
|
||||
label="Save"
|
||||
icon="pi pi-check"
|
||||
(click)="onSave()"
|
||||
[loading]="isSaving"
|
||||
[disabled]="!formData || isSaving">
|
||||
</p-button>
|
||||
</div>
|
||||
</ng-template>
|
||||
</p-dialog>
|
||||
`
|
||||
})
|
||||
export class BookmarkEditDialogComponent implements OnChanges {
|
||||
@Input() visible = false;
|
||||
@Input() bookmark: BookMark | null = null;
|
||||
@Input() isSaving = false;
|
||||
|
||||
@Output() visibleChange = new EventEmitter<boolean>();
|
||||
@Output() save = new EventEmitter<UpdateBookMarkRequest>();
|
||||
@Output() cancelEdit = new EventEmitter<void>();
|
||||
|
||||
formData: BookmarkFormData | null = null;
|
||||
titleError = false;
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['bookmark'] && this.bookmark) {
|
||||
this.titleError = false;
|
||||
this.formData = {
|
||||
title: this.bookmark.title || '',
|
||||
color: this.bookmark.color || '#3B82F6',
|
||||
notes: this.bookmark.notes || '',
|
||||
priority: this.bookmark.priority ?? 3
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
onSave(): void {
|
||||
if (!this.formData) return;
|
||||
|
||||
if (!this.formData.title || !this.formData.title.trim()) {
|
||||
this.titleError = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const request: UpdateBookMarkRequest = {
|
||||
title: this.formData.title.trim(),
|
||||
color: this.formData.color || undefined,
|
||||
notes: this.formData.notes || undefined,
|
||||
priority: this.formData.priority ?? undefined
|
||||
};
|
||||
|
||||
this.save.emit(request);
|
||||
}
|
||||
|
||||
onDialogHide(): void {
|
||||
// When dialog is closed via X button, treat it as cancel
|
||||
this.onCancel();
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.formData = null; // Clear form data
|
||||
this.visible = false;
|
||||
this.visibleChange.emit(false);
|
||||
this.cancelEdit.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Dialog } from 'primeng/dialog';
|
||||
import { Button } from 'primeng/button';
|
||||
import { BookMark } from '../../../../shared/service/book-mark.service';
|
||||
import { PrimeTemplate } from 'primeng/api';
|
||||
|
||||
@Component({
|
||||
selector: 'app-bookmark-view-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
Dialog,
|
||||
Button,
|
||||
PrimeTemplate
|
||||
],
|
||||
template: `
|
||||
<p-dialog
|
||||
[(visible)]="visible"
|
||||
[modal]="true"
|
||||
[closable]="true"
|
||||
[style]="{width: '420px', maxWidth: '95vw'}"
|
||||
[draggable]="false"
|
||||
[resizable]="false"
|
||||
[closeOnEscape]="true"
|
||||
[appendTo]="'body'"
|
||||
header="View Bookmark"
|
||||
(onHide)="onClose()">
|
||||
|
||||
@if (bookmark) {
|
||||
<div class="bookmark-view-content">
|
||||
<div class="bookmark-view-header">
|
||||
<span class="bookmark-view-color" [style.background-color]="bookmark.color || 'var(--primary-color)'"></span>
|
||||
<h3 class="bookmark-view-title">{{ bookmark.title }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="bookmark-view-details">
|
||||
<div class="bookmark-view-row">
|
||||
<span class="bookmark-view-label">Created</span>
|
||||
<span class="bookmark-view-value">{{ bookmark.createdAt | date:'MMM d, y, h:mm a' }}</span>
|
||||
</div>
|
||||
<div class="bookmark-view-row">
|
||||
<span class="bookmark-view-label">Priority</span>
|
||||
<span class="bookmark-view-priority" [attr.data-priority]="bookmark.priority">
|
||||
{{ getPriorityLabel(bookmark.priority) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bookmark-view-notes">
|
||||
<span class="bookmark-view-label">Notes</span>
|
||||
@if (bookmark.notes) {
|
||||
<p class="bookmark-view-notes-content">{{ bookmark.notes }}</p>
|
||||
} @else {
|
||||
<p class="bookmark-view-notes-empty">No notes added</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ng-template pTemplate="footer">
|
||||
<p-button
|
||||
label="Close"
|
||||
icon="pi pi-times"
|
||||
(click)="onClose()"
|
||||
[text]="true"
|
||||
severity="secondary">
|
||||
</p-button>
|
||||
</ng-template>
|
||||
</p-dialog>
|
||||
`,
|
||||
styles: [`
|
||||
.bookmark-view-content {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.bookmark-view-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
.bookmark-view-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bookmark-view-title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.bookmark-view-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.bookmark-view-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bookmark-view-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.bookmark-view-value {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.bookmark-view-priority {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 1rem;
|
||||
background: color-mix(in srgb, var(--primary-color) 15%, transparent);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.bookmark-view-priority[data-priority="1"] {
|
||||
background: color-mix(in srgb, #ef4444 15%, transparent);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.bookmark-view-priority[data-priority="2"] {
|
||||
background: color-mix(in srgb, #f97316 15%, transparent);
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.bookmark-view-priority[data-priority="3"] {
|
||||
background: color-mix(in srgb, var(--primary-color) 15%, transparent);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.bookmark-view-priority[data-priority="4"],
|
||||
.bookmark-view-priority[data-priority="5"] {
|
||||
background: color-mix(in srgb, #6b7280 15%, transparent);
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.bookmark-view-notes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bookmark-view-notes-content {
|
||||
margin: 0;
|
||||
padding: 0.875rem;
|
||||
background: var(--surface-ground);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.bookmark-view-notes-empty {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class BookmarkViewDialogComponent {
|
||||
@Input() visible = false;
|
||||
@Input() bookmark: BookMark | null = null;
|
||||
@Output() visibleChange = new EventEmitter<boolean>();
|
||||
|
||||
onClose(): void {
|
||||
this.visible = false;
|
||||
this.visibleChange.emit(false);
|
||||
}
|
||||
|
||||
getPriorityLabel(priority: number | undefined): string {
|
||||
if (priority === undefined) return 'Normal';
|
||||
if (priority <= 1) return 'Highest';
|
||||
if (priority === 2) return 'High';
|
||||
if (priority === 3) return 'Normal';
|
||||
if (priority === 4) return 'Low';
|
||||
return 'Lowest';
|
||||
}
|
||||
}
|
||||
@@ -12,72 +12,101 @@
|
||||
<div class="progress-info">
|
||||
<span class="progress-percentage"><span class="progress-label">Progress: </span>{{ progressPercentage }}%</span>
|
||||
</div>
|
||||
<p-drawer [(visible)]="isDrawerVisible" [modal]="false" [position]="'left'" [style]="{ width: '320px' }">
|
||||
<ng-template pTemplate="header">
|
||||
<div class="drawer-header">
|
||||
<span class="drawer-title">Table of Contents</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
<p-drawer [(visible)]="isDrawerVisible" [modal]="false" [position]="'left'" [style]="{ width: '320px' }">
|
||||
<ng-template pTemplate="header">
|
||||
<div class="drawer-header">
|
||||
<span class="drawer-title">Table of Contents</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<p-tabs value="0">
|
||||
<p-tablist>
|
||||
<p-tab value="0">
|
||||
<div class="tab-header">
|
||||
<i class="pi pi-book"></i>
|
||||
<span>Chapters</span>
|
||||
<p-tabs value="0">
|
||||
<p-tablist>
|
||||
<p-tab value="0">
|
||||
<div class="tab-header">
|
||||
<i class="pi pi-book"></i>
|
||||
<span>Chapters</span>
|
||||
</div>
|
||||
</p-tab>
|
||||
<p-tab value="1">
|
||||
<div class="tab-header">
|
||||
<i class="pi pi-bookmark"></i>
|
||||
<span>Bookmarks</span>
|
||||
</div>
|
||||
</p-tab>
|
||||
</p-tablist>
|
||||
<p-tabpanels>
|
||||
<p-tabpanel value="0">
|
||||
<div class="tab-content">
|
||||
<ul class="chapter-list">
|
||||
@for (chapter of chapters; track chapter) {
|
||||
<li (click)="navigateToChapter(chapter); $event.stopPropagation()"
|
||||
class="chapter-item"
|
||||
[class.current-chapter]="chapter.href === currentChapterHref"
|
||||
[style.padding-left.rem]="chapter.level * 1.5 + 0.75">
|
||||
<i class="pi pi-chevron-right chapter-icon"></i>
|
||||
<span class="chapter-label">{{ chapter.label }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</p-tabpanel>
|
||||
<p-tabpanel value="1">
|
||||
<div class="tab-content">
|
||||
<div class="p-2 mb-2">
|
||||
<p-iconfield class="w-full">
|
||||
<p-inputicon class="pi pi-search"/>
|
||||
<input
|
||||
pInputText
|
||||
type="text"
|
||||
[(ngModel)]="filterText"
|
||||
placeholder="Search bookmarks..."
|
||||
class="w-full p-inputtext-sm">
|
||||
</p-iconfield>
|
||||
</div>
|
||||
@if (filteredBookmarks.length === 0) {
|
||||
<div class="empty-state">
|
||||
<i class="pi pi-bookmark"></i>
|
||||
<p>No bookmarks found</p>
|
||||
<span>{{ filterText ? 'Try a different search term' : 'Tap the bookmark icon to save your place' }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<ul class="bookmark-list">
|
||||
@for (bookmark of filteredBookmarks; track bookmark.id) {
|
||||
<li class="bookmark-item" (click)="navigateToBookmark(bookmark); $event.stopPropagation()">
|
||||
<i class="pi pi-bookmark-fill bookmark-icon" [style.color]="bookmark.color || 'var(--primary-color)'"></i>
|
||||
<span class="bookmark-label">{{ bookmark.title }}</span>
|
||||
<div class="bookmark-actions">
|
||||
<button
|
||||
class="bookmark-action-btn"
|
||||
(click)="openViewDialog(bookmark); $event.stopPropagation()"
|
||||
pTooltip="View Details"
|
||||
tooltipPosition="bottom">
|
||||
<i class="pi pi-info-circle"></i>
|
||||
</button>
|
||||
<button
|
||||
class="bookmark-action-btn"
|
||||
(click)="openEditBookmarkDialog(bookmark); $event.stopPropagation()"
|
||||
pTooltip="Edit"
|
||||
tooltipPosition="bottom">
|
||||
<i class="pi pi-pencil"></i>
|
||||
</button>
|
||||
<button
|
||||
class="bookmark-action-btn delete"
|
||||
(click)="deleteBookmark(bookmark.id); $event.stopPropagation()"
|
||||
pTooltip="Delete"
|
||||
tooltipPosition="bottom">
|
||||
<i class="pi pi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</p-tab>
|
||||
<p-tab value="1">
|
||||
<div class="tab-header">
|
||||
<i class="pi pi-bookmark"></i>
|
||||
<span>Bookmarks</span>
|
||||
</div>
|
||||
</p-tab>
|
||||
</p-tablist>
|
||||
<p-tabpanels>
|
||||
<p-tabpanel value="0">
|
||||
<div class="tab-content">
|
||||
<ul class="chapter-list">
|
||||
@for (chapter of chapters; track chapter) {
|
||||
<li (click)="navigateToChapter(chapter); $event.stopPropagation()"
|
||||
class="chapter-item"
|
||||
[class.current-chapter]="chapter.href === currentChapterHref"
|
||||
[style.padding-left.rem]="chapter.level * 1.5 + 0.75">
|
||||
<i class="pi pi-chevron-right chapter-icon"></i>
|
||||
<span class="chapter-label">{{ chapter.label }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</p-tabpanel>
|
||||
<p-tabpanel value="1">
|
||||
<div class="tab-content">
|
||||
@if (bookmarks.length === 0) {
|
||||
<div class="empty-state">
|
||||
<i class="pi pi-bookmark"></i>
|
||||
<p>No bookmarks yet</p>
|
||||
<span>Tap the bookmark icon to save your place</span>
|
||||
</div>
|
||||
} @else {
|
||||
<ul class="bookmark-list">
|
||||
@for (bookmark of bookmarks; track bookmark.id) {
|
||||
<li class="bookmark-item" (click)="navigateToBookmark(bookmark); $event.stopPropagation()">
|
||||
<i class="pi pi-bookmark-fill bookmark-icon"></i>
|
||||
<span class="bookmark-label">{{ bookmark.title }}</span>
|
||||
<button
|
||||
class="bookmark-delete"
|
||||
(click)="deleteBookmark(bookmark.id); $event.stopPropagation()">
|
||||
<i class="pi pi-trash"></i>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</p-tabpanel>
|
||||
</p-tabpanels>
|
||||
</p-tabs>
|
||||
</p-drawer>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</p-tabpanel>
|
||||
</p-tabpanels>
|
||||
</p-tabs>
|
||||
</p-drawer>
|
||||
</div>
|
||||
|
||||
<div class="header-center">
|
||||
@@ -268,3 +297,18 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Bookmark Edit Dialog Component -->
|
||||
<app-bookmark-edit-dialog
|
||||
[(visible)]="showEditBookmarkDialog"
|
||||
[bookmark]="editingBookmark"
|
||||
[isSaving]="isEditingBookmark"
|
||||
(save)="onBookmarkSave($event)"
|
||||
(cancelEdit)="onBookmarkCancel()">
|
||||
</app-bookmark-edit-dialog>
|
||||
|
||||
<!-- Bookmark View Dialog Component -->
|
||||
<app-bookmark-view-dialog
|
||||
[(visible)]="viewDialogVisible"
|
||||
[bookmark]="selectedBookmark">
|
||||
</app-bookmark-view-dialog>
|
||||
|
||||
@@ -308,8 +308,15 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bookmark-delete {
|
||||
.bookmark-actions {
|
||||
opacity: 0;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-left: auto;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.bookmark-action-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
@@ -318,9 +325,16 @@
|
||||
color: color-mix(in srgb, var(--text-color) 60%, transparent);
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--primary-color) 15%, transparent);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
&.delete:hover {
|
||||
background: color-mix(in srgb, #ef4444 15%, transparent);
|
||||
color: #ef4444;
|
||||
}
|
||||
@@ -334,7 +348,7 @@
|
||||
background-color: color-mix(in srgb, var(--primary-color) 8%, transparent);
|
||||
transform: translateX(4px);
|
||||
|
||||
.bookmark-delete {
|
||||
.bookmark-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -363,24 +377,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.bookmark-delete {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
color: color-mix(in srgb, var(--text-color) 60%, transparent);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, #ef4444 15%, transparent);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
|
||||
@@ -3,6 +3,8 @@ import ePub from 'epubjs';
|
||||
import {Drawer} from 'primeng/drawer';
|
||||
import {forkJoin, Subscription} from 'rxjs';
|
||||
import {Button} from 'primeng/button';
|
||||
import {InputText} from 'primeng/inputtext';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {ActivatedRoute} from '@angular/router';
|
||||
import {Book, BookSetting} from '../../../book/model/book.model';
|
||||
@@ -11,19 +13,43 @@ import {Select} from 'primeng/select';
|
||||
import {UserService} from '../../../settings/user-management/user.service';
|
||||
import {ProgressSpinner} from 'primeng/progressspinner';
|
||||
import {MessageService, PrimeTemplate} from 'primeng/api';
|
||||
import {BookMark, BookMarkService} from '../../../../shared/service/book-mark.service';
|
||||
import {BookMark, BookMarkService, UpdateBookMarkRequest} from '../../../../shared/service/book-mark.service';
|
||||
import {Tooltip} from 'primeng/tooltip';
|
||||
import {Slider} from 'primeng/slider';
|
||||
import {FALLBACK_EPUB_SETTINGS, getChapter} from '../epub-reader-helper';
|
||||
import {EpubThemeUtil, EpubTheme} from '../epub-theme-util';
|
||||
import {PageTitleService} from "../../../../shared/service/page-title.service";
|
||||
import {Tab, TabList, TabPanel, TabPanels, Tabs} from 'primeng/tabs';
|
||||
import {IconField} from 'primeng/iconfield';
|
||||
import {InputIcon} from 'primeng/inputicon';
|
||||
import {BookmarkEditDialogComponent} from './bookmark-edit-dialog.component';
|
||||
import {BookmarkViewDialogComponent} from './bookmark-view-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-epub-reader',
|
||||
templateUrl: './epub-reader.component.html',
|
||||
styleUrls: ['./epub-reader.component.scss'],
|
||||
imports: [Drawer, Button, FormsModule, Select, ProgressSpinner, Tooltip, Slider, PrimeTemplate, Tabs, TabList, Tab, TabPanels, TabPanel],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
Drawer,
|
||||
Button,
|
||||
Select,
|
||||
ProgressSpinner,
|
||||
Tooltip,
|
||||
Slider,
|
||||
PrimeTemplate,
|
||||
Tabs,
|
||||
TabList,
|
||||
Tab,
|
||||
TabPanels,
|
||||
TabPanel,
|
||||
IconField,
|
||||
InputIcon,
|
||||
BookmarkEditDialogComponent,
|
||||
BookmarkViewDialogComponent,
|
||||
InputText
|
||||
],
|
||||
standalone: true
|
||||
})
|
||||
export class EpubReaderComponent implements OnInit, OnDestroy {
|
||||
@@ -40,8 +66,15 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
|
||||
isBookmarked = false;
|
||||
isAddingBookmark = false;
|
||||
isDeletingBookmark = false;
|
||||
isEditingBookmark = false;
|
||||
isUpdatingPosition = false;
|
||||
private routeSubscription?: Subscription;
|
||||
|
||||
// Bookmark Filter & View
|
||||
filterText = '';
|
||||
viewDialogVisible = false;
|
||||
selectedBookmark: BookMark | null = null;
|
||||
|
||||
public locationsReady = false;
|
||||
public approxProgress = 0;
|
||||
public exactProgress = 0;
|
||||
@@ -54,6 +87,10 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
|
||||
private isMouseInTopRegion = false;
|
||||
private headerShownByMobileTouch = false;
|
||||
|
||||
// Properties for bookmark editing
|
||||
editingBookmark: BookMark | null = null;
|
||||
showEditBookmarkDialog = false;
|
||||
|
||||
private book: any;
|
||||
private rendition: any;
|
||||
private keyListener: (e: KeyboardEvent) => void = () => {
|
||||
@@ -209,6 +246,39 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
get filteredBookmarks(): BookMark[] {
|
||||
let filtered = this.bookmarks;
|
||||
|
||||
// Filter
|
||||
if (this.filterText && this.filterText.trim()) {
|
||||
const lowerFilter = this.filterText.toLowerCase().trim();
|
||||
filtered = filtered.filter(b =>
|
||||
(b.title && b.title.toLowerCase().includes(lowerFilter)) ||
|
||||
(b.notes && b.notes.toLowerCase().includes(lowerFilter))
|
||||
);
|
||||
}
|
||||
|
||||
// Sort: Priority ASC (1 is high), then CreatedAt DESC
|
||||
return [...filtered].sort((a, b) => {
|
||||
const priorityA = a.priority ?? 3; // Default to 3 (Normal) if undefined
|
||||
const priorityB = b.priority ?? 3;
|
||||
|
||||
if (priorityA !== priorityB) {
|
||||
return priorityA - priorityB;
|
||||
}
|
||||
|
||||
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||
|
||||
return dateB - dateA;
|
||||
});
|
||||
}
|
||||
|
||||
openViewDialog(bookmark: BookMark): void {
|
||||
this.selectedBookmark = bookmark;
|
||||
this.viewDialogVisible = true;
|
||||
}
|
||||
|
||||
updateThemeStyle(): void {
|
||||
this.applyCombinedTheme();
|
||||
this.updateViewerSetting();
|
||||
@@ -620,6 +690,8 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
|
||||
this.bookMarkService.createBookmark(request).subscribe({
|
||||
next: (bookmark) => {
|
||||
this.bookmarks.push(bookmark);
|
||||
// Force array update for change detection if needed, but simple push works with getter usually if ref is stable
|
||||
this.bookmarks = [...this.bookmarks];
|
||||
this.updateBookmarkStatus();
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
@@ -643,6 +715,11 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
|
||||
if (this.isDeletingBookmark) {
|
||||
return;
|
||||
}
|
||||
// Simple confirmation using window.confirm for now, as consistent with UserManagementComponent behavior seen in linting
|
||||
if (!confirm('Are you sure you want to delete this bookmark?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isDeletingBookmark = true;
|
||||
this.bookMarkService.deleteBookmark(bookmarkId).subscribe({
|
||||
next: () => {
|
||||
@@ -682,4 +759,85 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
|
||||
? this.bookmarks.some(b => b.cfi === this.currentCfi)
|
||||
: false;
|
||||
}
|
||||
|
||||
openEditBookmarkDialog(bookmark: BookMark): void {
|
||||
this.editingBookmark = { ...bookmark };
|
||||
this.showEditBookmarkDialog = true;
|
||||
}
|
||||
|
||||
onBookmarkSave(updateRequest: UpdateBookMarkRequest): void {
|
||||
if (!this.editingBookmark || this.isEditingBookmark) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isEditingBookmark = true;
|
||||
|
||||
this.bookMarkService.updateBookmark(this.editingBookmark.id, updateRequest).subscribe({
|
||||
next: (updatedBookmark) => {
|
||||
const index = this.bookmarks.findIndex(b => b.id === this.editingBookmark!.id);
|
||||
if (index !== -1) {
|
||||
this.bookmarks[index] = updatedBookmark;
|
||||
this.bookmarks = [...this.bookmarks]; // Trigger change detection for getter
|
||||
}
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Success',
|
||||
detail: 'Bookmark updated successfully',
|
||||
});
|
||||
this.showEditBookmarkDialog = false;
|
||||
this.editingBookmark = null; // Reset the editing bookmark after successful save
|
||||
this.isEditingBookmark = false;
|
||||
},
|
||||
error: () => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Failed to update bookmark',
|
||||
});
|
||||
this.showEditBookmarkDialog = false;
|
||||
this.editingBookmark = null; // Reset the editing bookmark even on error
|
||||
this.isEditingBookmark = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onBookmarkCancel(): void {
|
||||
this.showEditBookmarkDialog = false;
|
||||
this.editingBookmark = null; // Reset the editing bookmark when dialog is cancelled
|
||||
}
|
||||
|
||||
updateBookmarkPosition(bookmarkId: number): void {
|
||||
if (!this.currentCfi || this.isUpdatingPosition) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isUpdatingPosition = true;
|
||||
const updateRequest = {
|
||||
cfi: this.currentCfi
|
||||
};
|
||||
|
||||
this.bookMarkService.updateBookmark(bookmarkId, updateRequest).subscribe({
|
||||
next: (updatedBookmark) => {
|
||||
const index = this.bookmarks.findIndex(b => b.id === bookmarkId);
|
||||
if (index !== -1) {
|
||||
this.bookmarks[index] = updatedBookmark;
|
||||
this.bookmarks = [...this.bookmarks];
|
||||
}
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Success',
|
||||
detail: 'Bookmark position updated successfully',
|
||||
});
|
||||
this.isUpdatingPosition = false;
|
||||
},
|
||||
error: () => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Failed to update bookmark position',
|
||||
});
|
||||
this.isUpdatingPosition = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,15 @@ import {API_CONFIG} from '../../core/config/api-config';
|
||||
|
||||
export interface BookMark {
|
||||
id: number;
|
||||
userId?: number;
|
||||
bookId: number;
|
||||
cfi: string;
|
||||
title: string;
|
||||
color?: string;
|
||||
notes?: string;
|
||||
priority?: number;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CreateBookMarkRequest {
|
||||
@@ -17,6 +22,14 @@ export interface CreateBookMarkRequest {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface UpdateBookMarkRequest {
|
||||
title?: string;
|
||||
cfi?: string;
|
||||
color?: string;
|
||||
notes?: string;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
@@ -36,4 +49,7 @@ export class BookMarkService {
|
||||
deleteBookmark(bookmarkId: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.url}/${bookmarkId}`);
|
||||
}
|
||||
updateBookmark(bookmarkId: number, request: UpdateBookMarkRequest): Observable<BookMark> {
|
||||
return this.http.put<BookMark>(`${this.url}/${bookmarkId}`, request);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user