Merge pull request #2435 from booklore-app/develop

Merge develop into master for release
This commit is contained in:
ACX
2026-01-23 12:19:41 -07:00
committed by GitHub
111 changed files with 3626 additions and 1877 deletions

View File

@@ -2,8 +2,8 @@ package com.adityachandel.booklore.service.library;
import com.adityachandel.booklore.exception.ApiError;
import com.adityachandel.booklore.model.dto.settings.LibraryFile;
import com.adityachandel.booklore.model.entity.BookFileEntity;
import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.model.entity.BookFileEntity;
import com.adityachandel.booklore.model.entity.LibraryEntity;
import com.adityachandel.booklore.model.websocket.LogNotification;
import com.adityachandel.booklore.model.websocket.Topic;
@@ -22,7 +22,8 @@ import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@AllArgsConstructor
@@ -115,13 +116,19 @@ public class LibraryProcessingService {
return libraryEntity.getBookEntities().stream()
.filter(book -> (book.getDeleted() == null || !book.getDeleted()))
.filter(book -> !currentFullPaths.contains(book.getFullFilePath()))
.filter(book -> {
if (book.getBookFiles() == null || book.getBookFiles().isEmpty()) {
return true;
}
return !currentFullPaths.contains(book.getFullFilePath());
})
.map(BookEntity::getId)
.collect(Collectors.toList());
}
protected List<LibraryFile> detectNewBookPaths(List<LibraryFile> libraryFiles, LibraryEntity libraryEntity) {
Set<String> existingKeys = libraryEntity.getBookEntities().stream()
.filter(book -> book.getBookFiles() != null && !book.getBookFiles().isEmpty())
.map(this::generateUniqueKey)
.collect(Collectors.toSet());

View File

@@ -604,6 +604,13 @@ public class OpdsFeedService {
return DateTimeFormatter.ISO_INSTANT.format(java.time.Instant.now());
}
private boolean hasValidFilePath(Book book) {
return book.getFileName() != null
&& book.getLibraryPath() != null
&& book.getLibraryPath().getPath() != null
&& book.getFileSubPath() != null;
}
private String fileMimeType(Book book) {
if (book == null || book.getBookType() == null) {
return "application/octet-stream";
@@ -612,7 +619,7 @@ public class OpdsFeedService {
case PDF -> "application/pdf";
case EPUB -> "application/epub+zip";
case FB2 -> {
if (book.getFileName() != null) {
if (hasValidFilePath(book)) {
ArchiveUtils.ArchiveType type = ArchiveUtils.detectArchiveType(new File(FileUtils.getBookFullPath(book)));
if (type == ArchiveUtils.ArchiveType.ZIP) {
yield "application/zip";
@@ -632,7 +639,7 @@ public class OpdsFeedService {
};
}
if (book.getFileName() != null) {
if (hasValidFilePath(book)) {
ArchiveUtils.ArchiveType type = ArchiveUtils.detectArchiveType(new File(FileUtils.getBookFullPath(book)));
yield switch (type) {
case RAR -> "application/vnd.comicbook-rar";

View File

@@ -0,0 +1,112 @@
package com.adityachandel.booklore.service.library;
import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.model.entity.LibraryEntity;
import com.adityachandel.booklore.model.entity.LibraryPathEntity;
import com.adityachandel.booklore.model.enums.LibraryScanMode;
import com.adityachandel.booklore.repository.BookAdditionalFileRepository;
import com.adityachandel.booklore.repository.LibraryRepository;
import com.adityachandel.booklore.service.NotificationService;
import com.adityachandel.booklore.task.options.RescanLibraryContext;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class LibraryProcessingServiceRegressionTest {
@Mock
private LibraryRepository libraryRepository;
@Mock
private NotificationService notificationService;
@Mock
private BookAdditionalFileRepository bookAdditionalFileRepository;
@Mock
private LibraryFileProcessorRegistry fileProcessorRegistry;
@Mock
private BookRestorationService bookRestorationService;
@Mock
private BookDeletionService bookDeletionService;
@Mock
private LibraryFileHelper libraryFileHelper;
@Mock
private EntityManager entityManager;
@Mock
private LibraryFileProcessor libraryFileProcessor;
private LibraryProcessingService libraryProcessingService;
@BeforeEach
void setUp() {
libraryProcessingService = new LibraryProcessingService(
libraryRepository,
notificationService,
bookAdditionalFileRepository,
fileProcessorRegistry,
bookRestorationService,
bookDeletionService,
libraryFileHelper,
entityManager
);
}
@Test
void rescanLibrary_shouldThrowException_whenBookHasNoFiles(@TempDir Path tempDir) throws IOException {
long libraryId = 1L;
Path accessiblePath = tempDir.resolve("accessible");
Files.createDirectory(accessiblePath);
LibraryEntity libraryEntity = new LibraryEntity();
libraryEntity.setId(libraryId);
libraryEntity.setName("Test Library");
libraryEntity.setScanMode(LibraryScanMode.FILE_AS_BOOK);
LibraryPathEntity pathEntity = new LibraryPathEntity();
pathEntity.setId(10L);
pathEntity.setPath(accessiblePath.toString());
libraryEntity.setLibraryPaths(List.of(pathEntity));
BookEntity bookWithNoFiles = new BookEntity();
bookWithNoFiles.setId(1L);
bookWithNoFiles.setLibraryPath(pathEntity);
bookWithNoFiles.setBookFiles(Collections.emptyList()); // Empty files list
libraryEntity.setBookEntities(List.of(bookWithNoFiles));
when(libraryRepository.findById(libraryId)).thenReturn(Optional.of(libraryEntity));
when(fileProcessorRegistry.getProcessor(libraryEntity)).thenReturn(libraryFileProcessor);
// We need at least one file so it doesn't think the library is offline
when(libraryFileHelper.getLibraryFiles(libraryEntity, libraryFileProcessor)).thenReturn(List.of(
com.adityachandel.booklore.model.dto.settings.LibraryFile.builder()
.libraryPathEntity(pathEntity)
.fileName("other.epub")
.fileSubPath("")
.build()
));
RescanLibraryContext context = RescanLibraryContext.builder().libraryId(libraryId).build();
// Should not throw exception anymore
libraryProcessingService.rescanLibrary(context);
// Verify that the book with no files (ID 1) was detected as deleted
verify(bookDeletionService).processDeletedLibraryFiles(
argThat(list -> list.contains(1L)),
any()
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -40,31 +40,29 @@
"ngx-sse-client": "^20.0.1",
"primeicons": "^7.0.0",
"primeng": "^21.0.3",
"quill": "^2.0.3",
"quill": "^2.0.2",
"rxjs": "^7.8.2",
"showdown": "^2.1.0",
"tailwindcss-primeui": "^0.6.1",
"tslib": "^2.8.1",
"uuid": "^13.0.0",
"ws": "^8.19.0",
"zone.js": "^0.16.0"
},
"devDependencies": {
"@analogjs/vite-plugin-angular": "^2.2.2",
"@analogjs/vitest-angular": "^2.2.2",
"@angular/build": "^21.1.0",
"@angular/cli": "^21.1.0",
"@angular/compiler-cli": "^21.1.0",
"@tailwindcss/typography": "^0.5.19",
"@types/jasmine": "^5.1.15",
"@types/node": "^25.0.9",
"@types/showdown": "^2.0.6",
"@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^4.0.17",
"angular-eslint": "^21.1.0",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.2",
"jasmine-core": "^5.13.0",
"jsdom": "^27.4.0",
"tailwindcss": "^3.4.19",
"typescript": "~5.9.3",
"typescript-eslint": "^8.53.0",
"vitest": "^4.0.17"
}
}
}

View File

@@ -76,7 +76,7 @@
id="providerName"
[(ngModel)]="oidcProvider.providerName"
placeholder="e.g. Authentik"
class="w-full"/>
class="full-width"/>
</div>
<div class="form-field">
<label for="clientId">Client ID</label>
@@ -85,19 +85,19 @@
id="clientId"
[(ngModel)]="oidcProvider.clientId"
placeholder="e.g. my-client-id"
class="w-full"/>
class="full-width"/>
</div>
<div class="form-field form-field-full">
<label for="scope">Scope</label>
<input
pInputText
id="scope"
class="w-full"
class="full-width"
[readonly]="true"
disabled="disabled"
value="openid profile email offline_access"/>
<div class="field-info">
<i class="pi pi-info-circle text-blue-500"></i>
<i class="pi pi-info-circle info-icon"></i>
<span>
Required scopes for OIDC login and token exchange. Must be supported and advertised in the provider's discovery metadata.
</span>
@@ -110,7 +110,7 @@
id="issuerUri"
[(ngModel)]="oidcProvider.issuerUri"
placeholder="e.g. https://authentik.domain.com/application/o/booklore/ or https://pocket-id.domain.com"
class="w-full"/>
class="full-width"/>
</div>
<div class="form-field form-field-full">
<div class="claims-grid">
@@ -121,7 +121,7 @@
id="claimUsername"
[(ngModel)]="oidcProvider.claimMapping.username"
placeholder="e.g. preferred_username"
class="w-full"/>
class="full-width"/>
</div>
<div class="form-field">
<label for="claimEmail">Email Claim</label>
@@ -130,7 +130,7 @@
id="claimEmail"
[(ngModel)]="oidcProvider.claimMapping.email"
placeholder="e.g. email"
class="w-full"/>
class="full-width"/>
</div>
<div class="form-field">
<label for="claimName">Display Name Claim</label>
@@ -139,11 +139,11 @@
id="claimName"
[(ngModel)]="oidcProvider.claimMapping.name"
placeholder="e.g. name or given_name"
class="w-full"/>
class="full-width"/>
</div>
</div>
<div class="field-info">
<i class="pi pi-info-circle text-blue-500"></i>
<i class="pi pi-info-circle info-icon"></i>
<span>
These claims are used by Booklore to provision new OIDC users with their name, username, and email. Ensure they match the claims provided by your OIDC provider.
</span>
@@ -178,7 +178,7 @@
</p-toggleswitch>
</div>
<div class="auth-info">
<i class="pi pi-info-circle text-blue-500"></i>
<i class="pi pi-info-circle info-icon"></i>
<span>
Enabling auto-provisioning ensures that new users are automatically created with the selected default permissions. If turned off, users must be manually created in Booklore before they can log in.
</span>

View File

@@ -250,3 +250,11 @@
padding-left: 1rem;
margin-bottom: 1rem;
}
.full-width {
width: 100%;
}
.info-icon {
color: #3b82f6;
}

View File

@@ -25,24 +25,24 @@
optionLabel="label"
optionValue="value"
placeholder="Select file type"
class="w-full"
styleClass="full-width"
[disabled]="isUploading"
></p-select>
<!-- Description Field -->
<div class="w-full">
<label for="description" class="block text-sm font-medium mb-2">Description (Optional)</label>
<div class="description-field">
<label for="description" class="description-label">Description (Optional)</label>
<p-textarea
[(ngModel)]="description"
rows="3"
placeholder="Add a description for this file..."
class="w-full"
styleClass="full-width"
[disabled]="isUploading"
></p-textarea>
</div>
<!-- File Upload -->
<p-fileupload class="w-full" name="file"
<p-fileupload class="file-upload" name="file"
[maxFileSize]="maxFileSizeBytes"
[customUpload]="true"
[multiple]="false"
@@ -50,8 +50,8 @@
(uploadHandler)="uploadFiles($event)"
[disabled]="isUploading">
<ng-template #header let-files let-chooseCallback="chooseCallback" let-clearCallback="clearCallback" let-uploadCallback="uploadCallback">
<div class="flex flex-wrap items-center justify-center flex-1 gap-4">
<div class="flex gap-4">
<div class="upload-header-actions">
<div class="action-buttons">
<p-button (onClick)="choose($event, chooseCallback)"
icon="pi pi-images"
[rounded]="true"
@@ -73,19 +73,19 @@
</div>
</ng-template>
<ng-template #content let-files let-removeFileCallback="removeFileCallback">
<div class="flex flex-col gap-8 px-4">
<div class="upload-content">
@if (files?.length > 0) {
<div>
<div class="max-h-96 max-w-[22rem] md:max-w-none overflow-y-auto pr-2">
<div class="flex flex-wrap">
<div class="file-list-scroll">
<div class="file-list">
@for (uploadFile of this.files; track uploadFile; let i = $index) {
<div class="flex justify-between items-center w-full gap-4">
<div class="flex items-center gap-4 overflow-hidden flex-1">
<p-badge [value]="uploadFile.status" [severity]="getBadgeSeverity(uploadFile.status)" class="shrink-0"/>
<span class="font-semibold text-ellipsis whitespace-nowrap overflow-hidden flex-1">
<div class="file-row">
<div class="file-info">
<p-badge [value]="uploadFile.status" [severity]="getBadgeSeverity(uploadFile.status)" class="file-badge"/>
<span class="file-name">
{{ uploadFile.file.name }}
</span>
<div class="shrink-0">{{ formatSize(uploadFile.file.size) }}</div>
<div class="file-size">{{ formatSize(uploadFile.file.size) }}</div>
</div>
@switch (uploadFile.status) {
@case ('Pending') {
@@ -97,7 +97,7 @@
}
@case ('Uploading') {
<i
class="pi pi-spin pi-spinner p-3"
class="pi pi-spin pi-spinner status-icon"
style="color: slateblue"
pTooltip="Uploading"
tooltipPosition="top">
@@ -105,7 +105,7 @@
}
@case ('Uploaded') {
<i
class="pi pi-check p-3"
class="pi pi-check status-icon"
style="color: green"
pTooltip="Uploaded"
tooltipPosition="top">
@@ -113,7 +113,7 @@
}
@case ('Failed') {
<i
class="pi pi-exclamation-triangle p-3"
class="pi pi-exclamation-triangle status-icon"
style="color: darkred"
pTooltip="{{ uploadFile.errorMessage || 'Upload failed' }}"
tooltipPosition="top">
@@ -130,14 +130,14 @@
</ng-template>
<ng-template #file></ng-template>
<ng-template #empty>
<div class="flex items-center justify-center flex-col text-center">
<i class="pi pi-cloud-upload !border-2 !rounded-full !p-8 !text-4xl !text-muted-color"></i>
<p class="mt-6 mb-2">Drag and drop a file here to upload.</p>
<p class="mt-2 mb-2">
<div class="empty-state">
<i class="pi pi-cloud-upload empty-icon"></i>
<p class="empty-text">Drag and drop a file here to upload.</p>
<p class="empty-subtext">
Upload an additional file for <strong>{{ book.metadata?.title || 'this book' }}</strong>.
</p>
</div>
</ng-template>
</p-fileupload>
</div>
</div>
</div>

View File

@@ -26,6 +26,126 @@
gap: 1.5rem;
}
:host ::ng-deep .p-fileupload-content p-progressbar {
display: none !important;
:host ::ng-deep {
.p-fileupload-content p-progressbar {
display: none !important;
}
.full-width {
width: 100%;
}
}
.file-upload {
width: 100%;
}
.description-field {
width: 100%;
}
.description-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.upload-header-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
flex: 1;
gap: 1rem;
}
.action-buttons {
display: flex;
gap: 1rem;
}
.upload-content {
display: flex;
flex-direction: column;
gap: 2rem;
padding: 0 1rem;
}
.file-list-scroll {
max-height: 24rem;
max-width: 22rem;
overflow-y: auto;
padding-right: 0.5rem;
@media (min-width: 768px) {
max-width: none;
}
}
.file-list {
display: flex;
flex-wrap: wrap;
}
.file-row {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: 1rem;
}
.file-info {
display: flex;
align-items: center;
gap: 1rem;
overflow: hidden;
flex: 1;
}
.file-badge {
flex-shrink: 0;
}
.file-name {
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
flex: 1;
}
.file-size {
flex-shrink: 0;
}
.status-icon {
padding: 0.75rem;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
text-align: center;
}
.empty-icon {
border: 2px solid;
border-radius: 9999px;
padding: 2rem;
font-size: 2.25rem;
color: var(--p-text-muted-color);
}
.empty-text {
margin-top: 1.5rem;
margin-bottom: 0.5rem;
}
.empty-subtext {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}

View File

@@ -81,7 +81,7 @@
</div>
@if (!bottomBarHidden) {
<div class="book-title-container flex items-center">
<div class="book-title-container">
@if (_shouldShowStatusIcon) {
<div class="read-status-indicator"
[ngClass]="_readStatusClass"
@@ -90,7 +90,7 @@
<i [class]="_readStatusIcon" style="font-size: 0.9rem"></i>
</div>
}
<h4 class="book-title m-0 pl-2"
<h4 class="book-title"
tooltipPosition="bottom"
[pTooltip]="titleTooltip">
{{ displayTitle }}

View File

@@ -65,6 +65,8 @@
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
margin: 0;
padding-left: 0.5rem;
}
.info-btn,

View File

@@ -24,7 +24,7 @@
<p-tableHeaderCheckbox/>
</th>
<th></th>
<th class="max-w-14 min-w-14"></th>
<th class="cover-header-cell"></th>
@for (col of visibleColumns; track col.field) {
<th pResizableColumn>{{ col.header }}</th>
}
@@ -37,7 +37,7 @@
<td>
<p-tableCheckbox [value]="book"/>
</td>
<td class="text-center">
<td class="cell-center">
<p-button
[icon]="isMetadataFullyLocked(metadata) ? 'pi pi-lock' : 'pi pi-lock-open'"
[severity]="isMetadataFullyLocked(metadata) ? 'danger' : 'success'"
@@ -53,27 +53,27 @@
<img
[attr.src]="urlHelper.getThumbnailUrl(metadata.bookId, metadata.coverUpdatedOn)"
alt="Book Cover"
class="size-7"
class="cover-thumbnail"
tooltipPosition="left"
[pTooltip]="tooltipContent"
tooltipStyleClass="[&>.p-tooltip-text]:p-1"
tooltipStyleClass="cover-tooltip"
/>
</a>
<ng-template #tooltipContent>
<div class="flex flex-col items-center gap-2">
<div class="tooltip-cover-container">
<img
[attr.src]="urlHelper.getThumbnailUrl(metadata.bookId, metadata.coverUpdatedOn)"
alt="Book Cover"
class="w-[40rem] h-auto"
class="tooltip-cover-image"
/>
<em class="text-sm text-balance text-center">{{ metadata.title }}</em>
<em class="tooltip-cover-title">{{ metadata.title }}</em>
</div>
</ng-template>
</td>
@for (col of visibleColumns; track col.field) {
@if (col.field === 'readStatus') {
<td class="text-center min-w-[4rem] max-w-[4rem]">
<td class="cell-status">
@if (shouldShowStatusIcon(book.readStatus)) {
<div class="read-status-indicator"
[ngClass]="getReadStatusClass(book.readStatus)"
@@ -84,8 +84,8 @@
}
</td>
} @else if (col.field === 'amazonRating' || col.field === 'goodreadsRating' || col.field === 'hardcoverRating' || col.field == 'ranobedbRating') {
<td class="min-w-[10rem] max-w-[10rem] overflow-hidden truncate">
<span class="flex items-center gap-1">
<td class="cell-rating">
<span class="rating-wrapper">
<p-rating
[(ngModel)]="metadata[col.field]"
readonly
@@ -100,10 +100,10 @@
} @else {
<td
[title]="getCellValue(metadata, book, col.field)"
class="overflow-hidden truncate text-right min-w-[6rem] max-w-[12rem]">
class="cell-text">
@if(['title', 'authors', 'publisher', 'seriesName', 'categories', 'language'].includes(col.field)) {
@for (item of getCellClickableValue(metadata, book, col.field); track $index; let isLast = $last) {
<a [routerLink]="item.url" class="hover:underline hover:text-blue-400">
<a [routerLink]="item.url" class="cell-link">
{{ item.anchor }}</a>@if (!isLast) {<span>, </span>}
}
} @else {

View File

@@ -36,3 +36,75 @@
.status-wont-read {
color: #ec4899;
}
.cover-header-cell {
max-width: 3.5rem;
min-width: 3.5rem;
}
.cell-center {
text-align: center;
}
.cover-thumbnail {
width: 1.75rem;
height: 1.75rem;
}
.tooltip-cover-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.tooltip-cover-image {
width: 40rem;
height: auto;
}
.tooltip-cover-title {
font-size: 0.875rem;
text-wrap: balance;
text-align: center;
}
.cell-status {
text-align: center;
min-width: 4rem;
max-width: 4rem;
}
.cell-rating {
min-width: 10rem;
max-width: 10rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rating-wrapper {
display: flex;
align-items: center;
gap: 0.25rem;
}
.cell-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
min-width: 6rem;
max-width: 12rem;
}
.cell-link {
&:hover {
text-decoration: underline;
color: #60a5fa;
}
}
::ng-deep .cover-tooltip > .p-tooltip-text {
padding: 0.25rem;
}

View File

@@ -1,4 +1,4 @@
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {TestBed} from '@angular/core/testing';
import {EnvironmentInjector, runInInjectionContext} from '@angular/core';
import {CoverScalePreferenceService} from './cover-scale-preference.service';

View File

@@ -1,106 +0,0 @@
import {describe, expect, it, vi} from 'vitest';
import {FilterLabelHelper} from './filter-label.helper';
vi.mock('./book-filter/book-filter.component', () => ({
fileSizeRanges: [
{id: 'small', label: 'Small Files'},
{id: 'large', label: 'Large Files'}
],
pageCountRanges: [
{id: 'short', label: 'Short Books'},
{id: 'long', label: 'Long Books'}
],
matchScoreRanges: [
{id: 'high', label: 'High Match'},
{id: 'low', label: 'Low Match'}
],
ratingOptions10: [
{id: '5', label: 'Five Stars'},
{id: '10', label: 'Ten Stars'}
],
ratingRanges: [
{id: 'A', label: 'A Range'},
{id: 'B', label: 'B Range'}
]
}));
describe('FilterLabelHelper', () => {
describe('getFilterTypeName', () => {
it('should return mapped label for known filter type', () => {
expect(FilterLabelHelper.getFilterTypeName('author')).toBe('Author');
expect(FilterLabelHelper.getFilterTypeName('category')).toBe('Genre');
expect(FilterLabelHelper.getFilterTypeName('series')).toBe('Series');
});
it('should capitalize and return unknown filter type', () => {
expect(FilterLabelHelper.getFilterTypeName('unknownType')).toBe('UnknownType');
expect(FilterLabelHelper.getFilterTypeName('custom')).toBe('Custom');
});
});
describe('getFilterDisplayValue', () => {
it('should return file size label for known id', () => {
expect(FilterLabelHelper.getFilterDisplayValue('fileSize', 'small')).toBe('small');
expect(FilterLabelHelper.getFilterDisplayValue('filesize', 'large')).toBe('large');
});
it('should return value if file size id not found', () => {
expect(FilterLabelHelper.getFilterDisplayValue('fileSize', 'unknown')).toBe('unknown');
});
it('should return page count label for known id', () => {
expect(FilterLabelHelper.getFilterDisplayValue('pageCount', 'short')).toBe('short');
expect(FilterLabelHelper.getFilterDisplayValue('pagecount', 'long')).toBe('long');
});
it('should return value if page count id not found', () => {
expect(FilterLabelHelper.getFilterDisplayValue('pageCount', 'unknown')).toBe('unknown');
});
it('should return match score label for known id', () => {
expect(FilterLabelHelper.getFilterDisplayValue('matchScore', 'high')).toBe('high');
expect(FilterLabelHelper.getFilterDisplayValue('matchscore', 'low')).toBe('low');
});
it('should return value if match score id not found', () => {
expect(FilterLabelHelper.getFilterDisplayValue('matchScore', 'unknown')).toBe('unknown');
});
it('should return personal rating label for known id', () => {
expect(FilterLabelHelper.getFilterDisplayValue('personalRating', '5')).toBe('5');
expect(FilterLabelHelper.getFilterDisplayValue('personalrating', '10')).toBe('10');
});
it('should return value if personal rating id not found', () => {
expect(FilterLabelHelper.getFilterDisplayValue('personalRating', 'unknown')).toBe('unknown');
});
it('should return rating range label for amazon/goodreads/hardcover', () => {
expect(FilterLabelHelper.getFilterDisplayValue('amazonRating', 'A')).toBe('A');
expect(FilterLabelHelper.getFilterDisplayValue('goodreadsRating', 'B')).toBe('B');
expect(FilterLabelHelper.getFilterDisplayValue('hardcoverRating', 'A')).toBe('A');
});
it('should return value if rating range id not found', () => {
expect(FilterLabelHelper.getFilterDisplayValue('amazonRating', 'unknown')).toBe('unknown');
expect(FilterLabelHelper.getFilterDisplayValue('goodreadsRating', 'unknown')).toBe('unknown');
expect(FilterLabelHelper.getFilterDisplayValue('hardcoverRating', 'unknown')).toBe('unknown');
});
it('should return value for unknown filter type', () => {
expect(FilterLabelHelper.getFilterDisplayValue('unknownType', 'someValue')).toBe('someValue');
});
});
describe('capitalize', () => {
it('should capitalize the first letter', () => {
// @ts-expect-private
// @ts-ignore
expect(FilterLabelHelper.capitalize('test')).toBe('Test');
// @ts-ignore
expect(FilterLabelHelper.capitalize('T')).toBe('T');
// @ts-ignore
expect(FilterLabelHelper.capitalize('')).toBe('');
});
});
});

View File

@@ -18,10 +18,10 @@
</div>
<div class="dialog-content">
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-x-24 gap-y-4">
<div class="fields-grid">
@for (field of lockableFields; track field) {
<div class="flex items-center justify-between">
<label class="capitalize">{{ fieldLabels[field] || field }}</label>
<div class="field-row">
<label class="field-label">{{ fieldLabels[field] || field }}</label>
<p-button
[outlined]="true"
[label]="getLockLabel(field)"

View File

@@ -26,3 +26,28 @@
gap: 0.5rem;
align-items: center;
}
.fields-grid {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
column-gap: 6rem;
row-gap: 1rem;
@media (min-width: 576px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (min-width: 768px) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.field-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.field-label {
text-transform: capitalize;
}

View File

@@ -1,12 +1,12 @@
<div class="book-notes-container">
<div class="notes-content">
@if (loading) {
<div class="flex flex-col items-center justify-center p-8 gap-4">
<div class="loading-state">
<p-progressSpinner/>
<span class="text-gray-400">Loading notes...</span>
<span class="loading-text">Loading notes...</span>
</div>
} @else if (notes.length === 0) {
<div class="text-center p-8 text-gray-400 empty-state">
<div class="empty-state">
<div class="empty-state-icon">
<p-button
outlined
@@ -19,8 +19,8 @@
class="action-btn floating-btn">
</p-button>
</div>
<p class="text-gray-200 empty-state-title">No notes yet for this book</p>
<p class="text-sm text-gray-400 mt-2 empty-state-subtitle">
<p class="empty-state-title">No notes yet for this book</p>
<p class="empty-state-subtitle">
Click "Add Note" to create your first note and start capturing your thoughts
</p>
<div class="empty-state-decoration"></div>
@@ -29,14 +29,14 @@
<div class="notes-scroll-container">
<div class="notes-horizontal-list">
@for (note of notes; track note.id + '-' + note.createdAt + '-' + $index) {
<div class="note-card border border-zinc-700">
<div class="note-card">
<div class="note-header">
<div class="flex justify-between items-start p-3">
<div class="flex flex-col gap-1">
<h4 class="note-title font-medium text-lg text-gray-200 m-0">{{ note.title }}</h4>
<div class="note-header-inner">
<div class="note-title-group">
<h4 class="note-title">{{ note.title }}</h4>
<div class="note-meta">
<i class="pi pi-clock text-xs mr-1"></i>
<span class="text-sm text-gray-400">
<i class="pi pi-clock note-meta-icon"></i>
<span class="note-meta-text">
@if (note.createdAt !== note.updatedAt) {
Updated {{ formatDate(note.updatedAt) }}
} @else {
@@ -71,8 +71,8 @@
</div>
</div>
<div class="note-content-area px-3 pb-3">
<div class="text-zinc-300 note-body">{{ note.content }}</div>
<div class="note-content-area">
<div class="note-body">{{ note.content }}</div>
<div class="note-fade-overlay"></div>
</div>
<div class="note-bottom-glow"></div>
@@ -104,34 +104,34 @@
[closable]="true"
[dismissableMask]="true">
<div class="flex flex-col gap-4">
<div class="dialog-form">
<div class="field">
<label for="create-title" class="block text-sm font-medium mb-2">Title</label>
<label for="create-title" class="field-label">Title</label>
<input
id="create-title"
pInputText
[(ngModel)]="newNote.title"
placeholder="Enter note title"
class="w-full"
class="field-input"
maxlength="255"/>
</div>
<div class="field">
<label for="create-content" class="block text-sm font-medium mb-2">Content</label>
<label for="create-content" class="field-label">Content</label>
<textarea
id="create-content"
pTextarea
[(ngModel)]="newNote.content"
placeholder="Enter your note content..."
[rows]="8"
class="w-full"
class="field-input"
[autoResize]="true">
</textarea>
</div>
</div>
<ng-template #footer>
<div class="flex gap-2 justify-end">
<div class="dialog-actions">
<p-button
label="Cancel"
severity="secondary"
@@ -157,34 +157,34 @@
[closable]="true"
[dismissableMask]="true">
<div class="flex flex-col gap-4">
<div class="dialog-form">
<div class="field">
<label for="edit-title" class="block text-sm font-medium mb-2">Title</label>
<label for="edit-title" class="field-label">Title</label>
<input
id="edit-title"
pInputText
[(ngModel)]="editNote.title"
placeholder="Enter note title"
class="w-full"
class="field-input"
maxlength="255"/>
</div>
<div class="field">
<label for="edit-content" class="block text-sm font-medium mb-2">Content</label>
<label for="edit-content" class="field-label">Content</label>
<textarea
id="edit-content"
pTextarea
[(ngModel)]="editNote.content"
placeholder="Enter your note content..."
[rows]="8"
class="w-full"
class="field-input"
[autoResize]="true">
</textarea>
</div>
</div>
<ng-template #footer>
<div class="flex gap-2 justify-end">
<div class="dialog-actions">
<p-button
label="Cancel"
severity="secondary"

View File

@@ -20,21 +20,39 @@
}
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
gap: 1rem;
}
.loading-text {
color: #9ca3af;
}
.empty-state {
position: relative;
padding: 3rem !important;
text-align: center;
color: #9ca3af;
.empty-state-title {
font-size: 1.25rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
color: #e5e7eb;
}
.empty-state-subtitle {
max-width: 400px;
margin: 0 auto;
margin: 0.5rem auto 0;
line-height: 1.6;
font-size: 0.875rem;
color: #9ca3af;
}
.empty-state-decoration {
@@ -85,7 +103,6 @@
border: 1px solid var(--p-content-border-color);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
@media (max-width: 768px) {
width: 340px;
height: 220px;
@@ -116,6 +133,19 @@
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
position: relative;
.note-header-inner {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 0.75rem;
}
.note-title-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.note-title {
max-width: 350px;
white-space: nowrap;
@@ -125,6 +155,9 @@
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-weight: 500;
font-size: 1.125rem;
margin: 0;
@media (max-width: 768px) {
max-width: 200px;
@@ -147,6 +180,16 @@
}
}
.note-meta-icon {
font-size: 0.75rem;
margin-right: 0.25rem;
}
.note-meta-text {
font-size: 0.875rem;
color: #9ca3af;
}
.note-actions {
display: flex;
gap: 0.5rem;
@@ -174,7 +217,7 @@
display: flex;
flex-direction: column;
position: relative;
padding: 1rem !important;
padding: 1rem;
.note-fade-overlay {
position: absolute;
@@ -233,6 +276,29 @@
}
}
.dialog-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.field-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.field-input {
width: 100%;
}
.dialog-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
@keyframes float {
0%, 100% {
transform: translateY(0px);

View File

@@ -1,12 +1,12 @@
<div class="book-reviews-container">
<div class="reviews-content">
@if (loading) {
<div class="flex flex-col items-center justify-center p-8 gap-4">
<div class="loading-state">
<p-progressSpinner/>
<span class="text-gray-400">Getting latest reviews...</span>
<span class="loading-text">Getting latest reviews...</span>
</div>
} @else if (reviews?.length === 0) {
<div class="text-center p-8 text-gray-400 empty-state">
<div class="empty-state">
<div class="empty-state-icon">
@if (hasPermission && !reviewsLocked && reviewDownloadEnabled) {
<p-button
@@ -21,13 +21,13 @@
</p-button>
}
</div>
<p class="text-gray-200 empty-state-title">No reviews available for this book</p>
<p class="empty-state-title">No reviews available for this book</p>
@if (!reviewDownloadEnabled) {
<p class="text-sm text-amber-400 mt-2 empty-state-subtitle">
<p class="empty-state-subtitle empty-state-subtitle--warning">
Book review downloads are currently disabled. Enable this in Metadata Settings to fetch reviews.
</p>
} @else if (hasPermission && !reviewsLocked) {
<p class="text-sm text-gray-400 mt-2 empty-state-subtitle">
<p class="empty-state-subtitle">
Click "Fetch Reviews" to download reviews from configured providers
</p>
}
@@ -37,7 +37,7 @@
<div class="reviews-scroll-container">
<div class="reviews-horizontal-list">
@for (review of reviews; track review.id || $index) {
<div [class]="'review-card border border-zinc-700' + (!review.title ? ' no-title' : '') + (review.spoiler && !isSpoilerRevealed(review.id!) ? ' has-spoiler' : '')">
<div [class]="'review-card' + (!review.title ? ' no-title' : '') + (review.spoiler && !isSpoilerRevealed(review.id!) ? ' has-spoiler' : '')">
@if (review.spoiler && !isSpoilerRevealed(review.id!)) {
<p-button
label="Show Spoiler"
@@ -49,10 +49,10 @@
}
<div class="review-header">
<div class="flex justify-between items-start p-3">
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<span class="font-medium text-sm reviewer-name">{{ review.reviewerName || 'Anonymous' }}</span>
<div class="review-header-inner">
<div class="reviewer-info-group">
<div class="reviewer-name-row">
<span class="reviewer-name">{{ review.reviewerName || 'Anonymous' }}</span>
@if (review.metadataProvider) {
<p-tag
[rounded]="true"
@@ -67,28 +67,28 @@
}
</div>
<div class="review-meta">
<i class="pi pi-clock text-xs mr-1"></i>
<i class="pi pi-clock review-meta-icon"></i>
@if (review.date) {
<span class="text-sm text-gray-400">{{ formatDate(review.date) }}</span>
<span class="review-meta-text">{{ formatDate(review.date) }}</span>
}
</div>
</div>
<div class="flex items-center gap-2">
<div class="review-actions">
@if (review.rating) {
<div class="flex items-center gap-1">
<div class="rating-wrapper">
<p-rating
[ngModel]="review.rating"
[readonly]="true"
[stars]="5"/>
<span class="text-sm font-medium">{{ review.rating }}/5</span>
<span class="rating-text">{{ review.rating }}/5</span>
</div>
}
@if (hasPermission) {
@if (reviewsLocked) {
<div class="flex items-center justify-center w-8 h-8">
<i class="pi pi-lock text-amber-500 text-sm"
<div class="lock-icon-container">
<i class="pi pi-lock lock-icon"
pTooltip="Reviews are locked"
tooltipPosition="top"></i>
</div>
@@ -110,7 +110,7 @@
@if (review.title && (!review.spoiler || isSpoilerRevealed(review.id!))) {
<div class="review-title-section">
<h4 class="font-medium text-xl text-gray-200 leading-tight m-0 review-title"
<h4 class="review-title"
[pTooltip]="review.title"
tooltipPosition="top">{{ review.title }}
</h4>
@@ -121,19 +121,19 @@
@if (review.spoiler && !isSpoilerRevealed(review.id!)) {
<div class="spoiler-blur-content">
@if (review.title) {
<h4 class="font-medium text-gray-200 leading-tight m-0 review-title blur-sm mb-2">{{ review.title }}</h4>
<h4 class="review-title review-title--blurred">{{ review.title }}</h4>
}
@if (review.body) {
<div class="text-zinc-300 review-body blur-sm">{{ review.body }}</div>
<div class="review-body review-body--blurred">{{ review.body }}</div>
} @else {
<div class="text-zinc-400 italic text-sm blur-sm">No review content available</div>
<div class="review-empty-text review-empty-text--blurred">No review content available</div>
}
</div>
} @else {
@if (review.body) {
<div class="text-zinc-300 review-body">{{ review.body }}</div>
<div class="review-body">{{ review.body }}</div>
} @else {
<div class="text-zinc-400 italic text-sm">No review content available</div>
<div class="review-empty-text">No review content available</div>
}
}
<div class="review-fade-overlay"></div>

View File

@@ -54,21 +54,43 @@
}
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
gap: 1rem;
}
.loading-text {
color: #9ca3af;
}
.empty-state {
position: relative;
padding: 3rem !important;
text-align: center;
color: #9ca3af;
.empty-state-title {
font-size: 1.25rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
color: #e5e7eb;
}
.empty-state-subtitle {
max-width: 400px;
margin: 0 auto;
margin: 0.5rem auto 0;
line-height: 1.6;
font-size: 0.875rem;
color: #9ca3af;
&--warning {
color: #fbbf24;
}
}
.empty-state-decoration {
@@ -177,6 +199,25 @@
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
position: relative;
.review-header-inner {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 0.75rem;
}
.reviewer-info-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.reviewer-name-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.reviewer-name {
max-width: 120px;
white-space: nowrap;
@@ -186,6 +227,8 @@
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-weight: 500;
font-size: 0.875rem;
}
.review-meta {
@@ -198,6 +241,46 @@
}
}
.review-meta-icon {
font-size: 0.75rem;
margin-right: 0.25rem;
}
.review-meta-text {
font-size: 0.875rem;
color: #9ca3af;
}
.review-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.rating-wrapper {
display: flex;
align-items: center;
gap: 0.25rem;
}
.rating-text {
font-size: 0.875rem;
font-weight: 500;
}
.lock-icon-container {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
}
.lock-icon {
color: #f59e0b;
font-size: 0.875rem;
}
.action-btn-hover {
opacity: 0.7;
transition: all 0.3s ease;
@@ -248,6 +331,24 @@
}
}
.review-title {
max-width: 450px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: help;
font-weight: 500;
font-size: 1.25rem;
color: #e5e7eb;
line-height: 1.25;
margin: 0;
&--blurred {
filter: blur(4px);
margin-bottom: 0.5rem;
}
}
.review-body {
flex: 1;
min-height: 0;
@@ -256,6 +357,11 @@
word-wrap: break-word;
white-space: pre-wrap;
box-sizing: border-box;
color: #d4d4d8;
&--blurred {
filter: blur(4px);
}
&::-webkit-scrollbar {
width: 4px;
@@ -276,10 +382,21 @@
}
}
.review-title {
max-width: 450px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: help;
.review-empty-text {
color: #a1a1aa;
font-style: italic;
font-size: 0.875rem;
&--blurred {
filter: blur(4px);
}
}
@keyframes float {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
}

View File

@@ -18,21 +18,21 @@
</div>
<div class="dialog-content">
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-gray-300">Email Provider</label>
<div class="form-fields">
<div class="form-field">
<label class="field-label">Email Provider</label>
<p-select
[options]="emailProviders"
optionLabel="label"
placeholder="Select Email Provider"
[(ngModel)]="selectedProvider"
appendTo="body"
class="w-full">
styleClass="full-width">
</p-select>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-gray-300">Recipient</label>
<div class="form-field">
<label class="field-label">Recipient</label>
<p-select
[options]="emailRecipients"
optionLabel="label"
@@ -40,7 +40,7 @@
[(ngModel)]="selectedRecipient"
[disabled]="!selectedProvider"
appendTo="body"
class="w-full">
styleClass="full-width">
</p-select>
</div>
</div>

View File

@@ -24,3 +24,25 @@
.dialog-footer {
@include panel.dialog-footer;
}
.form-fields {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.field-label {
font-size: 0.875rem;
font-weight: 500;
color: #d1d5db;
}
:host ::ng-deep .full-width {
width: 100%;
}

View File

@@ -1,5 +1,5 @@
@if (filteredBooks$ | async; as books) {
<div class="relative rounded-xl overflow-hidden border-[1px] border-solid border-[var(--p-content-border-color)]">
<div class="series-wrapper">
<p-tabs [value]="tab" lazy="true" scrollable>
<p-tablist>
<p-tab value="view">
@@ -7,26 +7,25 @@
Series Details
</p-tab>
</p-tablist>
<p-tabpanels class="tabpanels-responsive overflow-auto">
<p-tabpanels class="tabpanels-responsive">
<p-tabpanel value="view">
@if (books[0]; as firstBook) {
<div class="flex flex-col justify-between flex-grow">
<div class="space-y-4 ">
<div class="series-main">
<div class="series-details">
<!-- <div class="flex items-start justify-between"> -->
<div class="flex flex-col text-center gap-4 md:text-left w-full">
<div class="series-info-section">
<div class="flex flex-col items-center md:items-start gap-1">
<div class="flex items-center gap-2 justify-center md:justify-start flex-wrap text-center md:text-left">
<h2 class="text-2xl md:text-3xl font-extrabold leading-tight">
<div class="series-title-block">
<div class="series-title-row">
<h2 class="series-title">
{{ seriesTitle$ | async }}
</h2>
</div>
<p class="text-lg">
<p class="series-authors">
@for (author of firstBook.metadata?.authors; track $index; let isLast = $last) {
<a class="hover:underline dark:text-blue-400 cursor-pointer" (click)="goToAuthorBooks(author)">
<a class="author-link" (click)="goToAuthorBooks(author)">
{{ author }}
</a>
@if (!isLast) {
@@ -34,27 +33,25 @@
}
}
</p>
<!-- </div> -->
</div>
@if (firstBook.metadata?.categories?.length) {
<div class="overflow-x-auto scrollbar-hide max-w-7xl pt-2 md:pt-0">
<div class="flex gap-2 w-max max-w-[1150px]">
<div class="categories-scroll">
<div class="categories-list">
@for (category of firstBook.metadata?.categories; track category) {
<a (click)="goToCategory(category)" class="shrink-0 no-underline cursor-pointer">
<a (click)="goToCategory(category)" class="category-link">
<p-tag [value]="category"></p-tag>
</a>
}
</div>
</div>
}
<div class="px-1 md:px-0">
<div
class="grid md:grid-cols-5 p-3 gap-y-2.5 text-sm pt-2 md:pt-4 pb-2 text-gray-300 md:min-w-[60rem] md:max-w-[100rem]">
<div class="metadata-section-padding">
<div class="metadata-grid">
<p>
<span class="font-bold">Publisher: </span>
<span class="metadata-label">Publisher: </span>
@if (firstBook.metadata?.publisher; as publisher) {
<span class="underline hover:opacity-80 cursor-pointer" (click)="goToPublisher(publisher)">
<span class="publisher-link" (click)="goToPublisher(publisher)">
{{publisher}}
</span>
} @else {
@@ -66,7 +63,7 @@
<p><strong>Language:</strong> {{ firstBook.metadata?.language || "-"}}</p>
<p><strong>Read Status: </strong>
@let s = seriesReadStatus$ | async;
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-bold text-white"
<span class="status-badge"
[ngClass]="getStatusSeverityClass(s || 'UNREAD')">
{{ getStatusLabel(s) }}
@if (s === 'PARTIALLY_READ') {
@@ -78,10 +75,10 @@
</div>
<!-- DESCRIPTION -->
<div class="px-4 pb-2 pt-4 mb-4 md:mb-6 md:mx-2 border border-[var(--border-color)] rounded-xl">
<div class="description-box">
<div [ngClass]="{ 'line-clamp-5': !isExpanded, 'line-clamp-none': isExpanded }"
class="transition-all duration-300 overflow-hidden description-container">
<div class="readonly-editor px-2"
class="description-container">
<div class="readonly-editor"
[innerHTML]="(firstDescription$ | async) || 'No description available.'"></div>
</div>
@let desc = firstDescription$ | async;
@@ -93,7 +90,7 @@
}
</div>
<div class="grid series-items-grid overflow-y-auto" #Container
<div class="series-items-grid" #Container
[ngStyle]="{'grid-template-columns': 'repeat(auto-fill, minmax(' + gridColumnMinWidth + ', 1fr))'}">
@for (book of books; track book; let i = $index) {
<div class="grid-item"
@@ -125,17 +122,17 @@
</p-tabs>
@if (selectedBooks.size > 0) {
@if (userService.userState$ | async; as userState) {
<div class="book-browser-footer bg-[var(--card-background)] bg-opacity-10" [@slideInOut]>
<div class="flex items-center">
<div class="flex items-center pl-2 w-1/4">
<div class="selected-count-badge text-zinc-200">
<i class="pi pi-check-circle mr-2"></i>
<span class="font-bold text-base" style="color: var(--primary-color)">{{ selectedBooks.size }}</span>
<span class="ml-1">selected</span>
<div class="book-browser-footer" [@slideInOut]>
<div class="footer-inner">
<div class="footer-left">
<div class="selected-count-badge">
<i class="pi pi-check-circle badge-icon"></i>
<span class="badge-count" style="color: var(--primary-color)">{{ selectedBooks.size }}</span>
<span class="badge-label">selected</span>
</div>
</div>
<div class="flex justify-center w-2/4">
<div class="flex gap-2 md:gap-6">
<div class="footer-center">
<div class="footer-action-group">
@if (hasMetadataMenuItems) {
<p-tieredMenu #menu [model]="metadataMenuItems" [popup]="true" appendTo="body"/>
<p-button
@@ -176,7 +173,7 @@
</p-button>
}
@if (hasBulkReadActionsItems) {
<div class="card flex justify-center">
<div class="more-actions-wrapper">
<p-button
(click)="menu.toggle($event)"
pTooltip="More actions"
@@ -190,7 +187,7 @@
}
</div>
<p-divider layout="vertical"></p-divider>
<div class="flex gap-2 md:gap-6">
<div class="footer-action-group">
<p-button
outlined="true"
icon="pi pi-check-square"
@@ -220,7 +217,7 @@
</p-button>
}
</div>
<div class="w-1/4"></div>
<div class="footer-right"></div>
</div>
</div>
}
@@ -228,11 +225,11 @@
</div>
} @else {
<div class="flex flex-col justify-center items-center h-full gap-4">
<div class="loading-state">
<p-progressSpinner strokeWidth="4" fill="transparent" animationDuration=".8s">
</p-progressSpinner>
<p style="color: var(--primary-color);">
Loading series details...
</p>
</div>
}
}

View File

@@ -1,5 +1,6 @@
.tabpanels-responsive {
height: calc(100dvh - 9.7rem);
overflow: auto;
}
@media (max-width: 768px) {
@@ -8,12 +9,205 @@
}
}
.grid {
.series-wrapper {
position: relative;
border-radius: 0.75rem;
overflow: hidden;
border: 1px solid var(--p-content-border-color);
}
.series-main {
display: flex;
flex-direction: column;
justify-content: space-between;
flex-grow: 1;
}
.series-details {
> * + * {
margin-top: 1rem;
}
}
.series-info-section {
display: flex;
flex-direction: column;
text-align: center;
gap: 1rem;
width: 100%;
@media (min-width: 768px) {
text-align: left;
}
}
.series-title-block {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
@media (min-width: 768px) {
align-items: flex-start;
}
}
.series-title-row {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: center;
flex-wrap: wrap;
text-align: center;
@media (min-width: 768px) {
justify-content: flex-start;
text-align: left;
}
}
.series-title {
font-size: 1.5rem;
font-weight: 800;
line-height: 1.25;
@media (min-width: 768px) {
font-size: 1.875rem;
}
}
.series-authors {
font-size: 1.125rem;
}
.author-link {
cursor: pointer;
color: #60a5fa;
&:hover {
text-decoration: underline;
}
}
.categories-scroll {
overflow-x: auto;
max-width: 80rem;
padding-top: 0.5rem;
@media (min-width: 768px) {
padding-top: 0;
}
}
.categories-list {
display: flex;
gap: 0.5rem;
width: max-content;
max-width: 1150px;
}
.category-link {
flex-shrink: 0;
text-decoration: none;
cursor: pointer;
}
.metadata-section-padding {
padding: 0 0.25rem;
@media (min-width: 768px) {
padding: 0;
}
}
.metadata-grid {
display: grid;
padding: 0.75rem;
row-gap: 0.625rem;
font-size: 0.875rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: #d1d5db;
@media (min-width: 768px) {
grid-template-columns: repeat(5, minmax(0, 1fr));
padding-top: 1rem;
min-width: 60rem;
max-width: 100rem;
}
}
.metadata-label {
font-weight: 700;
}
.publisher-link {
text-decoration: underline;
cursor: pointer;
&:hover {
opacity: 0.8;
}
}
.status-badge {
display: inline-block;
padding: 0.5rem 0.125rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 700;
color: white;
}
.description-box {
padding: 1rem 1rem 0.5rem;
margin-bottom: 1rem;
border: 1px solid var(--border-color);
border-radius: 0.75rem;
@media (min-width: 768px) {
margin-bottom: 1.5rem;
margin-left: 0.5rem;
margin-right: 0.5rem;
}
}
.description-container {
transition: all 0.3s;
overflow: hidden;
}
.line-clamp-5 {
display: -webkit-box;
-webkit-line-clamp: 5;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-none {
-webkit-line-clamp: unset;
}
.readonly-editor {
padding: 0 0.5rem;
}
.series-items-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(1px, 1fr));
gap: 1.3rem;
align-items: start;
width: 100%;
overflow-y: auto;
}
.loading-state {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
gap: 1rem;
}
.book-browser-footer {
@@ -28,23 +222,72 @@
border-radius: 10px 10px 0 0;
border: 1px solid rgba(255, 255, 255, 0.5);
border-width: 1px 1px 0px 1px;
background-color: var(--card-background);
background-color: color-mix(in srgb, var(--card-background), transparent 90%);
}
> .flex {
width: 100%;
.footer-inner {
display: flex;
align-items: center;
width: 100%;
}
.footer-left {
display: flex;
align-items: center;
padding-left: 0.5rem;
width: 25%;
}
.footer-center {
display: flex;
justify-content: center;
width: 50%;
}
.footer-right {
width: 25%;
}
.footer-action-group {
display: flex;
gap: 0.5rem;
@media (min-width: 768px) {
gap: 1.5rem;
}
}
.more-actions-wrapper {
display: flex;
justify-content: center;
}
.selected-count-badge {
display: inline-flex;
align-items: center;
padding: 0.5rem 1rem;
font-size: 0.9rem;
color: #e4e4e7;
i {
font-size: 1rem;
}
}
.badge-icon {
margin-right: 0.5rem;
}
.badge-count {
font-weight: 700;
font-size: 1rem;
}
.badge-label {
margin-left: 0.25rem;
}
@media (max-width: 768px) {
.book-browser-footer {
left: 0%;
@@ -59,4 +302,4 @@
font-size: 0.9rem;
}
}
}
}

View File

@@ -1,15 +1,15 @@
@if (fetchedMetadata && fetchedMetadata.title) {
<form [formGroup]="metadataForm" class="metapicker flex flex-col w-full">
<div class="flex-grow overflow-auto">
<div class="metaheader relative flex items-center">
<label class="md:w-[8rem] text-sm"></label>
<div class="flex w-full items-center">
<p class="w-1/2">File Data</p>
<form [formGroup]="metadataForm" class="metapicker">
<div class="form-scroll">
<div class="metaheader">
<label class="field-label"></label>
<div class="header-fields-row">
<p class="header-column">File Data</p>
<div class="midbuttons">
<p-button
size="small"
icon="pi pi-angle-left"
class="mx-2"
class="btn-margin"
[outlined]="true"
pTooltip="Copy missing fields from fetched metadata"
tooltipPosition="bottom"
@@ -18,7 +18,7 @@
<p-button
size="small"
icon="pi pi-angle-double-left"
class="mx-2"
class="btn-margin"
[outlined]="true"
pTooltip="Overwrite all fields with fetched metadata"
tooltipPosition="bottom"
@@ -28,21 +28,21 @@
size="small"
severity="warn"
icon="pi pi-refresh"
class="mx-2"
class="btn-margin"
[outlined]="true"
pTooltip="Reset all fields to original values"
tooltipPosition="bottom"
(onClick)="confirmReset()"
></p-button>
</div>
<p class="w-1/2">Fetched Data</p>
<p class="header-column">Fetched Data</p>
</div>
</div>
<div class="metacontent">
<div class="meta-row field-thumbnail">
<label class="md:w-[8rem] text-sm"></label>
<div class="flex w-full items-center justify-center">
<div class="flex w-1/2">
<label class="field-label"></label>
<div class="thumbnail-row">
<div class="thumbnail-column">
@if (metadataForm.get('thumbnailUrl')?.value) {
<p-image
class="thumbnail"
@@ -57,7 +57,7 @@
></p-image>
}
@else {
<div class="thumbnail text-sm">No cover image</div>
<div class="thumbnail no-cover-text">No cover image</div>
}
<input type="hidden" id="thumbnailUrl" formControlName="thumbnailUrl"/>
</div>
@@ -70,7 +70,7 @@
[disabled]="!isFetchedDifferent('thumbnailUrl') && !isValueCopied('thumbnailUrl')"
(click)="isValueCopied('thumbnailUrl') ? resetField('thumbnailUrl') : copyFetchedToCurrent('thumbnailUrl')"
/>
<div class="flex w-1/2">
<div class="thumbnail-column">
@if (fetchedMetadata.thumbnailUrl) {
<p-image
class="thumbnail"
@@ -82,7 +82,7 @@
></p-image>
}
@else {
<div class="thumbnail text-sm">Cover image not found</div>
<div class="thumbnail no-cover-text">Cover image not found</div>
}
<input type="hidden" [value]="fetchedMetadata.thumbnailUrl" readonly/>
</div>
@@ -90,7 +90,7 @@
</div>
@for (field of metadataFieldsTop; track field) {
<div class="meta-row field-{{field.controlName}}">
<label for="{{field.controlName}}" class="md:w-[8rem] text-sm">{{ field.label }}</label>
<label for="{{field.controlName}}" class="field-label">{{ field.label }}</label>
<div class="meta-fields">
<input
pSize="small"
@@ -116,7 +116,7 @@
(click)="isValueChanged(field.controlName) ? resetField(field.controlName) : copyFetchedToCurrent(field.controlName)"
/>
@if (fetchedMetadata[field.fetchedKey]) {
<input
<input
pSize="small"
pInputText
[value]="fetchedMetadata[field.fetchedKey] ?? null"
@@ -132,7 +132,7 @@
}
@for (field of metadataPublishDate; track field) {
<div class="meta-row field-{{field.controlName}}">
<label for="{{field.controlName}}" class="md:w-[8rem] text-sm">{{ field.label }}</label>
<label for="{{field.controlName}}" class="field-label">{{ field.label }}</label>
<div class="meta-fields">
<p-datepicker
size="small"
@@ -163,7 +163,7 @@
(click)="isValueChanged(field.controlName) ? resetField(field.controlName) : copyFetchedToCurrent(field.controlName)"
/>
@if (fetchedMetadata[field.fetchedKey]) {
<input
<input
pSize="small"
pInputText
[value]="fetchedMetadata[field.fetchedKey] ?? null"
@@ -179,7 +179,7 @@
}
@for (field of metadataChips; track field) {
<div class="meta-row field-{{field.controlName}}">
<label for="{{field.controlName}}" class="md:w-[8rem] text-sm">{{ field.label }}</label>
<label for="{{field.controlName}}" class="field-label">{{ field.label }}</label>
<div class="meta-fields">
<p-autoComplete
size="small"
@@ -188,7 +188,7 @@
[typeahead]="false"
[dropdown]="false"
[forceSelection]="false"
class="dest w-full"
class="dest full-width"
[ngClass]="{
'changed': isValueChanged(field.controlName),
}"
@@ -216,7 +216,7 @@
[typeahead]="false"
[dropdown]="false"
[forceSelection]="false"
class="src w-full"
class="src full-width"
[ngClass]="{
'diff': isFetchedDifferent(field.controlName),
}"
@@ -227,7 +227,7 @@
}
@for (field of metadataDescription; track field) {
<div class="meta-row field-{{field.controlName}}">
<label for="{{field.controlName}}" class="md:w-[8rem] text-sm">{{ field.label }}</label>
<label for="{{field.controlName}}" class="field-label">{{ field.label }}</label>
<div class="meta-fields">
<textarea
pTextarea
@@ -270,7 +270,7 @@
}
@for (field of metadataFieldsBottom; track field) {
<div class="meta-row field-{{field.controlName}}">
<label for="{{field.controlName}}" class="md:w-[8rem] text-sm">{{ field.label }}</label>
<label for="{{field.controlName}}" class="field-label">{{ field.label }}</label>
<div class="meta-fields">
<input
pInputText
@@ -313,10 +313,10 @@
</div>
</form>
} @else {
<form [formGroup]="metadataForm" class="metaeditor flex w-full">
<div class="flex-grow overflow-auto">
<div class="metaheader relative flex items-center">
<p class="text-sm missingmd">
<form [formGroup]="metadataForm" class="metaeditor">
<div class="form-scroll">
<div class="metaheader">
<p class="missingmd">
Unable to fetch metadata for this file
</p>
<p-button
@@ -329,22 +329,22 @@
(onClick)="confirmReset()"
></p-button>
</div>
<div class="metabody flex flex-col p-4 md:flex-row">
<div class="flex items-start justify-center md:w-[20%] md:h-full">
<div class="w-full md:h-full field-thumbnail">
<img
<div class="metabody">
<div class="editor-thumbnail-wrapper">
<div class="editor-thumbnail-inner field-thumbnail">
<img
[src]="metadataForm.get('thumbnailUrl')?.value"
alt="Book Thumbnail"
class="thumbnail w-full md:h-full object-contain"
class="thumbnail editor-thumbnail-img"
/>
<input type="hidden" id="thumbnailUrl" formControlName="thumbnailUrl"/>
</div>
</div>
<div class="metacontent md:w-[80%]">
<div class="metacontent editor-metacontent">
@for (field of metadataFieldsTop; track field) {
<div class="meta-row field-{{field.controlName}}">
<label for="{{field.controlName}}" class="w-[8rem] text-sm">{{ field.label }}</label>
<div class="flex w-full">
<label for="{{field.controlName}}" class="field-label field-label--fixed">{{ field.label }}</label>
<div class="field-input-row">
<input
pSize="small"
fluid
@@ -370,8 +370,8 @@
@for (field of metadataPublishDate; track field) {
<div class="meta-row field-{{field.controlName}}">
<label for="{{field.controlName}}" class="w-[8rem] text-sm">{{ field.label }}</label>
<div class="flex w-full">
<label for="{{field.controlName}}" class="field-label field-label--fixed">{{ field.label }}</label>
<div class="field-input-row">
<p-datepicker
size="small"
fluid
@@ -383,7 +383,7 @@
[showIcon]="true"
[iconDisplay]="'input'"
appendTo="body"
class="w-full"
class="full-width"
[ngClass]="{
'changed': isValueChanged(field.controlName),
}">
@@ -403,8 +403,8 @@
@for (field of metadataChips; track field) {
<div class="meta-row field-{{field.controlName}}">
<label for="{{field.controlName}}" class="w-[8rem] text-sm">{{ field.label }}</label>
<div class="flex w-full">
<label for="{{field.controlName}}" class="field-label field-label--fixed">{{ field.label }}</label>
<div class="field-input-row">
<p-autoComplete
size="small"
formControlName="{{field.controlName}}"
@@ -412,7 +412,7 @@
[typeahead]="false"
[dropdown]="false"
[forceSelection]="false"
class="w-full"
class="full-width"
[ngClass]="{
'changed': isValueChanged(field.controlName),
}"
@@ -433,8 +433,8 @@
@for (field of metadataDescription; track field) {
<div class="meta-row field-{{field.controlName}}">
<label for="{{field.controlName}}" class="w-[8rem] text-sm">{{ field.label }}</label>
<div class="flex w-full">
<label for="{{field.controlName}}" class="field-label field-label--fixed">{{ field.label }}</label>
<div class="field-input-row">
<textarea
pTextarea
fluid
@@ -461,8 +461,8 @@
@for (field of metadataFieldsBottom; track field) {
<div class="meta-row field-{{field.controlName}}">
<label for="{{field.controlName}}" class="w-[8rem] text-sm">{{ field.label }}</label>
<div class="flex w-full">
<label for="{{field.controlName}}" class="field-label field-label--fixed">{{ field.label }}</label>
<div class="field-input-row">
<input
pSize="small"
fluid
@@ -486,7 +486,7 @@
</div>
}
</div>
</div>
</div>
</div>
</form>
}

View File

@@ -1,4 +1,4 @@
.thumbnail {
.thumbnail {
border: 1px solid var(--border-color);
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
@@ -36,7 +36,7 @@
max-height: 100%;
height: auto;
width: auto;
@media (max-width: 768px) {
max-height: 14em;
}
@@ -68,6 +68,112 @@ textarea.changed,
.metapicker, .metaeditor {
box-sizing: border-box;
display: flex;
width: 100%;
}
.metapicker {
flex-direction: column;
}
.form-scroll {
flex-grow: 1;
overflow: auto;
}
.field-label {
font-size: 0.875rem;
@media (min-width: 768px) {
width: 8rem;
}
}
.field-label--fixed {
width: 8rem;
}
.header-fields-row {
display: flex;
width: 100%;
align-items: center;
}
.header-column {
width: 50%;
}
.btn-margin {
margin: 0 0.5rem;
}
.thumbnail-row {
display: flex;
width: 100%;
align-items: center;
justify-content: center;
}
.thumbnail-column {
display: flex;
width: 50%;
}
.no-cover-text {
font-size: 0.875rem;
}
.full-width {
width: 100%;
}
.metabody {
display: flex;
flex-direction: column;
padding: 1rem;
@media (min-width: 768px) {
flex-direction: row;
}
}
.editor-thumbnail-wrapper {
display: flex;
align-items: flex-start;
justify-content: center;
@media (min-width: 768px) {
width: 20%;
height: 100%;
}
}
.editor-thumbnail-inner {
width: 100%;
@media (min-width: 768px) {
height: 100%;
}
}
.editor-thumbnail-img {
width: 100%;
object-fit: contain;
@media (min-width: 768px) {
height: 100%;
}
}
.editor-metacontent {
@media (min-width: 768px) {
width: 80%;
}
}
.field-input-row {
display: flex;
width: 100%;
}
.metapicker label,
@@ -112,6 +218,9 @@ textarea.changed,
}
.metaheader {
position: relative;
display: flex;
align-items: center;
border-bottom: 1px dotted var(--border-color);
padding: 1rem;
justify-content: space-between;
@@ -196,7 +305,7 @@ textarea.changed,
display: flex;
align-items: center;
margin-bottom: 0.5em;
label {
margin: auto 0;
}
@@ -286,4 +395,4 @@ textarea.changed,
}
}
}
}
}

View File

@@ -76,7 +76,7 @@
[binary]="true"
[(ngModel)]="includeCoversOnCopy">
</p-checkbox>
<label for="includecovers"><i class="pi pi-image pl-2"></i></label>
<label for="includecovers"><i class="pi pi-image icon-padding-left"></i></label>
</p-inputgroup-addon>
</p-inputgroup>
@@ -170,7 +170,7 @@
class="cover-image"
(click)="file.showDetails = !file.showDetails"/>
} @else {
<div title="No cover found" class="cover-image text-sm">?</div>
<div title="No cover found" class="cover-image cover-placeholder">?</div>
}
@if (file.file.fetchedMetadata?.thumbnailUrl) {
@@ -181,7 +181,7 @@
class="cover-image"
(click)="file.showDetails = !file.showDetails"/>
} @else {
<div title="No cover fetched" class="cover-image text-sm">?</div>
<div title="No cover fetched" class="cover-image cover-placeholder">?</div>
}
<div class="file-name" (click)="file.showDetails = !file.showDetails">
@@ -270,7 +270,7 @@
@if (bookdropFileUis.length > 0) {
<p-inputgroup class="selectall">
<p-inputgroup-addon>
<label class="text-sm" pTooltip="Number of files selected" tooltipPosition="top">{{ selectedCount }}</label>
<label class="selected-count-label" pTooltip="Number of files selected" tooltipPosition="top">{{ selectedCount }}</label>
</p-inputgroup-addon>
<p-button
label="Select&nbsp;All"

View File

@@ -433,3 +433,15 @@ div.cover-image {
.spacer {
min-width: 10rem;
}
.icon-padding-left {
padding-left: 0.5rem;
}
.cover-placeholder {
font-size: 0.875rem;
}
.selected-count-label {
font-size: 0.875rem;
}

View File

@@ -1,17 +1,17 @@
<div class="staging-border p-4 rounded">
<div class="flex justify-between items-center">
<div class="staging-border">
<div class="widget-row">
<div class="flex flex-col justify-center">
<p class="text font-bold text-zinc-300">Pending Bookdrop Files</p>
<p class="text-lg font-bold text-primary">{{ pendingCount }}</p>
<div class="widget-info">
<p class="widget-title">Pending Bookdrop Files</p>
<p class="widget-count">{{ pendingCount }}</p>
@if (lastUpdatedAt) {
<p class="text-xs text-zinc-400 mt-1">
<p class="widget-date">
Last updated: {{ lastUpdatedAt | date: 'short' }}
</p>
}
</div>
<div class="flex items-center">
<div class="widget-actions">
<p-button
size="small"
outlined

View File

@@ -2,4 +2,39 @@
background: var(--card-background);
border: 1px solid var(--primary-color);
border-radius: 0.5rem;
padding: 1rem;
}
.widget-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.widget-info {
display: flex;
flex-direction: column;
justify-content: center;
}
.widget-title {
font-weight: 700;
color: #d4d4d8;
}
.widget-count {
font-size: 1.125rem;
font-weight: 700;
color: var(--primary-color);
}
.widget-date {
font-size: 0.75rem;
color: #a1a1aa;
margin-top: 0.25rem;
}
.widget-actions {
display: flex;
align-items: center;
}

View File

@@ -1,8 +1,6 @@
@use '../../../../shared/styles/panel-shared' as panel;
.dashboard-settings {
width: 1000px;
max-width: 1000px;
display: flex;
flex-direction: column;

View File

@@ -1,6 +1,6 @@
<div class="dashboard-container">
@if ((bookState$ | async)?.loaded === false) {
<div class="flex items-center justify-center h-full">
<div class="loading-state">
<p-progressSpinner
class="spinner center-spinner"
[style]="{'width': '60px', 'height': '60px'}"
@@ -12,7 +12,7 @@
}
@if ((bookState$ | async)?.loaded === true) {
<div class="dashboard">
<div class="w-full">
<div class="dashboard-inner">
@if (isLibrariesEmpty$ | async) {
<div class="dashboard-no-library">
@if ((userService.userState$ | async)?.user?.permissions; as permissions) {
@@ -23,7 +23,7 @@
Welcome to BookLore!<br>
Let's create your first library
</h1>
<p class="text-gray-300 mb-8 mx-auto leading-relaxed">
<p class="welcome-description">
A library is where all your books live. Start building your digital collection today!
</p>
<p-button

View File

@@ -46,6 +46,25 @@
z-index: 1;
}
.loading-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.dashboard-inner {
width: 100%;
}
.welcome-description {
color: #d1d5db;
margin-bottom: 2rem;
margin-left: auto;
margin-right: auto;
line-height: 1.625;
}
.dashboard .no-library-header {
font-size: 1.75rem;
font-weight: 700;

View File

@@ -139,7 +139,7 @@
optionLabel="label"
optionValue="value"
placeholder="Choose scan mode"
class="w-full"
class="full-width"
[showClear]="false"
/>
<div class="info-tooltip large">
@@ -166,7 +166,7 @@
optionLabel="label"
optionValue="value"
placeholder="Select preferred format"
class="w-full"
class="full-width"
appendTo="body"
/>
<div class="info-tooltip">

View File

@@ -848,3 +848,7 @@
.validation-message {
@include panel.validation-message;
}
.full-width {
width: 100%;
}

View File

@@ -18,7 +18,7 @@
</p-button>
</div>
}
<div class="rounded-xl overflow-hidden border-[1px] border-solid border-[var(--p-content-border-color)]" [class.dialog-mode]="config">
<div class="tabs-wrapper" [class.dialog-mode]="config">
<p-tabs [(value)]="tab" lazy="true" scrollable>
<p-tablist>
<p-tab value="view">
@@ -38,7 +38,7 @@
</p-tab>
}
</p-tablist>
<p-tabpanels class="tabpanels-responsive overflow-auto">
<p-tabpanels class="tabpanels-responsive">
<p-tabpanel value="view">
<app-metadata-viewer
[book$]="book$"

View File

@@ -11,11 +11,21 @@
flex-shrink: 0;
}
.tabs-wrapper {
border-radius: 0.75rem;
overflow: hidden;
border: 1px solid var(--p-content-border-color);
}
.dialog-mode {
border-radius: 0 0 0.75rem 0.75rem !important;
border-width: 1px 0 0 !important;
}
.tabpanels-responsive {
overflow: auto;
}
::ng-deep .layout-main .tabpanels-responsive {
height: calc(100dvh - 9.7rem);
}

View File

@@ -73,7 +73,7 @@
pTooltip="Go to previous book"
tooltipPosition="bottom">
</p-button>
<span class="text-sm text-surface-600 dark:text-surface-400">
<span class="navigation-position">
{{ getNavigationPosition() }}
</span>
<p-button
@@ -537,7 +537,7 @@
pTooltip="Go to previous book"
tooltipPosition="bottom">
</p-button>
<span class="text-sm text-surface-600 dark:text-surface-400">
<span class="navigation-position">
{{ getNavigationPosition() }}
</span>
<p-button

View File

@@ -680,3 +680,8 @@
::ng-deep .p-inputchips {
width: 100%;
}
.navigation-position {
font-size: 0.875rem;
color: var(--text-secondary-color);
}

View File

@@ -17,29 +17,29 @@
</p-button>
</div>
<div class="dialog-content relative">
<div class="dialog-content">
@if (loading) {
<div class="absolute inset-0 z-50 flex items-center justify-center bg-black/60">
<div class="loading-overlay">
<p-progressSpinner></p-progressSpinner>
</div>
}
<div class="p-4 border border-gray-700 rounded-md mb-4 bg-gray-900 text-gray-200">
<div class="flex justify-between items-center mb-2">
<p class="text-base font-semibold">
<div class="info-box">
<div class="info-header">
<p class="info-title">
Editing {{ books.length }} Book{{ books.length !== 1 ? 's' : '' }}
</p>
<button
type="button"
(click)="showBookList = !showBookList"
class="text-sm text-sky-400 hover:underline">
class="toggle-list-btn">
{{ showBookList ? 'Hide' : 'Show' }} Titles
</button>
</div>
@if (showBookList) {
<div class="max-h-48 overflow-y-auto border-t border-gray-700 pt-2 pl-1 text-sm text-gray-300">
<ul class="list-disc pl-5 space-y-1">
<div class="book-list-panel">
<ul class="book-list">
@for (book of books; track book) {
<li>{{ book.metadata?.title || 'Untitled Book' }}</li>
}
@@ -48,31 +48,31 @@
}
</div>
<div class="px-4 text-sm text-yellow-500 mb-2">
<i class="pi pi-exclamation-triangle mr-1"></i>
<div class="warning-text">
<i class="pi pi-exclamation-triangle warning-icon"></i>
Checking <strong>Clear</strong> will remove that field's metadata from <strong>all selected books</strong>.
</div>
<form [formGroup]="metadataForm" (ngSubmit)="onSubmit()" (keydown)="onFormKeydown($event)" class="flex flex-col gap-4 w-full p-4">
<form [formGroup]="metadataForm" (ngSubmit)="onSubmit()" (keydown)="onFormKeydown($event)" class="bulk-form">
<div class="flex justify-between gap-7">
<div class="form-row">
<!-- Authors -->
<div class="flex flex-col gap-2 w-full md:basis-[80%]">
<label for="authors" class="flex items-center justify-between gap-2">
<span class="flex items-center gap-2">
<div class="field-group field-group--wide">
<label for="authors" class="field-label-row">
<span class="field-label-text">
Author(s)
<i class="pi pi-info-circle text-sky-600"
<i class="pi pi-info-circle info-icon"
pTooltip="Enter multiple authors. Press Enter after typing each name."
tooltipPosition="right"
style="cursor: pointer"></i>
</span>
<div class="flex items-center gap-1">
<div class="clear-checkbox">
<p-checkbox [(ngModel)]="clearFields.authors" [ngModelOptions]="{standalone: true}" (onChange)="onFieldClearToggle('authors')" binary="true" inputId="clearAuthors"/>
<label for="clearAuthors" class="text-sm text-gray-300 cursor-pointer">Clear</label>
<label for="clearAuthors" class="clear-label">Clear</label>
</div>
</label>
<p-autoComplete
class="w-full"
class="full-width"
id="authors"
formControlName="authors"
[disabled]="clearFields.authors"
@@ -87,33 +87,33 @@
</div>
<!-- Language -->
<div class="flex flex-col gap-2 md:basis-[20%]">
<label for="language" class="flex items-center justify-between gap-2">
<div class="field-group field-group--narrow">
<label for="language" class="field-label-row">
<span>Language</span>
<div class="flex items-center gap-1">
<div class="clear-checkbox">
<p-checkbox [(ngModel)]="clearFields.language" [ngModelOptions]="{standalone: true}"
(onChange)="onFieldClearToggle('language')" binary="true" inputId="clearLanguage"/>
<label for="clearLanguage" class="text-sm text-gray-300 cursor-pointer">Clear</label>
<label for="clearLanguage" class="clear-label">Clear</label>
</div>
</label>
<input pInputText id="language" formControlName="language" [disabled]="clearFields.language" class="w-full"/>
<input pInputText id="language" formControlName="language" [disabled]="clearFields.language" class="full-width"/>
</div>
</div>
<div class="flex justify-between gap-7">
<div class="form-row">
<!-- Publisher -->
<div class="flex flex-col gap-2 w-full md:basis-[80%]">
<label for="publisher" class="flex items-center justify-between gap-2">
<div class="field-group field-group--wide">
<label for="publisher" class="field-label-row">
<span>Publisher</span>
<div class="flex items-center gap-1">
<div class="clear-checkbox">
<p-checkbox [(ngModel)]="clearFields.publisher" [ngModelOptions]="{standalone: true}"
(onChange)="onFieldClearToggle('publisher')" binary="true" inputId="clearPublisher"/>
<label for="clearPublisher" class="text-sm text-gray-300 cursor-pointer">Clear</label>
<label for="clearPublisher" class="clear-label">Clear</label>
</div>
</label>
<p-autoComplete
class="w-full"
inputStyleClass="w-full"
class="full-width"
inputStyleClass="full-width"
formControlName="publisher"
inputId="publisher"
[disabled]="clearFields.publisher"
@@ -127,17 +127,17 @@
</div>
<!-- Published Date -->
<div class="flex flex-col gap-2 md:basis-[20%]">
<label for="publishedDate" class="flex items-center justify-between gap-2">
<div class="field-group field-group--narrow">
<label for="publishedDate" class="field-label-row">
<span>Published Date</span>
<div class="flex items-center gap-1">
<div class="clear-checkbox">
<p-checkbox [(ngModel)]="clearFields.publishedDate" [ngModelOptions]="{standalone: true}"
(onChange)="onFieldClearToggle('publishedDate')" binary="true" inputId="clearPublishedDate"/>
<label for="clearPublishedDate" class="text-sm text-gray-300 cursor-pointer">Clear</label>
<label for="clearPublishedDate" class="clear-label">Clear</label>
</div>
</label>
<p-datepicker
class="w-full md:min-w-[9rem]"
class="full-width datepicker-min-width"
formControlName="publishedDate"
inputId="publishedDate"
dataType="string"
@@ -152,20 +152,20 @@
</div>
</div>
<div class="flex justify-between gap-7">
<div class="form-row">
<!-- Series Name -->
<div class="flex flex-col gap-2 w-full md:basis-[80%]">
<label for="seriesName" class="flex items-center justify-between gap-2">
<div class="field-group field-group--wide">
<label for="seriesName" class="field-label-row">
<span>Series Name</span>
<div class="flex items-center gap-1">
<div class="clear-checkbox">
<p-checkbox [(ngModel)]="clearFields.seriesName" [ngModelOptions]="{standalone: true}"
(onChange)="onFieldClearToggle('seriesName')" binary="true" inputId="clearSeriesName"/>
<label for="clearSeriesName" class="text-sm text-gray-300 cursor-pointer">Clear</label>
<label for="clearSeriesName" class="clear-label">Clear</label>
</div>
</label>
<p-autoComplete
class="w-full"
inputStyleClass="w-full"
class="full-width"
inputStyleClass="full-width"
formControlName="seriesName"
inputId="seriesName"
[disabled]="clearFields.seriesName"
@@ -179,44 +179,44 @@
</div>
<!-- Series Total -->
<div class="flex flex-col gap-2 md:basis-[20%]">
<label for="seriesTotal" class="flex items-center justify-between gap-2">
<span class="flex items-center gap-2">
<div class="field-group field-group--narrow">
<label for="seriesTotal" class="field-label-row">
<span class="field-label-text">
Total Books
<i class="pi pi-info-circle text-sky-600"
<i class="pi pi-info-circle info-icon"
pTooltip="Total number of books in the series."
tooltipPosition="right"
style="cursor: pointer"></i>
</span>
<div class="flex items-center gap-1">
<div class="clear-checkbox">
<p-checkbox [(ngModel)]="clearFields.seriesTotal" [ngModelOptions]="{standalone: true}"
(onChange)="onFieldClearToggle('seriesTotal')" binary="true" inputId="clearSeriesTotal"/>
<label for="clearSeriesTotal" class="text-sm text-gray-300 cursor-pointer">Clear</label>
<label for="clearSeriesTotal" class="clear-label">Clear</label>
</div>
</label>
<input pInputText id="seriesTotal" type="number" min="1" formControlName="seriesTotal"
[disabled]="clearFields.seriesTotal" class="w-full"/>
[disabled]="clearFields.seriesTotal" class="full-width"/>
</div>
</div>
<!-- Genres -->
<div class="flex flex-col gap-2">
<label for="genres" class="flex items-center justify-between gap-2">
<span class="flex items-center gap-2">
<div class="field-group">
<label for="genres" class="field-label-row">
<span class="field-label-text">
Genre(s)
<i class="pi pi-info-circle text-sky-600"
<i class="pi pi-info-circle info-icon"
pTooltip="Enter multiple genres. Press Enter after typing each genre."
tooltipPosition="right"
style="cursor: pointer"></i>
</span>
<div class="flex items-center gap-1">
<div class="clear-checkbox">
<p-checkbox [(ngModel)]="clearFields.genres" [ngModelOptions]="{standalone: true}"
(onChange)="onFieldClearToggle('genres')" binary="true" inputId="clearGenres"/>
<label for="clearGenres" class="text-sm text-gray-300 cursor-pointer">Clear</label>
<label for="clearGenres" class="clear-label">Clear</label>
</div>
</label>
<p-autoComplete
class="w-full"
class="full-width"
id="genres"
formControlName="genres"
[disabled]="clearFields.genres"
@@ -229,7 +229,7 @@
(onSelect)="onAutoCompleteSelect('genres', $event)">
</p-autoComplete>
<div class="flex items-center gap-2 mt-2">
<div class="merge-option">
<p-checkbox
inputId="mergeCategories"
name="mergeCategories"
@@ -237,30 +237,30 @@
[ngModelOptions]="{ standalone: true }"
binary="true"
/>
<label for="mergeCategories" class="text-sm text-gray-300 cursor-pointer">
<label for="mergeCategories" class="clear-label">
Keep existing genres and add new ones (uncheck to remove all existing genres)
</label>
</div>
</div>
<!-- Moods -->
<div class="flex flex-col gap-2">
<label for="moods" class="flex items-center justify-between gap-2">
<span class="flex items-center gap-2">
<div class="field-group">
<label for="moods" class="field-label-row">
<span class="field-label-text">
Mood(s)
<i class="pi pi-info-circle text-sky-600"
<i class="pi pi-info-circle info-icon"
pTooltip="Enter multiple moods. Press Enter after typing each mood."
tooltipPosition="right"
style="cursor: pointer"></i>
</span>
<div class="flex items-center gap-1">
<div class="clear-checkbox">
<p-checkbox [(ngModel)]="clearFields.moods" [ngModelOptions]="{standalone: true}"
(onChange)="onFieldClearToggle('moods')" binary="true" inputId="clearMoods"/>
<label for="clearMoods" class="text-sm text-gray-300 cursor-pointer">Clear</label>
<label for="clearMoods" class="clear-label">Clear</label>
</div>
</label>
<p-autoComplete
class="w-full"
class="full-width"
id="moods"
formControlName="moods"
[disabled]="clearFields.moods"
@@ -273,7 +273,7 @@
(onSelect)="onAutoCompleteSelect('moods', $event)">
</p-autoComplete>
<div class="flex items-center gap-2 mt-2">
<div class="merge-option">
<p-checkbox
inputId="mergeMoods"
name="mergeMoods"
@@ -281,30 +281,30 @@
[ngModelOptions]="{ standalone: true }"
binary="true"
/>
<label for="mergeMoods" class="text-sm text-gray-300 cursor-pointer">
<label for="mergeMoods" class="clear-label">
Keep existing moods and add new ones (uncheck to remove all existing moods)
</label>
</div>
</div>
<!-- Tags -->
<div class="flex flex-col gap-2">
<label for="tags" class="flex items-center justify-between gap-2">
<span class="flex items-center gap-2">
<div class="field-group">
<label for="tags" class="field-label-row">
<span class="field-label-text">
Tag(s)
<i class="pi pi-info-circle text-sky-600"
<i class="pi pi-info-circle info-icon"
pTooltip="Enter multiple tags. Press Enter after typing each tag."
tooltipPosition="right"
style="cursor: pointer"></i>
</span>
<div class="flex items-center gap-1">
<div class="clear-checkbox">
<p-checkbox [(ngModel)]="clearFields.tags" [ngModelOptions]="{standalone: true}"
(onChange)="onFieldClearToggle('tags')" binary="true" inputId="clearTags"/>
<label for="clearTags" class="text-sm text-gray-300 cursor-pointer">Clear</label>
<label for="clearTags" class="clear-label">Clear</label>
</div>
</label>
<p-autoComplete
class="w-full"
class="full-width"
id="tags"
formControlName="tags"
[disabled]="clearFields.tags"
@@ -317,7 +317,7 @@
(onSelect)="onAutoCompleteSelect('tags', $event)">
</p-autoComplete>
<div class="flex items-center gap-2 mt-2">
<div class="merge-option">
<p-checkbox
inputId="mergeTags"
name="mergeTags"
@@ -325,24 +325,24 @@
[ngModelOptions]="{ standalone: true }"
binary="true"
/>
<label for="mergeTags" class="text-sm text-gray-300 cursor-pointer">
<label for="mergeTags" class="clear-label">
Keep existing tags and add new ones (uncheck to remove all existing tags)
</label>
</div>
</div>
<!-- Cover Image Upload Section -->
<div class="flex flex-col gap-2 border border-gray-700 rounded-md p-4 bg-gray-900">
<label class="flex items-center gap-2 text-base font-semibold">
<div class="cover-section">
<label class="cover-label">
<i class="pi pi-image"></i>
Cover Image
</label>
<p class="text-sm text-gray-400">
<p class="cover-description">
Upload an image to set as the cover for all selected books.
</p>
<div class="flex items-center gap-4 mt-2">
<div class="cover-actions">
@if (selectedCoverFile) {
<div class="flex items-center gap-2 text-sm text-gray-300">
<div class="cover-file-info">
<i class="pi pi-file-image"></i>
<span>{{ selectedCoverFile.name }}</span>
<p-button
@@ -361,7 +361,7 @@
#coverFileInput
accept="image/jpeg,image/png,image/webp"
(change)="onCoverFileSelect($event)"
class="hidden">
class="file-input-hidden">
<p-button
icon="pi pi-upload"
label="Select Image"
@@ -374,7 +374,7 @@
</div>
</div>
<div class="flex justify-end mt-4">
<div class="form-submit">
<p-button type="submit" label="Apply to Selected" [disabled]="loading"/>
</div>
</form>

View File

@@ -20,6 +20,208 @@
border-top: none;
border-radius: 0 0 10px 10px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
position: relative;
}
.loading-overlay {
position: absolute;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
}
.info-box {
padding: 1rem;
border: 1px solid #374151;
border-radius: 0.375rem;
margin-bottom: 1rem;
background: #111827;
color: #e5e7eb;
}
.info-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.info-title {
font-size: 1rem;
font-weight: 600;
}
.toggle-list-btn {
font-size: 0.875rem;
color: #38bdf8;
background: none;
border: none;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.book-list-panel {
max-height: 12rem;
overflow-y: auto;
border-top: 1px solid #374151;
padding-top: 0.5rem;
padding-left: 0.25rem;
font-size: 0.875rem;
color: #d1d5db;
}
.book-list {
list-style-type: disc;
padding-left: 1.25rem;
li + li {
margin-top: 0.25rem;
}
}
.warning-text {
padding: 0 1rem;
font-size: 0.875rem;
color: #eab308;
margin-bottom: 0.5rem;
}
.warning-icon {
margin-right: 0.25rem;
}
.bulk-form {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
padding: 1rem;
}
.form-row {
display: flex;
justify-content: space-between;
gap: 1.75rem;
}
.field-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
&--wide {
width: 100%;
@media (min-width: 768px) {
flex-basis: 80%;
}
}
&--narrow {
@media (min-width: 768px) {
flex-basis: 20%;
}
}
}
.field-label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.field-label-text {
display: flex;
align-items: center;
gap: 0.5rem;
}
.info-icon {
color: #0284c7;
}
.clear-checkbox {
display: flex;
align-items: center;
gap: 0.25rem;
}
.clear-label {
font-size: 0.875rem;
color: #d1d5db;
cursor: pointer;
}
.full-width {
width: 100%;
}
.datepicker-min-width {
@media (min-width: 768px) {
min-width: 9rem;
}
}
.merge-option {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
}
.cover-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
border: 1px solid #374151;
border-radius: 0.375rem;
padding: 1rem;
background: #111827;
}
.cover-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
font-weight: 600;
}
.cover-description {
font-size: 0.875rem;
color: #9ca3af;
}
.cover-actions {
display: flex;
align-items: center;
gap: 1rem;
margin-top: 0.5rem;
}
.cover-file-info {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: #d1d5db;
}
.file-input-hidden {
display: none;
}
.form-submit {
display: flex;
justify-content: flex-end;
margin-top: 1rem;
}
::ng-deep .p-inputchips {

View File

@@ -181,7 +181,7 @@
type="text"
pInputText
[(ngModel)]="mergeTarget"
class="w-full"
class="full-width-input"
[placeholder]="isSingleValueField(currentMergeType) ? 'e.g., English' : 'e.g., Fiction, Mystery, Thriller'"/>
@if (isSingleValueField(currentMergeType)) {
<small class="help-text">
@@ -254,7 +254,7 @@
type="text"
pInputText
[(ngModel)]="renameTarget"
class="w-full"
class="full-width-input"
[placeholder]="isSingleValueField(currentMergeType) ? 'e.g., Mystery' : 'e.g., Mystery, Detective'"
(keyup.enter)="confirmRename()"/>
@if (isSingleValueField(currentMergeType)) {

View File

@@ -497,3 +497,7 @@
font-size: 1rem;
}
}
.full-width-input {
width: 100%;
}

View File

@@ -1,102 +1,102 @@
<div class="flex flex-col space-y-6 p-4">
<table class="min-w-full table-auto border-collapse custom-table">
<div class="options-container">
<table class="custom-table">
<thead>
<tr>
<th class="px-4 py-1.5 text-left font-semibold text-zinc-300" style="max-width: 70px; width: 70px;">Enabled</th>
<th class="px-4 py-1.5 text-left font-semibold text-zinc-300" style="max-width: 127px; width: 127px;">Field</th>
<th class="px-4 py-1.5 text-left font-semibold text-zinc-300">
<th class="table-header" style="max-width: 70px; width: 70px;">Enabled</th>
<th class="table-header" style="max-width: 127px; width: 127px;">Field</th>
<th class="table-header">
1st Priority
<i class="pi pi-question-circle ml-1 text-xs"
<i class="pi pi-question-circle help-icon"
pTooltip="First choice - always tried first for this field"
tooltipPosition="top"></i>
</th>
<th class="px-4 py-1.5 text-left font-semibold text-zinc-300">
<th class="table-header">
2nd Priority
<i class="pi pi-question-circle ml-1 text-xs"
<i class="pi pi-question-circle help-icon"
pTooltip="Second choice - used if 1st priority doesn't have data"
tooltipPosition="top"></i>
</th>
<th class="px-4 py-1.5 text-left font-semibold text-zinc-300">
<th class="table-header">
3rd Priority
<i class="pi pi-question-circle ml-1 text-xs"
<i class="pi pi-question-circle help-icon"
pTooltip="Third choice - used if 1st and 2nd priorities don't have data"
tooltipPosition="top"></i>
</th>
<th class="px-4 py-1.5 text-left font-semibold text-zinc-300">
<th class="table-header">
4th Priority
<i class="pi pi-question-circle ml-1 text-xs"
<i class="pi pi-question-circle help-icon"
pTooltip="Last fallback option - only used if 1st, 2nd, and 3rd priorities fail or are empty"
tooltipPosition="top"></i>
</th>
</tr>
<tr>
<td class="px-4 py-2" style="max-width: 70px; width: 70px;"></td>
<td class="px-4 py-2 text-sm text-zinc-400 italic" style="max-width: 127px; width: 127px;">Set All:</td>
<td class="px-4 py-2">
<td class="table-cell" style="max-width: 70px; width: 70px;"></td>
<td class="table-cell set-all-label" style="max-width: 127px; width: 127px;">Set All:</td>
<td class="table-cell">
<p-select [options]="providersWithClear" [(ngModel)]="bulkP1"
(ngModelChange)="setBulkProvider('p1', $event)"
placeholder="Set all P1" appendTo="body"
class="w-full" size="small">
styleClass="select-full-width" size="small">
</p-select>
</td>
<td class="px-4 py-2">
<td class="table-cell">
<p-select [options]="providersWithClear" [(ngModel)]="bulkP2"
(ngModelChange)="setBulkProvider('p2', $event)"
placeholder="Set all P2" appendTo="body"
class="w-full" size="small">
styleClass="select-full-width" size="small">
</p-select>
</td>
<td class="px-4 py-2">
<td class="table-cell">
<p-select [options]="providersWithClear" [(ngModel)]="bulkP3"
(ngModelChange)="setBulkProvider('p3', $event)"
placeholder="Set all P3" appendTo="body"
class="w-full" size="small">
styleClass="select-full-width" size="small">
</p-select>
</td>
<td class="px-4 py-2">
<td class="table-cell">
<p-select [options]="providersWithClear" [(ngModel)]="bulkP4"
(ngModelChange)="setBulkProvider('p4', $event)"
placeholder="Set all P4" appendTo="body"
class="w-full" size="small">
styleClass="select-full-width" size="small">
</p-select>
</td>
</tr>
</thead>
<tbody>
@for (field of nonProviderSpecificFields; track field) {
<tr [hidden]="field === 'cover' && !refreshCovers" [class.opacity-50]="!enabledFields[field]">
<td class="px-4 py-1.5" style="max-width: 70px; width: 70px;">
<tr [hidden]="field === 'cover' && !refreshCovers" [class.row-disabled]="!enabledFields[field]">
<td class="table-cell-sm" style="max-width: 70px; width: 70px;">
<p-checkbox [(ngModel)]="enabledFields[field]" [binary]="true"
pTooltip="Enable this field during metadata fetch"
tooltipPosition="top"></p-checkbox>
</td>
<td class="px-4 py-1.5 text-zinc-200" style="max-width: 127px; width: 127px;">{{ formatLabel(field) }}</td>
<td class="px-4 py-1.5">
<td class="table-cell-sm field-label" style="max-width: 127px; width: 127px;">{{ formatLabel(field) }}</td>
<td class="table-cell-sm">
<p-select [options]="providers" [(ngModel)]="fieldOptions[field].p1"
[disabled]="!enabledFields[field]"
placeholder="Unset" appendTo="body"
class="w-full" size="small">
styleClass="select-full-width" size="small">
</p-select>
</td>
<td class="px-4 py-1.5">
<td class="table-cell-sm">
<p-select [options]="providers" [(ngModel)]="fieldOptions[field].p2"
[disabled]="!enabledFields[field]"
placeholder="Unset" appendTo="body"
class="w-full" size="small">
styleClass="select-full-width" size="small">
</p-select>
</td>
<td class="px-4 py-1.5">
<td class="table-cell-sm">
<p-select [options]="providers" [(ngModel)]="fieldOptions[field].p3"
[disabled]="!enabledFields[field]"
placeholder="Unset" appendTo="body"
class="w-full" size="small">
styleClass="select-full-width" size="small">
</p-select>
</td>
<td class="px-4 py-1.5">
<td class="table-cell-sm">
<p-select [options]="providers" [(ngModel)]="fieldOptions[field].p4"
[disabled]="!enabledFields[field]"
placeholder="Unset" appendTo="body"
class="w-full" size="small">
styleClass="select-full-width" size="small">
</p-select>
</td>
</tr>
@@ -104,45 +104,45 @@
</tbody>
</table>
<div class="space-y-4">
<h3 class="text-lg font-semibold text-zinc-300">Provider-Specific Fields</h3>
<p class="text-sm text-zinc-400">These fields are unique to specific providers and cannot have custom priority settings. Use the checkboxes to enable/disable fetching these fields.</p>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div class="provider-specific-section">
<h3 class="section-title">Provider-Specific Fields</h3>
<p class="section-description">These fields are unique to specific providers and cannot have custom priority settings. Use the checkboxes to enable/disable fetching these fields.</p>
<div class="fields-grid">
@for (field of providerSpecificFields; track field) {
<div class="flex items-center space-x-3 p-3 border border-zinc-600 rounded-lg">
<div class="field-card">
<p-checkbox [(ngModel)]="enabledFields[field]" [binary]="true"
pTooltip="Enable this field during metadata fetch"
tooltipPosition="top"></p-checkbox>
<span class="text-sm text-zinc-300">{{ formatLabel(field) }}</span>
<span class="field-card-label">{{ formatLabel(field) }}</span>
</div>
}
</div>
</div>
<div class="flex flex-row items-center justify-between gap-4 w-full">
<div class="footer-row">
<div class="flex flex-row gap-10">
<div class="flex flex-row items-center gap-4">
<div class="options-group">
<div class="option-item">
<p pTooltip="Controls how fetched metadata replaces existing values. 'Replace Missing Only' only fills empty fields, 'Replace All Fields' overwrites existing values." tooltipPosition="top">Replace Mode:</p>
<p-select [options]="replaceModeOptions" [(ngModel)]="replaceMode" optionLabel="label" optionValue="value"
appendTo="body" size="small" class="min-w-40">
appendTo="body" size="small" styleClass="select-mode">
</p-select>
</div>
<div class="flex flex-row items-center gap-4">
<div class="option-item">
<p pTooltip="Fetch and update new book cover images" tooltipPosition="top">Update covers:</p>
<p-checkbox [(ngModel)]="refreshCovers" [binary]="true"></p-checkbox>
</div>
<div class="flex flex-row items-center gap-4">
<div class="option-item">
<p pTooltip="Combine new genres with existing ones" tooltipPosition="top">Merge genres:</p>
<p-checkbox [(ngModel)]="mergeCategories" [binary]="true"></p-checkbox>
</div>
<div class="flex flex-row items-center gap-4">
<div class="option-item">
<p pTooltip="Review and approve metadata changes before saving" tooltipPosition="top">Manual review:</p>
<p-checkbox [(ngModel)]="reviewBeforeApply" [binary]="true"></p-checkbox>
</div>
</div>
<div class="flex flex-row gap-6">
<div class="action-buttons">
<p-button severity="warn" outlined="true" label="Reset Form" (onClick)="reset()"></p-button>
<p-button [label]="submitButtonLabel" severity="success" icon="pi pi-save" outlined="true" (onClick)="submit()"></p-button>
</div>

View File

@@ -1,4 +1,137 @@
.options-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1rem;
}
.custom-table {
width: 100%;
min-width: 100%;
table-layout: auto;
border-collapse: collapse;
border: 1px solid var(--border-color);
border-radius: var(--card-border);
}
.table-header {
padding: 0.375rem 1rem;
text-align: left;
font-weight: 600;
color: #d4d4d8;
}
.help-icon {
margin-left: 0.25rem;
font-size: 0.75rem;
}
.table-cell {
padding: 0.5rem 1rem;
}
.table-cell-sm {
padding: 0.375rem 1rem;
}
.set-all-label {
font-size: 0.875rem;
color: #a1a1aa;
font-style: italic;
}
.field-label {
color: #e4e4e7;
}
.row-disabled {
opacity: 0.5;
}
// Provider specific section
.provider-specific-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.section-title {
font-size: 1.125rem;
font-weight: 600;
color: #d4d4d8;
margin: 0;
}
.section-description {
font-size: 0.875rem;
color: #a1a1aa;
margin: 0;
}
.fields-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
@media (min-width: 768px) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@media (min-width: 1024px) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.field-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border: 1px solid #52525b;
border-radius: 0.5rem;
}
.field-card-label {
font-size: 0.875rem;
color: #d4d4d8;
}
// Footer row
.footer-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 1rem;
width: 100%;
}
.options-group {
display: flex;
flex-direction: row;
gap: 2.5rem;
}
.option-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 1rem;
}
.action-buttons {
display: flex;
flex-direction: row;
gap: 1.5rem;
}
// Select styles
:host ::ng-deep {
.select-full-width {
width: 100%;
}
.select-mode {
min-width: 10rem;
}
}

View File

@@ -19,11 +19,11 @@
<div class="dialog-content">
<div class="book-list-section">
<div class="max-h-24 overflow-auto border border-gray-500 rounded p-2 text-sm">
<ul class="list-disc pl-5 space-y-1">
<div class="book-list-container">
<ul class="book-list">
@for (book of booksToShow; track book.id) {
<li>
<span class="text-gray-400">#{{ book.id }}</span>: {{ book.metadata?.title || 'Untitled' }}
<span class="book-id">#{{ book.id }}</span>: {{ book.metadata?.title || 'Untitled' }}
</li>
}
</ul>

View File

@@ -38,6 +38,32 @@
margin-bottom: 1rem;
}
.book-list-container {
max-height: 6rem;
overflow: auto;
border: 1px solid #6b7280;
border-radius: 0.25rem;
padding: 0.5rem;
font-size: 0.875rem;
}
.book-list {
list-style-type: disc;
padding-left: 1.25rem;
li {
margin-bottom: 0.25rem;
&:last-child {
margin-bottom: 0;
}
}
}
.book-id {
color: #9ca3af;
}
::ng-deep app-metadata-fetch-options {
flex: 1;
display: flex;

View File

@@ -32,7 +32,7 @@
</div>
<!-- Page View (only in paginated mode) -->
@if (isPaginated) {
@if (isPaginated && !isPhonePortrait) {
<div class="control">
<label>Page View</label>
<div class="control-right">

View File

@@ -72,6 +72,10 @@ export class CbxQuickSettingsComponent implements OnInit, OnDestroy {
return this.state.scrollMode === CbxScrollMode.PAGINATED;
}
get isPhonePortrait(): boolean {
return window.innerWidth < 768 && window.innerHeight > window.innerWidth;
}
onFitModeSelect(mode: CbxFitMode): void {
this.quickSettingsService.emitFitModeChange(mode);
}

View File

@@ -122,6 +122,10 @@ export class ReaderEventService {
private attachKeyboardHandler(): void {
this.keydownHandler = (event: KeyboardEvent) => {
const target = event.target as HTMLElement;
if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA' || target?.isContentEditable) {
return;
}
const k = event.key;
if (k === 'ArrowLeft' || k === 'h' || k === 'PageUp') {
this.viewCallbacks?.prev();

View File

@@ -1,5 +1,5 @@
@if (isLoading) {
<div class="flex justify-center items-center h-screen relative">
<div class="loading-container">
<p-progressSpinner></p-progressSpinner>
</div>
}

View File

@@ -0,0 +1,7 @@
.loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
position: relative;
}

View File

@@ -17,6 +17,7 @@ import {Location} from '@angular/common';
standalone: true,
imports: [NgxExtendedPdfViewerModule, ProgressSpinner],
templateUrl: './pdf-reader.component.html',
styleUrl: './pdf-reader.component.scss',
})
export class PdfReaderComponent implements OnInit, OnDestroy {
isLoading = true;

View File

@@ -1,13 +1,13 @@
<div class="w-full h-[calc(100dvh-10.5rem)] md:h-[calc(100dvh-11.65rem)] overflow-y-auto border rounded-lg enclosing-container">
<div class="pb-6">
<div class="device-settings-container">
<div class="koreader-section">
<app-koreader-settings-component></app-koreader-settings-component>
</div>
<p-divider></p-divider>
<div class="py-4">
<div class="hardcover-section">
<app-hardcover-settings-component></app-hardcover-settings-component>
</div>
<p-divider></p-divider>
<div class="py-4">
<div class="kobo-section">
<app-kobo-sync-setting-component></app-kobo-sync-setting-component>
</div>
</div>

View File

@@ -1,3 +1,21 @@
.enclosing-container {
border-color: var(--p-content-border-color);
.device-settings-container {
width: 100%;
height: calc(100dvh - 10.5rem);
overflow-y: auto;
border: 1px solid var(--p-content-border-color);
border-radius: 0.5rem;
@media (min-width: 768px) {
height: calc(100dvh - 11.65rem);
}
}
.koreader-section {
padding-bottom: 1.5rem;
}
.hardcover-section,
.kobo-section {
padding-top: 1rem;
padding-bottom: 1rem;
}

View File

@@ -96,7 +96,7 @@
<ng-template pTemplate="body" let-provider>
<tr>
<td class="text-center">
<td class="cell-center">
<p-radioButton
name="defaultProvider"
[value]="provider.id"
@@ -107,10 +107,10 @@
<td>
@if (provider.isEditing) {
<input type="text" [(ngModel)]="provider.name" class="p-inputtext w-full" size="small"/>
<input type="text" [(ngModel)]="provider.name" class="p-inputtext input-full-width" size="small"/>
}
@if (!provider.isEditing) {
<div class="flex align-items-center gap-2">
<div class="provider-name-cell">
<span>
{{ provider.name }}
@if (provider.shared) {
@@ -123,7 +123,7 @@
<td>
@if (provider.isEditing) {
<input type="text" [(ngModel)]="provider.host" class="p-inputtext w-full" size="small"/>
<input type="text" [(ngModel)]="provider.host" class="p-inputtext input-full-width" size="small"/>
}
@if (!provider.isEditing) {
<span>{{ provider.host }}</span>
@@ -132,7 +132,7 @@
<td>
@if (provider.isEditing) {
<input type="number" [(ngModel)]="provider.port" class="p-inputtext w-full" size="small"/>
<input type="number" [(ngModel)]="provider.port" class="p-inputtext input-full-width" size="small"/>
}
@if (!provider.isEditing) {
<span>{{ provider.port }}</span>
@@ -141,7 +141,7 @@
<td>
@if (provider.isEditing) {
<input type="text" [(ngModel)]="provider.username" class="p-inputtext w-full" size="small"/>
<input type="text" [(ngModel)]="provider.username" class="p-inputtext input-full-width" size="small"/>
}
@if (!provider.isEditing) {
<span>{{ provider.username }}</span>
@@ -150,7 +150,7 @@
<td>
@if (provider.isEditing) {
<input [(ngModel)]="provider.password" class="p-inputtext w-full" size="small"/>
<input [(ngModel)]="provider.password" class="p-inputtext input-full-width" size="small"/>
}
@if (!provider.isEditing) {
<span class="password-hidden">Hidden</span>
@@ -159,14 +159,14 @@
<td>
@if (provider.isEditing) {
<input type="text" [(ngModel)]="provider.fromAddress" class="p-inputtext w-full" size="small"/>
<input type="text" [(ngModel)]="provider.fromAddress" class="p-inputtext input-full-width" size="small"/>
}
@if (!provider.isEditing) {
<span>{{ provider.fromAddress }}</span>
}
</td>
<td class="text-center">
<td class="cell-center">
<p-checkbox
[(ngModel)]="provider.auth"
[binary]="true"
@@ -174,7 +174,7 @@
</p-checkbox>
</td>
<td class="text-center">
<td class="cell-center">
<p-checkbox
[(ngModel)]="provider.startTls"
[binary]="true"
@@ -183,7 +183,7 @@
</td>
@if (isAdmin) {
<td class="text-center">
<td class="cell-center">
<p-checkbox
[(ngModel)]="provider.shared"
[binary]="true"
@@ -208,7 +208,7 @@
</p-button>
}
@if (provider.isEditing) {
<div class="flex gap-1">
<div class="edit-actions">
<p-button
icon="pi pi-check"
severity="success"

View File

@@ -202,3 +202,22 @@
vertical-align: text-bottom;
margin-left: 0.25rem;
}
.cell-center {
text-align: center;
}
.input-full-width {
width: 100%;
}
.provider-name-cell {
display: flex;
align-items: center;
gap: 0.5rem;
}
.edit-actions {
display: flex;
gap: 0.25rem;
}

View File

@@ -73,7 +73,7 @@
<td>
@if (recipient.isEditing) {
<input type="email" [(ngModel)]="recipient.email" class="p-inputtext w-full" size="small"/>
<input type="email" [(ngModel)]="recipient.email" class="p-inputtext full-width" size="small"/>
}
@if (!recipient.isEditing) {
<span>{{ recipient.email }}</span>
@@ -82,7 +82,7 @@
<td>
@if (recipient.isEditing) {
<input type="text" [(ngModel)]="recipient.name" class="p-inputtext w-full" size="small"/>
<input type="text" [(ngModel)]="recipient.name" class="p-inputtext full-width" size="small"/>
}
@if (!recipient.isEditing) {
<span>{{ recipient.name }}</span>
@@ -102,7 +102,7 @@
</p-button>
}
@if (recipient.isEditing) {
<div class="flex gap-1">
<div class="edit-actions">
<p-button
icon="pi pi-check"
severity="success"

View File

@@ -169,3 +169,16 @@
font-size: 0.875rem;
}
}
.full-width {
width: 100%;
}
.edit-actions {
display: flex;
gap: 0.25rem;
}
.text-center {
text-align: center;
}

View File

@@ -1,4 +1,4 @@
<div class="w-full h-[calc(100dvh-10.5rem)] md:h-[calc(100dvh-11.65rem)] overflow-y-auto border rounded-lg p-4 enclosing-container">
<div class="email-settings-container enclosing-container">
<div class="settings-header">
<h2 class="settings-title">
<i class="pi pi-envelope"></i>
@@ -11,11 +11,11 @@
</div>
@if (hasPermission) {
<div class="pb-8">
<div class="provider-section">
<app-email-v2-provider></app-email-v2-provider>
</div>
<p-divider></p-divider>
<div class="pt-4">
<div class="recipient-section">
<app-email-v2-recipient></app-email-v2-recipient>
</div>
} @else {

View File

@@ -1,6 +1,23 @@
.enclosing-container {
border-color: var(--p-content-border-color);
.email-settings-container {
width: 100%;
height: calc(100dvh - 10.5rem);
overflow-y: auto;
border: 1px solid var(--p-content-border-color);
border-radius: 0.5rem;
padding: 1rem;
background: var(--p-content-background);
@media (min-width: 768px) {
height: calc(100dvh - 11.65rem);
}
}
.provider-section {
padding-bottom: 2rem;
}
.recipient-section {
padding-top: 1rem;
}
.settings-header {

View File

@@ -34,7 +34,7 @@
<div class="settings-card">
<div class="form-field">
<div class="library-header pb-2">
<div class="library-header">
<i [class]="'pi pi-bolt'"></i>
<span class="library-name">Default Pattern</span>
</div>
@@ -45,7 +45,7 @@
[(ngModel)]="defaultPattern"
placeholder="e.g., {title} - {authors}"
(input)="onDefaultPatternChange(defaultPattern)"
class="w-full"/>
class="full-width"/>
<p-button
label="Save"
icon="pi pi-save"
@@ -94,7 +94,7 @@
[(ngModel)]="library.fileNamingPattern"
placeholder="Leave empty to use default pattern"
(input)="onLibraryPatternChange(library)"
class="w-full"/>
class="full-width"/>
<p-button
label="Clear"
(onClick)="clearLibraryPattern(library)"

View File

@@ -207,6 +207,7 @@
display: flex;
align-items: center;
gap: 0.5rem;
padding-bottom: 0.5rem;
.pi {
color: var(--p-primary-color);
@@ -441,3 +442,7 @@
font-size: 0.75rem;
}
}
.full-width {
width: 100%;
}

View File

@@ -1,6 +1,6 @@
<div class="w-full h-[calc(100dvh-10.5rem)] md:h-[calc(100dvh-11.65rem)] overflow-y-auto">
<div class="metadata-settings-container">
<app-metadata-persistence-settings-component></app-metadata-persistence-settings-component>
<div class="pt-10"><hr></div>
<div class="section-divider"><hr></div>
<div class="preferences-section">
<div class="settings-card">
<div class="section-header">
@@ -27,12 +27,12 @@
</div>
</div>
</div>
<div class="pt-10"><hr></div>
<div class="section-divider"><hr></div>
<app-metadata-provider-settings></app-metadata-provider-settings>
<div class="pt-10"><hr></div>
<div class="section-divider"><hr></div>
<app-metadata-provider-field-selector></app-metadata-provider-field-selector>
<div class="pt-10"><hr></div>
<div class="section-divider"><hr></div>
<app-public-reviews-settings-component></app-public-reviews-settings-component>
<div class="pt-10"><hr></div>
<div class="section-divider"><hr></div>
<app-metadata-match-weights-component></app-metadata-match-weights-component>
</div>

View File

@@ -1,3 +1,17 @@
.metadata-settings-container {
width: 100%;
height: calc(100dvh - 10.5rem);
overflow-y: auto;
@media (min-width: 768px) {
height: calc(100dvh - 11.65rem);
}
}
.section-divider {
padding-top: 2.5rem;
}
.settings-card {
display: flex;
flex-direction: column;

View File

@@ -232,7 +232,7 @@
</td>
<td>
@if (editingUserId === user.id) {
<div class="flex items-center gap-2">
<div class="inline-row">
<p-select
[options]="sortOrderOptions"
[(ngModel)]="editingSortOrder"
@@ -258,7 +258,7 @@
</p-button>
</div>
} @else {
<div class="flex items-center gap-2">
<div class="inline-row">
<span class="sort-order-badge">{{ getSortOrderLabel(user.sortOrder) }}</span>
<p-button
icon="pi pi-pencil"
@@ -272,10 +272,10 @@
}
</td>
<td>
<div class="flex items-center gap-2">
<div class="inline-row">
<p-password
fluid
class="w-32 md:w-56"
styleClass="password-input"
[(ngModel)]="dummyPassword"
[feedback]="false"
size="small"
@@ -283,7 +283,7 @@
[toggleMask]="false">
</p-password>
<i
class="pi pi-info-circle text-gray-400"
class="pi pi-info-circle info-icon"
pTooltip="Passwords are hidden for security reasons. To change, delete the user and create a new one with a new password."
tooltipPosition="right"
style="cursor: pointer;">

View File

@@ -279,6 +279,24 @@
text-align: center;
}
.inline-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.info-icon {
color: #9ca3af;
}
:host ::ng-deep .password-input {
width: 8rem;
@media (min-width: 768px) {
width: 14rem;
}
}
.empty-message {
text-align: center;
padding: 2rem 1rem;

View File

@@ -1,6 +1,6 @@
@if (userService.userState$ | async; as userState) {
@if (userState.user) {
<div class="rounded-xl overflow-hidden border-[1px] border-solid border-[var(--p-content-border-color)]">
<div class="settings-container">
<p-tabs [(value)]="activeTab" lazy scrollable>
<p-tablist>
<p-tab [value]="SettingsTab.ReaderSettings">

View File

@@ -0,0 +1,5 @@
.settings-container {
border-radius: 0.75rem;
overflow: hidden;
border: 1px solid var(--p-content-border-color);
}

View File

@@ -1,4 +1,4 @@
<div class="user-profile-dialog min-w-[300px] md:min-w-[500px]">
<div class="user-profile-dialog">
<div class="panel-header">
<div class="header-icon-wrapper">

View File

@@ -1,10 +1,15 @@
@use '../../../shared/styles/panel-shared' as panel;
.user-profile-dialog {
min-width: 300px;
max-width: 650px;
display: flex;
flex-direction: column;
@media (min-width: 768px) {
min-width: 500px;
}
@media (max-width: 768px) {
width: 100%;
max-width: 100%;

View File

@@ -1,9 +1,9 @@
<div class="w-full h-[calc(100dvh-10.5rem)] md:h-[calc(100dvh-11.65rem)] overflow-y-auto">
<div class="preferences-container">
<app-view-preferences></app-view-preferences>
<div class="pt-10"><hr></div>
<div class="section-divider"><hr></div>
<app-filter-preferences></app-filter-preferences>
<div class="pt-10"><hr></div>
<div class="section-divider"><hr></div>
<app-sidebar-sorting-preferences></app-sidebar-sorting-preferences>
<div class="pt-10"><hr></div>
<div class="section-divider"><hr></div>
<app-meta-center-view-mode-component></app-meta-center-view-mode-component>
</div>

View File

@@ -1,3 +1,17 @@
.enclosing-container {
border-color: var(--p-content-border-color);
}
.preferences-container {
width: 100%;
height: calc(100dvh - 10.5rem);
overflow-y: auto;
@media (min-width: 768px) {
height: calc(100dvh - 11.65rem);
}
}
.section-divider {
padding-top: 2.5rem;
}

View File

@@ -4,7 +4,7 @@
<div class="library-filter">
<div class="select-row">
<label for="library-dropdown">Library Statistics</label>
<div class="flex w-full gap-2 align-items-center">
<div class="dropdown-row">
<p-select
fluid
id="library-dropdown"

View File

@@ -36,6 +36,13 @@
width: auto;
}
.dropdown-row {
display: flex;
width: 100%;
gap: 0.5rem;
align-items: center;
}
label {
color: var(--text-color, #ffffff);
font-weight: 500;

View File

@@ -56,7 +56,7 @@
optionLabel="name"
placeholder="Select Library"
[(ngModel)]="selectedLibrary"
class="w-full">
class="full-width">
</p-select>
</div>
<div class="field-group">
@@ -68,7 +68,7 @@
placeholder="Select Subpath"
[(ngModel)]="selectedPath"
[disabled]="!selectedLibrary"
class="w-full">
class="full-width">
</p-select>
</div>
</div>
@@ -90,8 +90,7 @@
[multiple]="true"
accept=".pdf,.epub,.cbz,.cbr,.cb7,.fb2,.mobi,.azw,.azw3"
(onSelect)="onFilesSelect($event)"
(uploadHandler)="uploadFiles($event)"
[disabled]="value === 'library' ? (!selectedLibrary || !selectedPath) : false">
(uploadHandler)="uploadFiles($event)">
<ng-template #header let-files let-chooseCallback="chooseCallback" let-clearCallback="clearCallback" let-uploadCallback="uploadCallback">
<div class="upload-actions">
<div class="action-buttons">

View File

@@ -98,9 +98,10 @@
.file-upload-section {
::ng-deep .p-fileupload {
border: 1px solid var(--border-color);
border: 2px dashed var(--border-color);
border-radius: 8px;
background: var(--ground-background);
transition: border-color 0.3s ease, background-color 0.3s ease;
.p-fileupload-buttonbar {
display: flex;
@@ -115,6 +116,25 @@
padding: 1rem;
background: transparent;
border: none;
transition: background-color 0.3s ease;
&.p-fileupload-highlight {
background: color-mix(in srgb, var(--primary-color) 5%, transparent);
.empty-icon-wrapper {
border-color: var(--primary-color);
.empty-icon {
color: var(--primary-color);
opacity: 1;
transform: scale(1.1);
}
}
}
}
&:has(.p-fileupload-highlight) {
border-color: var(--primary-color);
}
}
@@ -299,12 +319,13 @@
border-radius: 50%;
margin-bottom: 1rem;
border: 3px dashed var(--border-color);
transition: border-color 0.3s ease;
.empty-icon {
font-size: 2.5rem;
color: var(--text-secondary-color);
opacity: 0.6;
transition: color 0.3s ease;
transition: color 0.3s ease, opacity 0.3s ease, transform 0.3s ease;
&.active {
color: #22c55e;
@@ -357,6 +378,10 @@
}
}
:host ::ng-deep .p-fileupload-content p-progressbar {
::host ::ng-deep .p-fileupload-content p-progressbar {
display: none !important;
}
.full-width {
width: 100%;
}

View File

@@ -1,4 +1,4 @@
import {Component, inject, OnInit} from '@angular/core';
import {Component, inject, OnInit, ViewChild} from '@angular/core';
import {FileSelectEvent, FileUpload, FileUploadHandlerEvent} from 'primeng/fileupload';
import {Button} from 'primeng/button';
import {AsyncPipe} from '@angular/common';
@@ -43,6 +43,8 @@ interface UploadingFile {
styleUrl: './book-uploader.component.scss'
})
export class BookUploaderComponent implements OnInit {
@ViewChild(FileUpload) fileUpload!: FileUpload;
files: UploadingFile[] = [];
isUploading: boolean = false;
uploadCompleted: boolean = false;
@@ -116,6 +118,18 @@ export class BookUploaderComponent implements OnInit {
}
onFilesSelect(event: FileSelectEvent): void {
if (this.value === 'library' && (!this.selectedLibrary || !this.selectedPath)) {
this.messageService.add({
severity: 'error',
summary: 'No Destination Selected',
detail: 'Please select a library and subpath before adding files.',
life: 5000
});
// We need to clear the files input explicitely, otherwise the files remain selected in the file upload component
this.fileUpload.clear();
return;
}
const newFiles = event.currentFiles;
for (const file of newFiles) {
const exists = this.files.some(f => f.file.name === file.name && f.file.size === file.size);

View File

@@ -96,7 +96,7 @@
strokeWidth="4"
animationDuration="1s"
/>
<p class="mt-3 text-secondary">Loading directories...</p>
<p class="loading-text">Loading directories...</p>
</div>
} @else if (filteredPaths.length === 0) {
<div class="empty-state">

View File

@@ -187,6 +187,11 @@
}
}
.loading-text {
margin-top: 0.75rem;
color: var(--text-secondary-color);
}
.empty-state {
display: flex;
flex-direction: column;
@@ -504,6 +509,11 @@
}
}
.selection-note {
margin-top: 0.75rem;
color: var(--text-secondary-color);
}
.dialog-footer {
@include panel.dialog-footer;
border-top: 1px solid var(--border-color);

View File

@@ -18,41 +18,41 @@
</div>
<div class="dialog-content">
<div class="px-4 mx-2 mb-4 py-3 rounded-lg border border-[var(--border-color)] transition-all">
<p class="text-[var(--primary-color)] font-semibold mb-1">
<div class="preview-info-box">
<p class="preview-title">
File Organization Preview
</p>
<div class="text-sm text-gray-300 space-y-3">
<div class="preview-description">
<p>
Your files will be automatically renamed, organized, and can be moved between libraries based on your preferences.
Check the preview below to see how your files will look, select target libraries if needed, then click <span class="font-semibold text-green-400">Move Files</span> to organize them.
Check the preview below to see how your files will look, select target libraries if needed, then click <span class="highlight-green">Move Files</span> to organize them.
</p>
<p>
<i class="pi pi-lightbulb text-blue-400 mr-1"></i>
<span class="text-blue-300">Want to change how files are named? Go to <strong>Settings → File Naming Pattern</strong>. Each library can have its own naming pattern!</span>
<i class="pi pi-lightbulb icon-blue"></i>
<span class="text-blue">Want to change how files are named? Go to <strong>Settings → File Naming Pattern</strong>. Each library can have its own naming pattern!</span>
</p>
<p class="text-yellow-500 font-medium flex items-center gap-2">
<i class="pi pi-exclamation-triangle text-yellow-400"></i>
<p class="warning-text">
<i class="pi pi-exclamation-triangle icon-yellow"></i>
This will actually move and rename files on your computer across different library folders. Make sure you're happy with the preview first!
</p>
<div class="bg-red-900/30 border border-red-500/50 rounded-md p-3 text-red-200 text-xs">
<p class="font-bold flex items-center gap-2 mb-1">
<div class="metadata-warning-box">
<p class="metadata-warning-title">
<i class="pi pi-exclamation-circle"></i>
Important: Metadata quality warning
</p>
<p>
This feature relies entirely on your books' metadata. If your metadata is missing, inconsistent, or of poor quality, the resulting file names and folder structures will be incorrect or "buggy".
</p>
<p class="mt-1 font-semibold">
<p class="metadata-warning-emphasis">
ONLY recommended for high-quality, curated libraries.
</p>
</div>
</div>
</div>
<div class="flex gap-4 mb-4 px-2 items-center w-full">
<div class="flex-1">
<div class="flex items-center justify-between cursor-pointer" (click)="togglePatternsCollapsed()">
<div class="patterns-section">
<div class="patterns-content">
<div class="patterns-toggle" (click)="togglePatternsCollapsed()">
<p-button
outlined
[icon]="patternsCollapsed ? 'pi pi-chevron-right' : 'pi pi-chevron-down'"
@@ -64,16 +64,16 @@
</div>
@if (!patternsCollapsed) {
<div class="space-y-2 mt-2">
<div class="patterns-list">
@for (pattern of libraryPatterns; track pattern.libraryId) {
<div class="p-3 border border-zinc-700 rounded-lg bg-zinc-800">
<div class="flex justify-between items-start">
<div class="flex-1">
<p class="text-xs text-gray-400 mb-1">Library: {{ pattern.libraryName }}</p>
<code class="text-[var(--primary-color)] font-mono text-sm">{{ pattern.pattern || 'No pattern available' }}</code>
<p class="text-xs text-gray-400 mt-1">Source: {{ pattern.source }}</p>
<div class="pattern-card">
<div class="pattern-card-content">
<div class="pattern-info">
<p class="pattern-label">Library: {{ pattern.libraryName }}</p>
<code class="pattern-code">{{ pattern.pattern || 'No pattern available' }}</code>
<p class="pattern-source">Source: {{ pattern.source }}</p>
</div>
<span class="text-xs bg-blue-600 text-white px-2 py-1 rounded ml-2">{{ pattern.bookCount }} book{{ pattern.bookCount > 1 ? 's' : '' }}</span>
<span class="pattern-badge">{{ pattern.bookCount }} book{{ pattern.bookCount > 1 ? 's' : '' }}</span>
</div>
</div>
}
@@ -82,9 +82,9 @@
</div>
</div>
<div class="flex gap-4 mb-4 px-2 items-center">
<div class="flex items-center gap-3">
<label class="text-sm font-medium text-gray-300">Set all files to:</label>
<div class="bulk-select-section">
<div class="bulk-select-controls">
<label class="bulk-select-label">Set all files to:</label>
<p-select
[options]="availableLibraries"
[(ngModel)]="defaultTargetLibraryId"
@@ -92,7 +92,7 @@
optionValue="id"
placeholder="Select default library"
(onChange)="onDefaultLibraryChange()"
class="min-w-[200px]"
styleClass="select-library"
size="small"
appendTo="body"
></p-select>
@@ -104,7 +104,7 @@
optionValue="id"
placeholder="Select default path"
(onChange)="onDefaultLibraryPathChange()"
class="min-w-[250px]"
styleClass="select-path"
size="small"
appendTo="body"
></p-select>
@@ -112,7 +112,7 @@
</div>
</div>
<div class="mb-4" style="height: 500px;">
<div class="table-container">
<p-table
[value]="filePreviews"
[scrollable]="true"
@@ -135,8 +135,8 @@
<tr>
<td>{{ preview.bookId }}</td>
<td>
<div class="overflow-hidden text-ellipsis">
<span class="text-gray-400">{{ preview.currentLibraryName }}/</span><span class="text-gray-200">{{ preview.relativeOriginalPath }}</span>
<div class="path-cell">
<span class="path-prefix">{{ preview.currentLibraryName }}/</span><span class="path-value">{{ preview.relativeOriginalPath }}</span>
</div>
</td>
<td style="max-width:150px;">
@@ -148,7 +148,7 @@
placeholder="Select Library"
(onChange)="onLibraryChange(preview)"
[disabled]="preview.isMoved"
class="w-full"
styleClass="select-full-width"
appendTo="body"
size="small"
></p-select>
@@ -163,23 +163,23 @@
placeholder="Select Path"
(onChange)="onLibraryPathChange(preview)"
[disabled]="preview.isMoved"
class="w-full"
styleClass="select-full-width"
appendTo="body"
size="small"
></p-select>
} @else {
<div class="text-xs text-red-400">No paths configured</div>
<div class="no-paths-error">No paths configured</div>
}
</td>
<td class="text-[var(--primary-color)] text-center"></td>
<td class="arrow-cell"></td>
<td>
<div class="overflow-hidden text-ellipsis">
<span class="text-gray-400">{{ preview.targetLibraryName }}/</span><span class="text-gray-200">{{ preview.relativeNewPath }}</span>
<div class="path-cell">
<span class="path-prefix">{{ preview.targetLibraryName }}/</span><span class="path-value">{{ preview.relativeNewPath }}</span>
</div>
</td>
<td class="text-center">
<td class="status-cell">
@if (preview.isMoved) {
<i class="pi pi-check-circle text-green-600"></i>
<i class="pi pi-check-circle icon-success"></i>
}
</td>
</tr>
@@ -191,10 +191,10 @@
<div class="dialog-footer">
@if (movedFileCount > 0) {
<span class="text-green-500 flex items-center font-semibold drop-shadow-sm select-none">
<span class="success-message">
{{ movedFileCount }} file{{ movedFileCount > 1 ? 's' : '' }} successfully moved
<i
class="pi pi-check-circle ml-2 text-green-500 animate-bounce"
class="pi pi-check-circle success-icon-bounce"
style="animation-duration: 1.5s;"
aria-label="Success"
role="img"

View File

@@ -40,3 +40,248 @@
:host ::ng-deep .p-datatable-tbody > tr > td {
padding: 0.5rem 1rem !important;
}
// Preview info box
.preview-info-box {
padding: 0.75rem 1rem;
margin: 0 0.5rem 1rem;
border-radius: 0.5rem;
border: 1px solid var(--border-color);
transition: all 0.2s ease;
}
.preview-title {
color: var(--primary-color);
font-weight: 600;
margin-bottom: 0.25rem;
}
.preview-description {
font-size: 0.875rem;
color: #d1d5db;
> * + * {
margin-top: 0.75rem;
}
}
.highlight-green {
font-weight: 600;
color: #4ade80;
}
.icon-blue {
color: #60a5fa;
margin-right: 0.25rem;
}
.text-blue {
color: #93c5fd;
}
.warning-text {
color: #eab308;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
}
.icon-yellow {
color: #facc15;
}
.metadata-warning-box {
background: rgba(127, 29, 29, 0.3);
border: 1px solid rgba(239, 68, 68, 0.5);
border-radius: 0.375rem;
padding: 0.75rem;
color: #fecaca;
font-size: 0.75rem;
}
.metadata-warning-title {
font-weight: 700;
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.metadata-warning-emphasis {
margin-top: 0.25rem;
font-weight: 600;
}
// Patterns section
.patterns-section {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
padding: 0 0.5rem;
align-items: center;
width: 100%;
}
.patterns-content {
flex: 1;
}
.patterns-toggle {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
}
.patterns-list {
margin-top: 0.5rem;
> * + * {
margin-top: 0.5rem;
}
}
.pattern-card {
padding: 0.75rem;
border: 1px solid #3f3f46;
border-radius: 0.5rem;
background: #27272a;
}
.pattern-card-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.pattern-info {
flex: 1;
}
.pattern-label {
font-size: 0.75rem;
color: #9ca3af;
margin-bottom: 0.25rem;
}
.pattern-code {
color: var(--primary-color);
font-family: monospace;
font-size: 0.875rem;
}
.pattern-source {
font-size: 0.75rem;
color: #9ca3af;
margin-top: 0.25rem;
}
.pattern-badge {
font-size: 0.75rem;
background: #2563eb;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
margin-left: 0.5rem;
}
// Bulk select section
.bulk-select-section {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
padding: 0 0.5rem;
align-items: center;
}
.bulk-select-controls {
display: flex;
align-items: center;
gap: 0.75rem;
}
.bulk-select-label {
font-size: 0.875rem;
font-weight: 500;
color: #d1d5db;
}
// Table section
.table-container {
margin-bottom: 1rem;
height: 500px;
}
.path-cell {
overflow: hidden;
text-overflow: ellipsis;
}
.path-prefix {
color: #9ca3af;
}
.path-value {
color: #e5e7eb;
}
.no-paths-error {
font-size: 0.75rem;
color: #f87171;
}
.arrow-cell {
color: var(--primary-color);
text-align: center;
}
.status-cell {
text-align: center;
}
.icon-success {
color: #16a34a;
}
// Footer
.success-message {
color: #22c55e;
display: flex;
align-items: center;
font-weight: 600;
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.05));
user-select: none;
}
.success-icon-bounce {
margin-left: 0.5rem;
color: #22c55e;
animation: bounce 1s infinite;
}
@keyframes bounce {
0%, 100% {
transform: translateY(-25%);
animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
}
50% {
transform: translateY(0);
animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
}
}
// Select widths
:host ::ng-deep {
.select-library {
min-width: 200px;
}
.select-path {
min-width: 250px;
}
.select-full-width {
width: 100%;
}
}

View File

@@ -1,11 +1,11 @@
<div class="flex flex-col p-4 space-y-2 live-border">
<div class="flex flex-row items-center gap-2">
<div class="notification-container live-border">
<div class="notification-header">
@if (latestNotification.severity) {
<app-tag [color]="getSeverityColor(latestNotification.severity)" size="3xs" class="self-center">
<app-tag [color]="getSeverityColor(latestNotification.severity)" size="3xs" class="tag-centered">
{{ latestNotification.severity }}
</app-tag>
}
<span class="text-xs text-zinc-400 self-center">{{ latestNotification.timestamp }}</span>
<span class="notification-timestamp">{{ latestNotification.timestamp }}</span>
</div>
<p class="font-normal text-zinc-200">{{ latestNotification.message }}</p>
<p class="notification-message">{{ latestNotification.message }}</p>
</div>

View File

@@ -3,3 +3,33 @@
border: 1px solid var(--primary-color);
border-radius: 0.5rem;
}
.notification-container {
display: flex;
flex-direction: column;
padding: 1rem;
gap: 0.5rem;
}
.notification-header {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.tag-centered {
align-self: center;
}
.notification-timestamp {
font-size: 0.75rem;
color: #a1a1aa;
align-self: center;
}
.notification-message {
font-weight: 400;
color: #e4e4e7;
margin: 0;
}

View File

@@ -1,39 +1,39 @@
<div class="task-border mt-4">
<div class="task-border">
@if (Object.keys(activeTasks).length > 0) {
@for (task of activeTasks | keyvalue; track task; let idx = $index) {
<div class="task-card p-4">
<div class="task-card">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<p class="text-sm font-semibold text-[var(--primary-color)]">Metadata Fetch Task</p>
<div class="task-header-row">
<div class="task-title-group">
<p class="task-title">Metadata Fetch Task</p>
</div>
<div class="flex items-center gap-1">
<div class="task-tags">
@if (task.value.review) {
<p-tag
value="Review"
severity="warn"
class="text-xs">
styleClass="task-tag">
</p-tag>
} @else {
<p-tag
value="Auto"
severity="success"
class="text-xs">
styleClass="task-tag">
</p-tag>
}
<p-tag
[value]="getStatusLabel(task.value.status)"
[severity]="getTagSeverity(task.value.status)"
class="text-xs"
styleClass="task-tag"
></p-tag>
</div>
</div>
<div class="task-message text-gray-200">
<div class="task-message">
{{ task.value.message }}
</div>
<div class="task-progress-info text-sm text-gray-300">
<div class="task-progress-info">
Book <strong>{{ task.value.status === 'COMPLETED' || task.value.completed >= task.value.total ? task.value.total : task.value.completed + 1 }}</strong>
of {{ task.value.total }}
</div>
@@ -45,7 +45,7 @@
</p-progressBar>
@if (task.value.status === 'COMPLETED' || task.value.status === 'ERROR' || task.value.status === 'CANCELLED') {
<div class="action-buttons mt-4 flex justify-end gap-2">
<div class="action-buttons">
@if (task.value.review) {
<p-button
label="Review"
@@ -69,7 +69,7 @@
</p-button>
</div>
} @else if (task.value.status === 'IN_PROGRESS') {
<div class="action-buttons mt-4 flex justify-end gap-2">
<div class="action-buttons">
<p-button
label="Cancel"
icon="pi pi-stop"

View File

@@ -1,9 +1,44 @@
.task-border {
margin-top: 1rem;
background: var(--card-background);
border: 1px solid var(--primary-color);
border-radius: 0.5rem;
}
.task-card {
padding: 1rem;
}
.task-header-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.task-title-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.task-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--primary-color);
margin: 0;
}
.task-tags {
display: flex;
align-items: center;
gap: 0.25rem;
}
:host ::ng-deep .task-tag {
font-size: 0.75rem;
}
.task-header {
display: flex;
justify-content: flex-end;
@@ -12,16 +47,23 @@
.task-message {
font-size: 1rem;
color: var(--text-color);
color: #e5e7eb;
margin-bottom: 0.5rem;
}
.task-progress-info {
font-size: 0.875rem;
color: var(--text-secondary-color);
color: #d1d5db;
margin-bottom: 0.5rem;
}
.action-buttons {
margin-top: 1rem;
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.review-button {
text-align: right;
margin-top: 0.75rem;

View File

@@ -1,4 +1,4 @@
<div class="metadata-progress-box flex gap-4 flex-col w-[25rem] max-h-[60vh] overflow-y-auto">
<div class="metadata-progress-box">
<app-live-notification-box/>
@if (hasMetadataTasks$ | async) {
<app-metadata-progress-widget/>

View File

@@ -1,4 +1,9 @@
.metadata-progress-box {
display: flex;
flex-direction: column;
gap: 1rem;
width: 25rem;
max-height: 60vh;
overflow-y: auto;
border-radius: 0.5rem;
}

View File

@@ -1,4 +1,4 @@
<div class="flex flex-col h-full">
<div class="menu-container">
<div>
<ul class="layout-menu">
@if (homeMenu$ | async; as homeMenu) {
@@ -42,26 +42,26 @@
</div>
@if (versionInfo) {
<div style="margin-top: auto;" class="p-4 text-center w-full text-sm">
<div class="text-gray-200">
<div class="version-info" style="margin-top: auto;">
<div class="version-text">
@if (isSemanticVersion(versionInfo.current)) {
<a
[href]="getVersionUrl(versionInfo.current)"
target="_blank"
rel="noopener noreferrer"
class="hover:underline hover:text-gray-100 text-sm"
class="version-link"
>
{{ versionInfo.current }}
</a>
} @else {
<span class="text-sm">{{ versionInfo.current }}</span>
<span class="version-label">{{ versionInfo.current }}</span>
}
</div>
@if (isSemanticVersion(versionInfo.current) && versionInfo.latest && versionInfo.latest !== versionInfo.current) {
<div>
<a
(click)="openChangelogDialog()"
class="cursor-pointer text-orange-400 hover:underline hover:text-orange-300 text-xs"
class="update-link"
>
(Update)
</a>

View File

@@ -0,0 +1,40 @@
.menu-container {
display: flex;
flex-direction: column;
height: 100%;
}
.version-info {
padding: 1rem;
text-align: center;
width: 100%;
font-size: 0.875rem;
}
.version-text {
color: #e5e7eb;
}
.version-link {
font-size: 0.875rem;
&:hover {
text-decoration: underline;
color: #f3f4f6;
}
}
.version-label {
font-size: 0.875rem;
}
.update-link {
cursor: pointer;
color: #fb923c;
font-size: 0.75rem;
&:hover {
text-decoration: underline;
color: #fdba74;
}
}

View File

@@ -20,6 +20,7 @@ import {DialogLauncherService} from '../../../services/dialog-launcher.service';
standalone: true,
imports: [AppMenuitemComponent, MenuModule, AsyncPipe],
templateUrl: './app.menu.component.html',
styleUrl: './app.menu.component.scss',
})
export class AppMenuComponent implements OnInit {
libraryMenu$: Observable<MenuItem[]> | undefined;

View File

@@ -39,7 +39,7 @@
{{ release.name }} ({{ release.publishedAt | date: 'mediumDate' }})
</a>
</p>
<div [innerHTML]="markdownToHtml(release.changelog)" class="prose prose-invert w-full max-w-full"></div>
<div [innerHTML]="markdownToHtml(release.changelog)" class="release-content"></div>
</div>
}
</div>

View File

@@ -59,6 +59,81 @@
}
}
:host ::ng-deep .prose h3 {
margin-top: 0;
.release-content {
width: 100%;
max-width: 100%;
line-height: 1.75;
color: var(--text-color);
:host ::ng-deep & {
h1, h2, h3, h4, h5, h6 {
margin-top: 1.5rem;
margin-bottom: 0.75rem;
font-weight: 600;
line-height: 1.25;
color: var(--text-color);
&:first-child {
margin-top: 0;
}
}
h3 {
margin-top: 0;
font-size: 1.125rem;
}
p {
margin-top: 0.75rem;
margin-bottom: 0.75rem;
}
ul, ol {
margin-top: 0.75rem;
margin-bottom: 0.75rem;
padding-left: 1.5rem;
}
li {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
code {
font-size: 0.875em;
background: var(--surface-ground);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
}
pre {
margin-top: 1rem;
margin-bottom: 1rem;
padding: 1rem;
background: var(--surface-ground);
border-radius: 0.5rem;
overflow-x: auto;
code {
background: transparent;
padding: 0;
}
}
a {
color: var(--primary-color);
text-decoration: underline;
&:hover {
text-decoration: none;
}
}
blockquote {
margin: 1rem 0;
padding-left: 1rem;
border-left: 3px solid var(--border-color);
color: var(--text-secondary-color);
}
}
}

View File

@@ -1,16 +1,16 @@
<div class="layout-topbar">
<a class="layout-topbar-logo pl-5 pr-24 flex gap-2" routerLink="">
<svg class="w-[1.875rem] h-[1.875rem] half-title" viewBox="0 0 126 126" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<a class="layout-topbar-logo topbar-logo" routerLink="">
<svg class="logo-icon half-title" viewBox="0 0 126 126" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path
d="M59 4.79297C71.5051 11.5557 80 24.7854 80 40C80 40.5959 79.987 41.1888 79.9609 41.7783C79.8609 44.0406 81.7355 46 84 46C106.091 46 124 63.9086 124 86C124 108.091 106.091 126 84 126H10C4.47715 126 0 121.523 0 116V39.0068L0.0126953 38.9941C0.357624 25.0252 7.86506 12.8347 19 5.95215V63.832C19 64.8345 20.0676 65.4391 20.9121 64.9902L21.0771 64.8867L38.2227 52.3428C38.6819 52.0068 39.3064 52.0068 39.7656 52.3428L56.9229 64.8945L57.0879 64.998C57.9324 65.447 59 64.8423 59 63.8398V4.79297Z"/>
<path
d="M40 0C43.8745 0 47.6199 0.552381 51.1631 1.58008V50.9697L44.3926 46.0176L44.0879 45.8037C40.9061 43.6679 36.7098 43.7393 33.5957 46.0176L26.8369 50.9619V2.21875C30.9593 0.782634 35.3881 0 40 0Z"
fill="white"/>
</svg>
<span class="flex items-center pt-2">
<span class="logo-text-wrapper">
<p>Book</p>
<p class="half-title text-3xl">lore</p>
<p class="half-title logo-text-lore">lore</p>
</span>
</a>
@@ -25,29 +25,29 @@
>
</p-button>
<div class="flex items-center w-full gap-4">
<app-book-searcher class="md:block flex-grow"></app-book-searcher>
<ul class="topbar-items hidden md:flex items-center gap-3 ml-auto pl-4">
<div class="flex gap-4">
<div class="topbar-content">
<app-book-searcher class="topbar-search"></app-book-searcher>
<ul class="topbar-items topbar-desktop-items">
<div class="topbar-action-group">
@if (userService.userState$ | async; as userState) {
@if (userState.user?.permissions?.canAccessBookdrop || userState.user?.permissions?.admin) {
<li>
<a class="topbar-item" (click)="navigateToBookdrop()" pTooltip="Bookdrop" tooltipPosition="bottom">
<i class="pi pi-inbox text-surface-100"></i>
<i class="pi pi-inbox topbar-icon"></i>
</a>
</li>
}
@if (userState.user?.permissions?.canManageLibrary || userState.user?.permissions?.admin) {
<li>
<a class="topbar-item" (click)="openLibraryCreatorDialog()" pTooltip="Create New Library" tooltipPosition="bottom">
<i class="pi pi-plus-circle text-surface-100"></i>
<i class="pi pi-plus-circle topbar-icon"></i>
</a>
</li>
}
@if (userState.user?.permissions?.canUpload || userState.user?.permissions?.admin) {
<li>
<a class="topbar-item" (click)="openFileUploadDialog()" pTooltip="Upload Book" tooltipPosition="bottom">
<i class="pi pi-upload text-surface-100"></i>
<i class="pi pi-upload topbar-icon"></i>
</a>
</li>
}
@@ -60,14 +60,14 @@
[pTooltip]="statsTooltip"
tooltipPosition="bottom">
<svg class="multi-color-chart-icon" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.25" y="10" width="2.5" height="9" rx="0.5" fill="#ef4444"/>
<rect x="6.25" y="7" width="2.5" height="12" rx="0.5" fill="#f59e0b"/>
<rect x="11.25" y="3" width="2.5" height="16" rx="0.5" fill="#10b981"/>
<rect x="16.25" y="5" width="2.5" height="14" rx="0.5" fill="#3b82f6"/>
<rect x="1.25" y="10" width="2.5" height="9" rx="0.5" fill="#ef4444"/>
<rect x="6.25" y="7" width="2.5" height="12" rx="0.5" fill="#f59e0b"/>
<rect x="11.25" y="3" width="2.5" height="16" rx="0.5" fill="#10b981"/>
<rect x="16.25" y="5" width="2.5" height="14" rx="0.5" fill="#3b82f6"/>
</svg>
</button>
@if (shouldShowStatsMenu) {
<p-menu #statsMenu [model]="statsMenuItems" [popup]="true" appendTo="body" />
<p-menu #statsMenu [model]="statsMenuItems" [popup]="true" appendTo="body"/>
}
</li>
}
@@ -75,14 +75,14 @@
@if (userState.user?.permissions?.canManageLibrary || userState.user?.permissions?.admin) {
<li>
<a class="topbar-item" (click)="navigateToMetadataManager()" pTooltip="Metadata Manager" tooltipPosition="bottom">
<i class="pi pi-sparkles text-surface-100"></i>
<i class="pi pi-sparkles topbar-icon"></i>
</a>
</li>
}
}
<li>
<a class="topbar-item" (click)="navigateToSettings()" pTooltip="Settings" tooltipPosition="bottom">
<i class="pi pi-cog text-surface-100"></i>
<i class="pi pi-cog topbar-icon"></i>
</a>
</li>
</div>
@@ -90,8 +90,8 @@
<p-divider layout="vertical"/>
<div class="flex gap-4">
<li class="relative">
<div class="topbar-action-group">
<li class="topbar-item-relative">
<button
type="button"
class="topbar-item"
@@ -116,15 +116,15 @@
</p-popover>
</li>
<li class="relative group overflow-hidden !border-transparent topbar-item" (click)="openGithubSupportDialog()">
<li class="topbar-item-relative heart-button topbar-item" (click)="openGithubSupportDialog()">
<span
style="animation-duration: 2s; background: conic-gradient(from 90deg, #f97316, #f59e0b, #eab308, #84cc16, #22c55e, #10b981, #14a8a6, #06b6d4, #0ea5e9, #3b82f6, #6366f1, #8b5cf6, #a855f7, #d946ef, #ec4899, #f43f5e)"
class="absolute -top-5 -left-5 w-20 h-20 animate-spin">
class="heart-spinner">
</span>
<span style="inset: 1px; border-radius: 4px" class="absolute z-2 bg-surface-900 transition-all"></span>
<i class="pi pi-heart z-10"></i>
<span class="heart-background"></span>
<i class="pi pi-heart heart-icon"></i>
</li>
<li class="relative">
<li class="topbar-item-relative">
<button type="button"
class="topbar-item config-item"
enterActiveClass="animate-scalein"
@@ -138,7 +138,7 @@
</li>
</div>
<p-divider layout="vertical"/>
<div class="flex gap-4">
<div class="topbar-action-group">
<li>
<a class="topbar-item"
href="https://booklore.org/docs/getting-started"
@@ -146,7 +146,7 @@
rel="noopener noreferrer"
pTooltip="Documentation"
tooltipPosition="bottom">
<i class="pi pi-info-circle text-surface-100"></i>
<i class="pi pi-info-circle topbar-icon"></i>
</a>
</li>
@@ -155,7 +155,7 @@
@if (!userState.user?.permissions?.demoUser) {
<li>
<button class="topbar-item" (click)="openUserProfileDialog()" pTooltip="Profile" tooltipPosition="bottom">
<i class="pi pi-user text-surface-100"></i>
<i class="pi pi-user topbar-icon"></i>
</button>
</li>
}
@@ -163,28 +163,28 @@
<li>
<button class="topbar-item" (click)="logout()" pTooltip="Logout" tooltipPosition="left">
<i class="pi pi-sign-out text-surface-100"></i>
<i class="pi pi-sign-out topbar-icon"></i>
</button>
</li>
</div>
</ul>
<!-- Trigger Button -->
<div class="md:hidden relative ml-auto">
<div class="mobile-trigger">
<p-button
icon="pi pi-ellipsis-v"
outlined
(click)="mobileMenu.toggle($event)"
/>
<p-popover #mobileMenu>
<ul class="flex flex-col gap-1 w-48">
<ul class="mobile-menu-list">
@if (userService.userState$ | async; as userState) {
@if (userState.user?.permissions?.canAccessBookdrop || userState.user?.permissions?.admin) {
<li>
<button
class="flex items-center gap-2 w-full text-left p-2 hover:bg-surface-200 dark:hover:bg-surface-700 rounded"
class="mobile-menu-item"
(click)="navigateToBookdrop(); mobileMenu.hide()"
>
<i class="pi pi-inbox text-surface-100"></i>
<i class="pi pi-inbox topbar-icon"></i>
Bookdrop
</button>
</li>
@@ -192,10 +192,10 @@
@if (userState.user?.permissions?.canManageLibrary || userState.user?.permissions?.admin) {
<li>
<button
class="flex items-center gap-2 w-full text-left p-2 hover:bg-surface-200 dark:hover:bg-surface-700 rounded"
class="mobile-menu-item"
(click)="openLibraryCreatorDialog(); mobileMenu.hide()"
>
<i class="pi pi-plus-circle text-surface-100"></i>
<i class="pi pi-plus-circle topbar-icon"></i>
Create Library
</button>
</li>
@@ -203,10 +203,10 @@
@if (userState.user?.permissions?.canUpload || userState.user?.permissions?.admin) {
<li>
<button
class="flex items-center gap-2 w-full text-left p-2 hover:bg-surface-200 dark:hover:bg-surface-700 rounded"
class="mobile-menu-item"
(click)="openFileUploadDialog(); mobileMenu.hide()"
>
<i class="pi pi-upload text-surface-100"></i>
<i class="pi pi-upload topbar-icon"></i>
Upload Book
</button>
</li>
@@ -215,7 +215,7 @@
@if (hasStatsAccess) {
<li>
<button
class="flex items-center gap-2 w-full text-left p-2 hover:bg-surface-200 dark:hover:bg-surface-700 rounded"
class="mobile-menu-item"
(click)="shouldShowStatsMenu ? statsMenuMobile.toggle($event) : handleStatsButtonClick($event)"
>
<svg class="multi-color-chart-icon" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -227,7 +227,7 @@
Charts
</button>
@if (shouldShowStatsMenu) {
<p-menu #statsMenuMobile [model]="statsMenuItems" [popup]="true" />
<p-menu #statsMenuMobile [model]="statsMenuItems" [popup]="true"/>
}
</li>
}
@@ -235,10 +235,10 @@
@if (userState.user?.permissions?.canManageLibrary || userState.user?.permissions?.admin) {
<li>
<button
class="flex items-center gap-2 w-full text-left p-2 hover:bg-surface-200 dark:hover:bg-surface-700 rounded"
class="mobile-menu-item"
(click)="navigateToMetadataManager(); mobileMenu.hide()"
>
<i class="pi pi-sparkles text-surface-100"></i>
<i class="pi pi-sparkles topbar-icon"></i>
Metadata Manager
</button>
</li>
@@ -246,29 +246,29 @@
}
<li>
<button
class="flex items-center gap-2 w-full text-left p-2 hover:bg-surface-200 dark:hover:bg-surface-700 rounded"
class="mobile-menu-item"
(click)="navigateToSettings(); mobileMenu.hide()"
>
<i class="pi pi-cog text-surface-100"></i>
<i class="pi pi-cog topbar-icon"></i>
Settings
</button>
</li>
<li>
<a
class="flex items-center gap-2 w-full text-left p-2 hover:bg-surface-200 dark:hover:bg-surface-700 rounded"
class="mobile-menu-item"
href="https://booklore.org/docs/getting-started"
target="_blank"
rel="noopener noreferrer">
<i class="pi pi-info-circle text-surface-100"></i>
<i class="pi pi-info-circle topbar-icon"></i>
Documentation
</a>
</li>
<li>
<button
class="flex items-center gap-2 w-full text-left p-2 hover:bg-surface-200 dark:hover:bg-surface-700 rounded"
class="mobile-menu-item"
(click)="openGithubSupportDialog(); mobileMenu.hide()"
>
<i class="pi pi-thumbs-up text-surface-100"></i>
<i class="pi pi-thumbs-up topbar-icon"></i>
Support BookLore
</button>
</li>
@@ -277,10 +277,10 @@
@if (!userState.user?.permissions?.demoUser) {
<li>
<button
class="flex items-center gap-2 w-full text-left p-2 hover:bg-surface-200 dark:hover:bg-surface-700 rounded"
class="mobile-menu-item"
(click)="openUserProfileDialog(); mobileMenu.hide()"
>
<i class="pi pi-user text-surface-100"></i>
<i class="pi pi-user topbar-icon"></i>
Profile
</button>
</li>
@@ -289,10 +289,10 @@
<li>
<button
class="flex items-center gap-2 w-full text-left p-2 hover:bg-surface-200 dark:hover:bg-surface-700 rounded"
class="mobile-menu-item"
(click)="logout(); mobileMenu.hide()"
>
<i class="pi pi-sign-out text-surface-100"></i>
<i class="pi pi-sign-out topbar-icon"></i>
Logout
</button>
</li>

View File

@@ -1,3 +1,138 @@
.topbar-logo {
padding-left: 1.25rem;
padding-right: 6rem;
display: flex;
gap: 0.5rem;
}
.logo-icon {
width: 1.875rem;
height: 1.875rem;
}
.logo-text-wrapper {
display: flex;
align-items: center;
padding-top: 0.5rem;
p {
margin: 0;
}
}
.logo-text-lore {
font-size: 1.875rem;
line-height: 2.25rem;
}
.topbar-content {
display: flex;
align-items: center;
width: 100%;
gap: 1rem;
}
.topbar-search {
flex-grow: 1;
@media (max-width: 768px) {
display: block;
}
@media (min-width: 768px) {
display: block;
}
}
.topbar-desktop-items {
display: none;
align-items: center;
gap: 0.75rem;
margin-left: auto;
padding-left: 1rem;
@media (min-width: 768px) {
display: flex;
}
}
.topbar-action-group {
display: flex;
gap: 1rem;
}
.topbar-icon {
color: var(--p-surface-100);
}
.topbar-item-relative {
position: relative;
}
.heart-button {
overflow: hidden;
border-color: transparent !important;
}
.heart-spinner {
position: absolute;
top: -1.25rem;
left: -1.25rem;
width: 5rem;
height: 5rem;
animation: spin 2s linear infinite;
}
.heart-background {
position: absolute;
z-index: 2;
inset: 1px;
border-radius: 4px;
background: var(--p-surface-900);
transition: all 0.2s;
}
.heart-icon {
z-index: 10;
}
.mobile-trigger {
display: none;
position: relative;
margin-left: auto;
@media (max-width: 767px) {
display: block;
}
}
.mobile-menu-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
width: 12rem;
}
.mobile-menu-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
text-align: left;
padding: 0.5rem;
border-radius: 0.25rem;
background: none;
border: none;
color: inherit;
font: inherit;
cursor: pointer;
text-decoration: none;
&:hover {
background: var(--p-surface-700);
}
}
.highlight {
color: red !important;
}

View File

@@ -1,4 +1,4 @@
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {TestBed} from '@angular/core/testing';
import {EnvironmentInjector, runInInjectionContext} from '@angular/core';
import {HttpClient} from '@angular/common/http';

View File

@@ -1,4 +1,4 @@
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {TestBed} from '@angular/core/testing';
import {HttpClient} from '@angular/common/http';
import {of} from 'rxjs';
@@ -139,16 +139,17 @@ describe('AuthService', () => {
it('should logout and clear all tokens', () => {
service.getRxStompService = vi.fn().mockReturnValue(rxStompServiceMock);
const loc = {href: ''};
vi.stubGlobal('window', {location: loc});
routerMock.navigate.mockReturnValue(Promise.resolve(true));
service.logout();
expect(localStorage.removeItem).toHaveBeenCalledWith('accessToken_Internal');
expect(localStorage.removeItem).toHaveBeenCalledWith('refreshToken_Internal');
expect(oAuthStorageMock.removeItem).toHaveBeenCalledWith('access_token');
expect(oAuthStorageMock.removeItem).toHaveBeenCalledWith('refresh_token');
expect(oAuthStorageMock.removeItem).toHaveBeenCalledWith('id_token');
expect(rxStompServiceMock.deactivate).toHaveBeenCalled();
expect(loc.href).toBe('/login');
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
});
it('should initialize websocket connection if token exists', () => {

View File

@@ -99,7 +99,9 @@ export class AuthService {
this.tokenSubject.next(null);
this.postLoginInitialized = false;
this.getRxStompService().deactivate();
window.location.href = '/login';
this.router.navigate(['/login']).then(() => {
window.location.reload();
});
}
getRxStompService(): RxStompService {

View File

@@ -11,6 +11,8 @@ describe('CustomFontService', () => {
let service: CustomFontService;
let httpClientMock: any;
let authServiceMock: any;
let fontsSet: any[];
let originalDocumentFonts: any;
const mockFont: CustomFont = {
id: 1,
@@ -41,19 +43,25 @@ describe('CustomFontService', () => {
this.load = vi.fn().mockResolvedValue(this);
};
const fontsSet: any[] = [];
(globalThis as any).document = {
fonts: {
add: vi.fn(font => fontsSet.push(font)),
delete: vi.fn(font => {
// Store original and create mock fonts set
originalDocumentFonts = document.fonts;
fontsSet = [];
// Mock document.fonts
Object.defineProperty(document, 'fonts', {
value: {
add: vi.fn((font: any) => fontsSet.push(font)),
delete: vi.fn((font: any) => {
const idx = fontsSet.indexOf(font);
if (idx !== -1) fontsSet.splice(idx, 1);
}),
[Symbol.iterator]: function* () {
yield* fontsSet;
}
}
};
},
writable: true,
configurable: true
});
TestBed.configureTestingModule({
providers: [
@@ -109,17 +117,17 @@ describe('CustomFontService', () => {
it('should mark font as loaded after loadFontFace', async () => {
await service.loadFontFace(mockFont);
expect(service.isFontLoaded(mockFont.fontName)).toBe(true);
const fontsArr = Array.from((globalThis as any).document.fonts);
const fontsArr = Array.from(document.fonts);
expect(fontsArr.some((f: any) => f.family === mockFont.fontName)).toBe(true);
});
it('should call removeFontFace on delete', () => {
const fontObj = {family: mockFont.fontName};
(globalThis as any).document.fonts.add(fontObj);
const fontObj = new (globalThis as any).FontFace(mockFont.fontName, 'url(test.ttf)');
document.fonts.add(fontObj);
service['fontsSubject'].next([mockFont]);
httpClientMock.delete.mockReturnValue(of(void 0));
service.deleteFont(mockFont.id).subscribe(() => {
const fontsArr = Array.from((globalThis as any).document.fonts);
const fontsArr = Array.from(document.fonts);
expect(fontsArr.some((f: any) => f.family === mockFont.fontName)).toBe(false);
});
});

Some files were not shown because too many files have changed in this diff Show More