fix: Bookdrop UI mobile support (#1911)

* fix: Bookdrop UI mobile support

* fix: Wrap long select option items in mobile mode
This commit is contained in:
Muppetteer
2025-12-17 15:28:26 +11:00
committed by GitHub
parent 80249b17aa
commit 0a5f12f38c
6 changed files with 666 additions and 362 deletions

View File

@@ -2,9 +2,9 @@
<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="w-[12%]"></label>
<label class="md:w-[8rem]"></label>
<div class="flex w-full items-center">
<p class="!w-1/2 pr-3" style="text-align:right">Current Metadata</p>
<p class="w-1/2 pr-3" style="text-align:right">File Metadata</p>
<div class="midbuttons">
<p-button
size="small"
@@ -12,7 +12,7 @@
icon="pi pi-angle-left"
class="mx-2"
[outlined]="true"
pTooltip="Move all missing fields"
pTooltip="Copy missing fields from fetched metadata"
tooltipPosition="bottom"
(onClick)="copyMissing()"
></p-button>
@@ -22,32 +22,31 @@
icon="pi pi-angle-double-left"
class="mx-2"
[outlined]="true"
pTooltip="Move all fields"
pTooltip="Overwrite all fields with fetched metadata"
tooltipPosition="bottom"
(onClick)="copyAll()"
></p-button>
</div>
<p class="!w-1/2 pl-3">Fetched Metadata</p>
<p class="w-1/2 pl-3">Fetched</p>
</div>
<div class="ml-auto absolute" style="right:1rem">
<p-button
size="small"
severity="warn"
icon="pi pi-refresh"
label="Reset"
[outlined]="true"
pTooltip="Reset all fields to original values"
tooltipPosition="bottom"
tooltipPosition="left"
(onClick)="confirmReset()"
></p-button>
</div>
</div>
<div class="metacontent">
<div class="flex items-center py-1">
<label class="w-[12%] text-sm">Cover</label>
<div class="meta-row flex items-center">
<label class="md:w-[8rem] text-sm">Cover</label>
<div class="flex w-full items-center justify-center">
<p-image class="thumbnail" [src]="metadataForm.get('thumbnailUrl')?.value" alt="Image" appendTo="body" lazyLoad [preview]="true"></p-image>
<input type="hidden" id="thumbnailUrl" formControlName="thumbnailUrl" class="!w-1/2"/>
<input type="hidden" id="thumbnailUrl" formControlName="thumbnailUrl" class="md:!w-1/2"/>
<p-button
size="small"
[icon]="isValueSaved('thumbnailUrl') ? 'pi pi-check' : (hoveredFields['thumbnailUrl'] && isValueCopied('thumbnailUrl') ? 'pi pi-times' : 'pi pi-arrow-left')"
@@ -61,24 +60,24 @@
(click)="hoveredFields['thumbnailUrl'] && isValueCopied('thumbnailUrl') ? resetField('thumbnailUrl') : copyFetchedToCurrent('thumbnailUrl')"
(mouseenter)="onMouseEnter('thumbnailUrl')"
(mouseleave)="onMouseLeave('thumbnailUrl')"/>
<input type="hidden" [value]="fetchedMetadata.thumbnailUrl" class="!w-1/2" readonly/>
<input type="hidden" [value]="fetchedMetadata.thumbnailUrl" class="md:!w-1/2" readonly/>
<p-image class="thumbnail" [src]="fetchedMetadata.thumbnailUrl!" alt="Image" appendTo="body" lazyLoad [preview]="true"></p-image>
</div>
</div>
@for (field of metadataFieldsTop; track field) {
<div class="flex items-center py-1">
<label for="{{field.controlName}}" class="w-[12%] text-sm">{{ field.label }}</label>
<div class="flex w-full">
<div class="meta-row flex items-center field-{{field.controlName}}">
<label for="{{field.controlName}}" class="md:w-[8rem] text-sm">{{ field.label }}</label>
<div class="meta-fields flex w-full">
<input
pSize="small"
fluid
pInputText
id="{{field.controlName}}"
formControlName="{{field.controlName}}"
class="!w-1/2"
class="dest md:!w-1/2"
[ngClass]="{
'outlined-input-green': isValueCopied(field.controlName) && !hoveredFields[field.controlName],
}"
'outlined-input-green': isValueCopied(field.controlName) && !hoveredFields[field.controlName],
}"
/>
<p-button
size="small"
@@ -87,20 +86,29 @@
[ngClass]="
{
'green-outlined-button': isValueCopied(field.controlName) && !hoveredFields[field.controlName],
'red-outlined-button': isValueCopied(field.controlName) && hoveredFields[field.controlName]
'red-outlined-button': isValueCopied(field.controlName) && hoveredFields[field.controlName],
'notneeded' : !fetchedMetadata[field.fetchedKey]
}"
class="arrow-button"
(click)="hoveredFields[field.controlName] && isValueCopied(field.controlName) ? resetField(field.controlName) : copyFetchedToCurrent(field.controlName)"
(mouseenter)="onMouseEnter(field.controlName)"
(mouseleave)="onMouseLeave(field.controlName)"/>
<input pSize="small" pInputText [value]="fetchedMetadata[field.fetchedKey] ?? null" class="!w-1/2" readonly/>
<input
pSize="small"
pInputText
[value]="fetchedMetadata[field.fetchedKey] ?? null"
class="src md:!w-1/2"
disabled
[ngClass]="{
'notneeded': !fetchedMetadata[field.fetchedKey],
}"/>
</div>
</div>
}
@for (field of metadataChips; track field) {
<div class="flex items-center py-1">
<label for="{{field.controlName}}" class="w-[12%] text-sm">{{ field.label }}</label>
<div class="flex w-full items-center">
<div class="meta-row flex items-center field-{{field.controlName}}">
<label for="{{field.controlName}}" class="md:w-[8rem] text-sm">{{ field.label }}</label>
<div class="meta-fields flex w-full items-center">
<p-autoComplete
size="small"
formControlName="{{field.controlName}}"
@@ -108,7 +116,7 @@
[typeahead]="false"
[dropdown]="false"
[forceSelection]="false"
class="w-full"
class="dest w-full"
[ngClass]="{'outlined-input-green': isValueCopied(field.controlName) && !hoveredFields[field.controlName]}"
(onBlur)="onAutoCompleteBlur(field.controlName, $event)"/>
<p-button
@@ -118,7 +126,8 @@
[ngClass]="
{
'green-outlined-button': isValueCopied(field.controlName) && !hoveredFields[field.controlName],
'red-outlined-button': isValueCopied(field.controlName) && hoveredFields[field.controlName]
'red-outlined-button': isValueCopied(field.controlName) && hoveredFields[field.controlName],
'notneeded' : !fetchedMetadata[field.fetchedKey]
}"
class="arrow-button"
(click)="hoveredFields[field.controlName] && isValueCopied(field.controlName) ? resetField(field.controlName) : copyFetchedToCurrent(field.controlName)"
@@ -133,21 +142,24 @@
[typeahead]="false"
[dropdown]="false"
[forceSelection]="false"
class="w-full"/>
class="src w-full"
[ngClass]="{
'notneeded': !fetchedMetadata[field.fetchedKey],
}"/>
</div>
</div>
}
@for (field of metadataDescription; track field) {
<div class="flex items-center py-1">
<label for="{{field.controlName}}" class="w-[12%] text-sm">{{ field.label }}</label>
<div class="flex w-full items-center">
<div class="meta-row flex items-center field-{{field.controlName}}">
<label for="{{field.controlName}}" class="md:w-[8rem] text-sm">{{ field.label }}</label>
<div class="meta-fields flex w-full items-center">
<textarea
rows="2"
rows="4"
pSize="small"
pTextarea
id="{{field.controlName}}"
formControlName="{{field.controlName}}"
class="!w-1/2"
class="dest md:!w-1/2"
[ngClass]="{'outlined-input-green': isValueCopied(field.controlName) && !hoveredFields[field.controlName]}"
></textarea>
<p-button
@@ -157,28 +169,37 @@
[ngClass]="
{
'green-outlined-button': isValueCopied(field.controlName) && !hoveredFields[field.controlName],
'red-outlined-button': isValueCopied(field.controlName) && hoveredFields[field.controlName]
'red-outlined-button': isValueCopied(field.controlName) && hoveredFields[field.controlName],
'notneeded' : !fetchedMetadata[field.fetchedKey]
}"
class="arrow-button"
(click)="hoveredFields[field.controlName] && isValueCopied(field.controlName) ? resetField(field.controlName) : copyFetchedToCurrent(field.controlName)"
(mouseenter)="onMouseEnter(field.controlName)"
(mouseleave)="onMouseLeave(field.controlName)"/>
<textarea
rows="2"
rows="4"
pSize="small"
pInputText
[value]="fetchedMetadata[field.fetchedKey] ?? null"
class="!w-1/2"
readonly></textarea>
class="src md:!w-1/2"
disabled
[ngClass]="{
'notneeded': !fetchedMetadata[field.fetchedKey],
}"></textarea>
</div>
</div>
}
@for (field of metadataFieldsBottom; track field) {
<div class="flex items-center py-1">
<label for="{{field.controlName}}" class="w-[12%] text-sm">{{ field.label }}</label>
<div class="flex w-full">
<input pInputText pSize="small" id="{{field.controlName}}" formControlName="{{field.controlName}}" class="!w-1/2"
[ngClass]="{
<div class="meta-row flex items-cente field-{{field.controlName}}">
<label for="{{field.controlName}}" class="md:w-[8rem] text-sm">{{ field.label }}</label>
<div class="meta-fields flex w-full">
<input
pInputText
pSize="small"
id="{{field.controlName}}"
formControlName="{{field.controlName}}"
class="dest md:!w-1/2"
[ngClass]="{
'outlined-input-green': isValueCopied(field.controlName) && !hoveredFields[field.controlName],
}"
/>
@@ -189,13 +210,22 @@
[ngClass]="
{
'green-outlined-button': isValueCopied(field.controlName) && !hoveredFields[field.controlName],
'red-outlined-button': isValueCopied(field.controlName) && hoveredFields[field.controlName]
'red-outlined-button': isValueCopied(field.controlName) && hoveredFields[field.controlName],
'notneeded' : !fetchedMetadata[field.fetchedKey]
}"
class="arrow-button"
(click)="hoveredFields[field.controlName] && isValueCopied(field.controlName) ? resetField(field.controlName) : copyFetchedToCurrent(field.controlName)"
(mouseenter)="onMouseEnter(field.controlName)"
(mouseleave)="onMouseLeave(field.controlName)"/>
<input pInputText pSize="small" [value]="fetchedMetadata[field.fetchedKey] ?? null" class="!w-1/2" readonly/>
<input
pInputText
pSize="small"
[value]="fetchedMetadata[field.fetchedKey] ?? null"
class="src md:!w-1/2"
disabled
[ngClass]="{
'notneeded': !fetchedMetadata[field.fetchedKey],
}"/>
</div>
</div>
}
@@ -203,40 +233,38 @@
</div>
</form>
} @else {
<form [formGroup]="metadataForm" class="metapicker flex w-full">
<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 flex items-center" style="gap: 0.5rem">
<i class="pi metadata-status pi-exclamation-triangle not-applied"></i>
Unable to fetch new metadata for this file
Unable to fetch metadata for this file
</p>
<p-button
size="small"
severity="warn"
icon="pi pi-refresh"
label="Reset"
[outlined]="true"
pTooltip="Reset all fields to original values"
tooltipPosition="bottom"
tooltipPosition="left"
(onClick)="confirmReset()"
></p-button>
</div>
<div class="flex flex-row">
<div class="w-[25%] h-full flex items-start justify-center pl-4">
<div class="w-full h-full">
<img
<div class="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">
<img
[src]="metadataForm.get('thumbnailUrl')?.value"
alt="Book Thumbnail"
class="w-full h-full object-contain"
class="thumbnail w-full md:h-full object-contain"
/>
<input type="hidden" id="thumbnailUrl" formControlName="thumbnailUrl"/>
</div>
</div>
<div class="metacontent w-[75%]">
<div class="metacontent md:w-[80%]">
@for (field of metadataFieldsTop; track field) {
<div class="flex items-center py-1">
<label for="{{field.controlName}}" class="w-[15%] text-sm">{{ field.label }}</label>
<div class="meta-row flex items-center">
<label for="{{field.controlName}}" class="w-[8rem] text-sm">{{ field.label }}</label>
<div class="flex w-full">
<input
pSize="small"
@@ -250,8 +278,8 @@
}
@for (field of metadataChips; track field) {
<div class="flex items-center py-1">
<label for="{{field.controlName}}" class="w-[15%] text-sm">{{ field.label }}</label>
<div class="meta-row flex items-center">
<label for="{{field.controlName}}" class="w-[8rem] text-sm">{{ field.label }}</label>
<div class="flex w-full">
<p-autoComplete formControlName="{{field.controlName}}" [multiple]="true" [typeahead]="false" [dropdown]="false" [forceSelection]="false" class="w-full" (onBlur)="onAutoCompleteBlur(field.controlName, $event)"></p-autoComplete>
</div>
@@ -259,17 +287,17 @@
}
@for (field of metadataDescription; track field) {
<div class="flex items-center py-1">
<label for="{{field.controlName}}" class="w-[15%] text-sm">{{ field.label }}</label>
<div class="meta-row flex items-center">
<label for="{{field.controlName}}" class="w-[8rem] text-sm">{{ field.label }}</label>
<div class="flex w-full">
<textarea fluid rows="2" pTextarea id="{{field.controlName}}" formControlName="{{field.controlName}}"></textarea>
<textarea fluid rows="4" pSize="small" pTextarea id="{{field.controlName}}" formControlName="{{field.controlName}}"></textarea>
</div>
</div>
}
@for (field of metadataFieldsBottom; track field) {
<div class="flex items-center py-1">
<label for="{{field.controlName}}" class="w-[15%] text-sm">{{ field.label }}</label>
<div class="meta-row flex items-center">
<label for="{{field.controlName}}" class="w-[8rem] text-sm">{{ field.label }}</label>
<div class="flex w-full">
<input
fluid

View File

@@ -1,10 +1,17 @@
.thumbnail {
width: 10.46875rem;
height: 14.65625rem;
width: 10rem;
height: 14rem;
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
margin-left: 2.5rem;
margin-right: 2.5rem;
margin: 0 auto 1rem;
@media (max-width: 768px) {
max-width: 50%;
}
}
.metaeditor .thumbnail {
width: 100%;
height: auto;
}
.arrow-button {
@@ -36,14 +43,22 @@ textarea.outlined-input-green,
font-size: 12px;
}
.metapicker {
::ng-deep .p-autocomplete-input-chip {
width: 2rem;
}
.metapicker, .metaeditor {
box-sizing: border-box;
}
.metapicker label,
.metaeditor label {
color: var(--text-color-secondary);
}
.metaheader {
border-bottom: 1px solid var(--border-color);
padding: 1rem;
margin-bottom: 1rem;
justify-content: space-between;
}
@@ -52,7 +67,14 @@ textarea.outlined-input-green,
}
.metacontent {
padding: 0 1rem 1rem;
padding: 1rem;
}
.metaeditor .metacontent {
padding: 0 0 0 1rem;
@media (max-width: 768px) {
padding: 0;
}
}
.metadata-status {
@@ -67,4 +89,91 @@ textarea.outlined-input-green,
&.not-applied {
color: rgb(239, 68, 68);
}
}
.meta-row {
margin-bottom: 0.5em;
}
@media (max-width: 768px) {
.meta-row {
margin-bottom: 1em;
}
.metapicker .meta-row {
flex-wrap: wrap;
label {
width: 100%;
}
}
.metapicker .meta-fields {
justify-content: center;
.dest {
width: 50%;
order: 1;
margin-right: 0.25rem;
}
p-button {
padding: 0;
order: 3;
}
::ng-deep button {
border-left-width: 0;
border-radius: 0 6px 6px 0;
}
.src {
width: 50%;
order: 2;
border-radius: 6px 0 0 6px;
margin-left: 0.25rem;
::ng-deep ul {
border-radius: 6px 0 0 6px;
}
}
}
.metapicker {
.field-title,
.field-subtitle,
.field-publisher,
.field-authors,
.field-categories,
.field-moods,
.field-tags,
.field-description,
.field-seriesName {
.meta-fields {
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-start;
}
.dest {
width: 100% !important;
margin: 0 0 2px 0;
}
.src {
flex: 1 !important;
width: auto !important;
margin: 0;
}
}
.field-authors,
.field-categories,
.field-moods,
.field-tags {
::ng-deep button {
line-height: 2em;
}
}
}
}

View File

@@ -47,7 +47,7 @@ export class BookdropFileMetadataPickerComponent {
{label: 'Title', controlName: 'title', fetchedKey: 'title'},
{label: 'Subtitle', controlName: 'subtitle', fetchedKey: 'subtitle'},
{label: 'Publisher', controlName: 'publisher', fetchedKey: 'publisher'},
{label: 'Published', controlName: 'publishedDate', fetchedKey: 'publishedDate'}
{label: 'Publish Date', controlName: 'publishedDate', fetchedKey: 'publishedDate'}
];
metadataChips = [
@@ -62,21 +62,21 @@ export class BookdropFileMetadataPickerComponent {
];
metadataFieldsBottom = [
{label: 'Series', controlName: 'seriesName', lockedKey: 'seriesNameLocked', fetchedKey: 'seriesName'},
{label: 'Book #', controlName: 'seriesNumber', lockedKey: 'seriesNumberLocked', fetchedKey: 'seriesNumber'},
{label: 'Total Books', controlName: 'seriesTotal', lockedKey: 'seriesTotalLocked', fetchedKey: 'seriesTotal'},
{label: 'Series Name', controlName: 'seriesName', lockedKey: 'seriesNameLocked', fetchedKey: 'seriesName'},
{label: 'Series #', controlName: 'seriesNumber', lockedKey: 'seriesNumberLocked', fetchedKey: 'seriesNumber'},
{label: 'Series Total', controlName: 'seriesTotal', lockedKey: 'seriesTotalLocked', fetchedKey: 'seriesTotal'},
{label: 'Language', controlName: 'language', lockedKey: 'languageLocked', fetchedKey: 'language'},
{label: 'ISBN-10', controlName: 'isbn10', lockedKey: 'isbn10Locked', fetchedKey: 'isbn10'},
{label: 'ISBN-13', controlName: 'isbn13', lockedKey: 'isbn13Locked', fetchedKey: 'isbn13'},
{label: 'ASIN', controlName: 'asin', lockedKey: 'asinLocked', fetchedKey: 'asin'},
{label: 'Amz Reviews', controlName: 'amazonReviewCount', lockedKey: 'amazonReviewCountLocked', fetchedKey: 'amazonReviewCount'},
{label: 'Amz Rating', controlName: 'amazonRating', lockedKey: 'amazonRatingLocked', fetchedKey: 'amazonRating'},
{label: 'GR ID', controlName: 'goodreadsId', lockedKey: 'goodreadsIdLocked', fetchedKey: 'goodreadsId'},
{label: 'GR Reviews', controlName: 'goodreadsReviewCount', lockedKey: 'goodreadsReviewCountLocked', fetchedKey: 'goodreadsReviewCount'},
{label: 'GR Rating', controlName: 'goodreadsRating', lockedKey: 'goodreadsRatingLocked', fetchedKey: 'goodreadsRating'},
{label: 'HC ID', controlName: 'hardcoverId', lockedKey: 'hardcoverIdLocked', fetchedKey: 'hardcoverId'},
{label: 'HC Reviews', controlName: 'hardcoverReviewCount', lockedKey: 'hardcoverReviewCountLocked', fetchedKey: 'hardcoverReviewCount'},
{label: 'HC Rating', controlName: 'hardcoverRating', lockedKey: 'hardcoverRatingLocked', fetchedKey: 'hardcoverRating'},
{label: 'Amazon ASIN', controlName: 'asin', lockedKey: 'asinLocked', fetchedKey: 'asin'},
{label: 'Amazon #', controlName: 'amazonReviewCount', lockedKey: 'amazonReviewCountLocked', fetchedKey: 'amazonReviewCount'},
{label: 'Amazon ★', controlName: 'amazonRating', lockedKey: 'amazonRatingLocked', fetchedKey: 'amazonRating'},
{label: 'Goodreads ID', controlName: 'goodreadsId', lockedKey: 'goodreadsIdLocked', fetchedKey: 'goodreadsId'},
{label: 'Goodreads #', controlName: 'goodreadsReviewCount', lockedKey: 'goodreadsReviewCountLocked', fetchedKey: 'goodreadsReviewCount'},
{label: 'Goodreads ★', controlName: 'goodreadsRating', lockedKey: 'goodreadsRatingLocked', fetchedKey: 'goodreadsRating'},
{label: 'Hardcover ID', controlName: 'hardcoverId', lockedKey: 'hardcoverIdLocked', fetchedKey: 'hardcoverId'},
{label: 'Hardcover #', controlName: 'hardcoverReviewCount', lockedKey: 'hardcoverReviewCountLocked', fetchedKey: 'hardcoverReviewCount'},
{label: 'Hardcover ★', controlName: 'hardcoverRating', lockedKey: 'hardcoverRatingLocked', fetchedKey: 'hardcoverRating'},
{label: 'Google ID', controlName: 'googleId', lockedKey: 'googleIdLocked', fetchedKey: 'googleId'},
{label: 'Comicvine ID', controlName: 'comicvineId', lockedKey: 'comicvineIdLocked', fetchedKey: 'comicvineId'},
{label: 'Pages', controlName: 'pageCount', lockedKey: 'pageCountLocked', fetchedKey: 'pageCount'}

View File

@@ -1,171 +1,174 @@
<div class="container">
<div class="main-card">
<div class="header">
<div class="header-content">
<h2>
Review Bookdrop Files
<a href="https://booklore-app.github.io/booklore-docs/docs/bookdrop" target="_blank" rel="noopener noreferrer">
<i
class="pi pi-external-link external-link-icon"
pTooltip="View documentation"
tooltipPosition="top"></i>
</a>
</h2>
<p>
These files were uploaded to the
<strong>Bookdrop Folder</strong>.
Review their fetched metadata, assign a library and subpath, and finalize where they belong in your collection.
</p>
</div>
<div class="main-card">
<div class="header">
<div class="header-content">
<h2>
Review Bookdrop Files
<a href="https://booklore-app.github.io/booklore-docs/docs/bookdrop" target="_blank" rel="noopener noreferrer">
<i
class="pi pi-external-link external-link-icon"
pTooltip="View documentation"
tooltipPosition="top"></i>
</a>
</h2>
<p>
These files were uploaded to the
<strong>Bookdrop Folder</strong>.
Review their fetched metadata, assign a library and subpath, and finalize where they belong in your collection.
</p>
</div>
<div class="header-actions">
<p-button
label="Rescan"
icon="pi pi-refresh"
severity="primary"
outlined
(click)="rescanBookdrop()"
pTooltip="Manually trigger a rescan of the Bookdrop folder"
tooltipPosition="top">
</p-button>
<div class="header-actions">
<p-button
label="Rescan"
icon="pi pi-refresh"
severity="primary"
outlined
(click)="rescanBookdrop()"
pTooltip="Manually trigger a rescan of the Bookdrop folder"
tooltipPosition="top">
</p-button>
</div>
</div>
<div class="card-body">
@if (loading) {
<div class="loading-overlay">
<div class="loading-content">
<p-progressSpinner strokeWidth="4"/>
<span>Loading Bookdrop files. Please wait...</span>
</div>
</div>
@if (loading) {
} @else {
<div class="loading-overlay">
<div class="loading-content">
<div class="controls-section">
@if (saving) {
<div class="saving-overlay">
<p-progressSpinner strokeWidth="4"/>
<span>Loading Bookdrop files. Please wait...</span>
<div class="saving-message">
<span>
Organizing and moving files to their designated libraries. Please wait...
</span>
</div>
</div>
</div>
}
} @else {
<div class="controls-section">
@if (saving) {
<div class="saving-overlay">
<p-progressSpinner strokeWidth="4"/>
<div class="saving-message">
<span>
Organizing and moving files to their designated libraries. Please wait...
</span>
</div>
@if (bookdropFileUis.length !== 0) {
<div class="controls-row">
<div class="action-buttons">
<p-button
size="small"
outlined
severity="info"
label="Import&nbsp;Metadata"
icon="pi pi-copy"
(click)="copyAll()"
pTooltip="Replace current metadata with fetched metadata on all files"
tooltipPosition="top">
</p-button>
<span pTooltip="Include book covers when importing fetched metadata"><p-checkbox
inputId="includecovers"
[binary]="true"
[(ngModel)]="includeCoversOnCopy">
</p-checkbox>
<label for="includecovers" class="text-sm" style="margin-left: 0.5em;">Covers</label></span>
</div>
}
<div class="default-controls">
<i class="pi pi-copy"
pTooltip="Select library and subpath for all files:"
tooltipPosition="left"></i>
@if (bookdropFileUis.length !== 0) {
<div class="controls-row">
<div class="action-buttons">
<p-button
size="small"
outlined
severity="info"
label="Import&nbsp;Metadata"
icon="pi pi-copy"
(click)="copyAll()"
pTooltip="Replace current metadata with fetched metadata on all files"
tooltipPosition="top">
</p-button>
<span pTooltip="Include book covers when importing fetched metadata"><p-checkbox
inputId="includecovers"
<p-select
size="small"
[options]="libraryOptions"
optionLabel="label"
optionValue="value"
placeholder="Library"
appendTo="body"
[(ngModel)]="defaultLibraryId">
</p-select>
<p-select
size="small"
[options]="selectedLibraryPaths"
optionLabel="label"
optionValue="value"
placeholder="Subpath"
appendTo="body"
[(ngModel)]="defaultPathId">
</p-select>
<p-button
size="small"
icon="pi pi-check"
severity="info"
[disabled]="!canApplyDefaults"
(click)="applyDefaultsToAll()"
pTooltip="Apply selected library and subpath to all files"
tooltipPosition="top">
</p-button>
</div>
</div>
}
</div>
<div class="content-area">
@if (bookdropFileUis.length === 0) {
<div class="empty-state">
No bookdrop files to review.
</div>
} @else {
@for (file of bookdropFileUis; track file) {
<div class="file-item">
<div class="file-row">
<p-checkbox
[binary]="true"
[(ngModel)]="includeCoversOnCopy">
[(ngModel)]="file.selected"
(ngModelChange)="toggleFileSelection(file.file.id, $event)">
</p-checkbox>
<label for="includecovers" class="text-sm" style="margin-left: 0.5em;">Covers</label></span>
</div>
<div class="default-controls">
<p-select
size="small"
[options]="libraryOptions"
optionLabel="label"
optionValue="value"
placeholder="Default Library"
[(ngModel)]="defaultLibraryId">
</p-select>
<p-select
size="small"
[options]="selectedLibraryPaths"
optionLabel="label"
optionValue="value"
placeholder="Default Subpath"
[(ngModel)]="defaultPathId">
</p-select>
@if (file.metadataForm.get('thumbnailUrl')?.value) {
<img
[src]="file.metadataForm.get('thumbnailUrl')?.value"
alt="Cover"
title="Original cover"
class="cover-image"
(click)="file.showDetails = !file.showDetails"/>
} @else {
<div title="No cover found" class="cover-image text-sm">?</div>
}
<p-button
size="small"
label="Apply"
icon="pi pi-check"
[disabled]="!canApplyDefaults"
(click)="applyDefaultsToAll()"
pTooltip="Apply selected library and subpath to all files"
tooltipPosition="top">
</p-button>
</div>
</div>
}
</div>
@if (file.file.fetchedMetadata?.thumbnailUrl) {
<img
[src]="file.file.fetchedMetadata?.thumbnailUrl"
alt="Fetched Cover"
title="Fetched cover"
class="cover-image"
(click)="file.showDetails = !file.showDetails"/>
} @else {
<div title="No cover fetched" class="cover-image text-sm">?</div>
}
<div class="content-area">
@if (bookdropFileUis.length === 0) {
<div class="empty-state">
No bookdrop files to review.
</div>
} @else {
@for (file of bookdropFileUis; track file) {
<div class="file-name" (click)="file.showDetails = !file.showDetails">
{{ file.file.fileName }}
</div>
<div class="file-item">
<div class="file-row">
<p-checkbox
[binary]="true"
[(ngModel)]="file.selected"
(ngModelChange)="toggleFileSelection(file.file.id, $event)">
</p-checkbox>
<i
class="pi pi-circle-fill status-indicator"
[ngStyle]="{'color': file.file.fetchedMetadata ? 'green' : 'darkorange'}"
pTooltip="{{file.file.fetchedMetadata ? 'Fetched metadata is available.' : 'No fetched metadata available.'}}"
tooltipPosition="top">
</i>
@if (file.metadataForm.get('thumbnailUrl')?.value) {
<img
[src]="file.metadataForm.get('thumbnailUrl')?.value"
alt="Cover"
title="Original cover"
class="cover-image"
(click)="file.showDetails = !file.showDetails"/>
}
@if (file.file.fetchedMetadata?.thumbnailUrl) {
<img
[src]="file.file.fetchedMetadata?.thumbnailUrl"
alt="Fetched Cover"
title="Fetched cover"
class="cover-image"
(click)="file.showDetails = !file.showDetails"/>
}
<div class="file-name" (click)="file.showDetails = !file.showDetails">
{{ file.file.fileName }}
</div>
<i
<div class="file-library">
<i
class="pi metadata-status"
[ngClass]="{
'pi-check-circle copied': copiedFlags[file.file.id],
'pi-check-circle no-metadata': !file.file.fetchedMetadata,
'pi-exclamation-triangle not-applied': file.file.fetchedMetadata && !copiedFlags[file.file.id]
}"
'pi-check-circle copied': copiedFlags[file.file.id],
'pi-check-circle no-metadata': !file.file.fetchedMetadata,
'pi-exclamation-triangle not-applied': file.file.fetchedMetadata && !copiedFlags[file.file.id]
}"
[pTooltip]="copiedFlags[file.file.id]
? 'Fetched metadata has been applied.'
: (!file.file.fetchedMetadata || !file.file.fetchedMetadata.title)
? 'No fetched metadata available. Original metadata will be used.'
: 'Fetched metadata hasnt been applied yet. Open metadata picker to review.'"
? 'Fetched metadata has been applied.'
: (!file.file.fetchedMetadata || !file.file.fetchedMetadata.title)
? 'No fetched metadata available. Original metadata will be used.'
: 'Fetched metadata hasnt been applied yet. Open metadata picker to review.'"
tooltipPosition="top">
</i>
@@ -174,8 +177,9 @@
[options]="libraryOptions"
optionLabel="label"
optionValue="value"
placeholder="Select Library"
placeholder="Library"
class="library-select"
appendTo="body"
[(ngModel)]="file.selectedLibraryId"
(onChange)="onLibraryChange(file)">
</p-select>
@@ -185,7 +189,7 @@
[options]="file.availablePaths"
optionLabel="name"
optionValue="id"
placeholder="Select Subpath"
placeholder="Subpath"
class="path-select"
appendTo="body"
[(ngModel)]="file.selectedPathId">
@@ -193,59 +197,71 @@
<p-button
size="small"
[variant]="file.file.fetchedMetadata?.title ? undefined : 'outlined'"
[icon]="file.showDetails ? 'pi pi-chevron-up' : 'pi pi-chevron-down'"
(click)="file.showDetails = !file.showDetails"
tooltipPosition="top">
[pTooltip]="file.showDetails
? 'Hide metadata'
: file.file.fetchedMetadata?.title
? 'Review and copy fetched metadata'
: 'Show file metadata (no fetched metadata)'"
tooltipPosition="left">
</p-button>
</div>
@if (file.showDetails) {
<app-bookdrop-file-metadata-picker-component
class="details-section"
[originalMetadata]="file.file.originalMetadata"
[fetchedMetadata]="file.file.fetchedMetadata!"
[metadataForm]="file.metadataForm"
[copiedFields]="file.copiedFields"
[savedFields]="file.savedFields"
[bookdropFileId]="file.file.id"
(metadataCopied)="onMetadataCopied(file.file.id, $event)">
</app-bookdrop-file-metadata-picker-component>
}
</div>
}
@if (file.showDetails) {
<app-bookdrop-file-metadata-picker-component
class="details-section"
[originalMetadata]="file.file.originalMetadata"
[fetchedMetadata]="file.file.fetchedMetadata!"
[metadataForm]="file.metadataForm"
[copiedFields]="file.copiedFields"
[savedFields]="file.savedFields"
[bookdropFileId]="file.file.id"
(metadataCopied)="onMetadataCopied(file.file.id, $event)">
</app-bookdrop-file-metadata-picker-component>
}
</div>
}
}
</div>
}
</div>
@if (!loading) {
<p-divider></p-divider>
<!-- Desktop Buttons -->
<div class="footer">
<div class="footer-left">
@if (bookdropFileUis.length > 0) {
<p-button
label="Select&nbsp;All"
icon="pi pi-check-square"
severity="info"
(click)="selectAll(true)"
outlined
pTooltip="Select all files across all pages you have navigated"
tooltipPosition="top">
</p-button>
<p-button
label="Clear"
icon="pi pi-times-circle"
severity="warn"
(click)="selectAll(false)"
outlined
pTooltip="Deselect all files across all pages you have navigated"
tooltipPosition="top">
</p-button>
} @else {
<div class="spacer"></div>
}
</div>
<p-divider></p-divider>
<div class="footer">
<div class="footer-left">
@if (bookdropFileUis.length > 0) {
<p-button
label="Select&nbsp;All"
icon="pi pi-check-square"
severity="info"
(click)="selectAll(true)"
outlined
pTooltip="Select all files across all pages you have navigated"
tooltipPosition="top">
</p-button>
<p-button
label="Clear"
icon="pi pi-times-circle"
severity="warn"
(click)="selectAll(false)"
outlined
pTooltip="Deselect all files across all pages you have navigated"
tooltipPosition="top">
</p-button>
} @else {
<div class="spacer"></div>
}
</div>
@if (totalRecords > pageSize) {
<div class="footer-center">
<p-paginator
[rows]="pageSize"
@@ -258,40 +274,116 @@
currentPageReportTemplate="Page {currentPage} of {totalPages}">
</p-paginator>
</div>
}
<div class="footer-right">
<p-button
[label]="'Reset ' + selectedCount"
icon="pi pi-refresh"
severity="warn"
[disabled]="!hasSelectedFiles"
outlined
(click)="confirmReset()"
pTooltip="Discard all changes made to metadata of selected files"
tooltipPosition="top">
</p-button>
<p-button
[label]="'Delete ' + selectedCount"
icon="pi pi-times"
severity="danger"
[disabled]="!hasSelectedFiles"
outlined
(click)="confirmDelete()"
pTooltip="Permanently delete selected Bookdrop files and discard any changes"
tooltipPosition="top">
</p-button>
<p-button
[label]="saving ? ('Finalizing ' + selectedCount + '...') : ('Finalize ' + selectedCount)"
[icon]="saving ? 'pi pi-spin pi-spinner' : 'pi pi-save'"
severity="success"
outlined
[disabled]="!canFinalize || saving"
(click)="confirmFinalize()"
pTooltip="Move selected files into the chosen library and subpath">
</p-button>
</div>
<div class="footer-right">
<p-button
[label]="'Reset ' + selectedCount"
icon="pi pi-refresh"
severity="warn"
[disabled]="!hasSelectedFiles"
outlined
(click)="confirmReset()"
pTooltip="Discard all changes made to metadata of selected files"
tooltipPosition="top">
</p-button>
<p-button
[label]="'Delete ' + selectedCount"
icon="pi pi-times"
severity="danger"
[disabled]="!hasSelectedFiles"
outlined
(click)="confirmDelete()"
pTooltip="Permanently delete selected Bookdrop files and discard any changes"
tooltipPosition="top">
</p-button>
<p-button
[label]="saving ? ('Finalizing ' + selectedCount + '...') : ('Finalize ' + selectedCount)"
[icon]="saving ? 'pi pi-spin pi-spinner' : 'pi pi-save'"
severity="success"
outlined
[disabled]="!canFinalize || saving"
(click)="confirmFinalize()"
pTooltip="Move selected files into the chosen library and subpath">
</p-button>
</div>
}
</div>
</div>
<!-- Mobile Buttons -->
<div class="footer-mobile">
@if (totalRecords > pageSize) {
<div class="footer-center">
<p-paginator
[rows]="pageSize"
[totalRecords]="totalRecords"
[first]="currentPage * pageSize"
(onPageChange)="loadPage($event.page ?? 0)"
[showJumpToPageDropdown]="true"
[showPageLinks]="false"
[showFirstLastIcon]="false"
currentPageReportTemplate="Page {currentPage} of {totalPages}">
</p-paginator>
</div>
}
<div class="footer-left">
@if (bookdropFileUis.length > 0) {
<p-button
icon="pi pi-check-square"
severity="info"
(click)="selectAll(true)"
outlined
pTooltip="Select all files across all pages you have navigated"
tooltipPosition="top">
</p-button>
<p-button
icon="pi pi-times-circle"
severity="warn"
(click)="selectAll(false)"
outlined
pTooltip="Deselect all files across all pages you have navigated"
tooltipPosition="top">
</p-button>
} @else {
<div class="spacer"></div>
}
</div>
<div class="footer-right">
@if (selectedCount > 0) {
<label class="text-sm">{{ selectedCount }}</label>
}
<p-button
icon="pi pi-refresh"
severity="warn"
[disabled]="!hasSelectedFiles"
outlined
(click)="confirmReset()"
pTooltip="Discard all changes made to metadata of selected files"
tooltipPosition="top">
</p-button>
<p-button
icon="pi pi-times"
severity="danger"
[disabled]="!hasSelectedFiles"
outlined
(click)="confirmDelete()"
pTooltip="Permanently delete selected Bookdrop files and discard any changes"
tooltipPosition="top">
</p-button>
<p-button
[icon]="saving ? 'pi pi-spin pi-spinner' : 'pi pi-save'"
severity="success"
outlined
[disabled]="!canFinalize || saving"
(click)="confirmFinalize()"
pTooltip="Move selected files into the chosen library and subpath">
</p-button>
</div>
</div>
}
</div>

View File

@@ -1,7 +1,3 @@
.container {
overflow-x: auto;
}
.main-card {
display: flex;
flex-direction: column;
@@ -9,22 +5,24 @@
border-radius: 0.75rem;
overflow: hidden;
background: var(--card-background);
min-width: 60rem;
border: 1px solid var(--p-content-border-color);
@media (max-width: 768px) {
height: calc(100dvh - 4.9rem);
}
}
.header {
padding: 1.5rem 1rem 1rem;
padding: 1.5rem;
display: flex;
flex-direction: column;
flex-direction: row;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--p-content-border-color);
margin-bottom: 1.5rem;
@media (min-width: 768px) {
padding: 1.5rem 1.5rem 1rem;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 1rem;
@media (max-width: 768px) {
padding: 1rem;
}
}
@@ -41,18 +39,24 @@
p {
font-size: 0.875rem;
color: rgb(156, 163, 175);
strong {
color: var(--primary-color);
}
}
@media (max-width: 768px) {
flex-shrink: 1;
p {
display: none;
}
}
}
.header-actions {
margin-top: 1rem;
@media (min-width: 768px) {
margin-top: 0;
@media (max-width: 768px) {
display: flex;
}
}
@@ -62,6 +66,13 @@
font-size: 0.9rem;
}
.card-body {
display: flex;
flex: 1;
flex-direction: column;
overflow: auto;
}
.loading-overlay {
position: absolute;
inset: 0;
@@ -82,7 +93,7 @@
}
span {
color: rgb(209, 213, 219);
color: var(--text-color-secondary);
}
p-progressSpinner {
@@ -92,7 +103,7 @@
}
.controls-section {
padding: 0 1.5rem 1.5rem;
padding: 1rem;
margin: 0;
border-bottom: 1px solid var(--p-content-border-color);
}
@@ -135,40 +146,62 @@
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0 0.25rem;
flex-wrap: wrap;
label {
font-size: 0.875rem;
color: var(--text-color-secondary);
font-weight: 500;
}
}
.default-controls {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
p-select {
min-width: 8rem;
max-width: 16rem;
width: 8rem;
}
@media (max-width: 768px) {
width: 100%;
label {
display: none;
}
p-select {
flex-basis: 50%;
max-width: 250px;
}
}
}
::ng-deep .p-select-overlay {
@media (max-width: 768px) {
max-width: 70vw;
.p-select-option {
white-space: normal;
}
}
}
.default-controls i.pi {
color: var(--p-button-outlined-info-color);
}
.action-buttons {
display: flex;
gap: 1rem;
align-items: center;
span {
font-size: 0.875rem;
color: rgb(209, 213, 219);
font-weight: 500;
}
}
.content-area {
flex: 1;
overflow-y: auto;
padding: 1rem;
> * + * {
margin-top: 0.5rem;
}
padding: 0 0 1px;
}
.empty-state {
@@ -191,9 +224,9 @@
display: flex;
align-items: center;
gap: 1rem;
border: 1px solid var(--border-color);
border-radius: 0.75rem 0.75rem 0 0;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--border-color);
padding: 1rem;
flex-wrap: wrap;
}
.status-indicator {
@@ -212,6 +245,17 @@
transform: scale(1.05);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
@media (max-width: 768px) {
display: none !important;
}
}
div.cover-image {
display: block;
background-color: rgba(255, 255, 255, 0.1);
text-align: center;
line-height: 2rem;
}
.file-name {
@@ -220,7 +264,11 @@
font-size: 0.875rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
white-space: wrap;
@media (max-width: 768px) {
max-width: 100%;
}
}
.metadata-status {
@@ -237,34 +285,58 @@
}
}
.file-library {
display: flex;
gap: 1rem;
justify-content: space-between;
align-items: center;
@media (max-width: 768px) {
width: 100%;
}
}
.library-select,
.path-select {
min-width: 8rem;
max-width: 16rem;
width: 8rem;
@media (max-width: 768px) {
flex-basis: 50%;
max-width: 250px;
}
}
.details-section {
border-left: 1px solid var(--border-color);
border-right: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
border-top: none;
border-radius: 0 0 0.625rem 0.625rem;
}
.footer {
padding: 0.5rem 1rem;
.footer, .footer-mobile {
padding: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.footer {
@media (max-width: 768px) {
display: none;
}
}
.footer-mobile {
flex-wrap: wrap;
@media (min-width: 768px) {
display: none;
}
}
.footer-left,
.footer-right {
display: flex;
gap: 1rem;
align-items: center;
min-width: 10rem;
}
.footer-left {
@@ -280,6 +352,10 @@
flex-grow: 1;
display: flex;
justify-content: center;
@media (max-width: 768px) {
width: 100%;
}
}
.spacer {

View File

@@ -21,7 +21,7 @@ import {AppSettingsService} from '../../../../shared/service/app-settings.servic
import {BookMetadata} from '../../../book/model/book.model';
import {UrlHelperService} from '../../../../shared/service/url-helper.service';
import {Checkbox} from 'primeng/checkbox';
import {NgClass, NgStyle} from '@angular/common';
import {NgClass} from '@angular/common';
import {Paginator} from 'primeng/paginator';
import {ActivatedRoute} from '@angular/router';
import {BookdropFileMetadataPickerComponent} from '../bookdrop-file-metadata-picker/bookdrop-file-metadata-picker.component';
@@ -53,7 +53,6 @@ export interface BookdropFileUI {
Tooltip,
Divider,
Checkbox,
NgStyle,
NgClass,
Paginator,
],