mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
Merge pull request #2435 from booklore-app/develop
Merge develop into master for release
This commit is contained in:
@@ -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());
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
1870
booklore-ui/package-lock.json
generated
1870
booklore-ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -250,3 +250,11 @@
|
||||
padding-left: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -65,6 +65,8 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.info-btn,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 All"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
@use '../../../../shared/styles/panel-shared' as panel;
|
||||
|
||||
.dashboard-settings {
|
||||
width: 1000px;
|
||||
max-width: 1000px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -848,3 +848,7 @@
|
||||
.validation-message {
|
||||
@include panel.validation-message;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -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$"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -680,3 +680,8 @@
|
||||
::ng-deep .p-inputchips {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.navigation-position {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary-color);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -497,3 +497,7 @@
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.full-width-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -169,3 +169,16 @@
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.settings-container {
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--p-content-border-color);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user