Revamp styling and layout of settings pages

This commit is contained in:
aditya.chandel
2025-08-30 23:46:43 -06:00
committed by Aditya Chandel
parent 443dcad59a
commit 13df6a79b6
60 changed files with 6558 additions and 1855 deletions

View File

@@ -1,196 +1,241 @@
<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="p-4">
<p class="text-lg gap-2 pb-4 pt-4">Internal Authentication:</p>
<div class="flex items-center gap-4 pl-6">
<label class="">Enabled</label>
<p-checkbox
[(ngModel)]="internalAuthEnabled"
[binary]="true"
[disabled]="true">
</p-checkbox>
<div class="flex items-center gap-2 pl-4 text-sm text-gray-400">
<i class="pi pi-info-circle text-blue-500"></i>
<span>
Internal authentication is always enabled and cannot be disabled. However, OIDC can be used alongside internal authentication if OIDC is enabled.
</span>
</div>
</div>
<div class="main-container enclosing-container">
<div class="settings-header">
<h2 class="settings-title">
<i class="pi pi-lock"></i>
Authentication Settings
</h2>
<p class="settings-description">
Configure authentication methods for your Booklore instance. Internal authentication is always enabled,
and you can optionally enable OIDC for external authentication providers.
</p>
</div>
<p-divider></p-divider>
<div class="settings-content">
<div class="preferences-section">
<div class="section-header">
<h3 class="section-title">
<i class="pi pi-shield"></i>
Internal Authentication
</h3>
<p class="section-description">
Internal authentication is always enabled and cannot be disabled. However, OIDC can be used alongside internal authentication if OIDC is enabled.
</p>
</div>
<div class="p-4">
<p class="text-lg pb-4 pt-2">OIDC Authentication (Experimental):</p>
<div class="flex items-center gap-4">
<label class=" pl-6">Enabled</label>
<p-toggleswitch
[(ngModel)]="oidcEnabled"
[disabled]="!isOidcFormComplete()"
(onChange)="toggleOidcEnabled()">
</p-toggleswitch>
@if (!isOidcFormComplete()) {
<div class="text-sm text-gray-400">
(Fill all required fields to enable)
</div>
}
</div>
<p class=" pt-8">OIDC Provider Settings:</p>
<div class="pl-6 space-y-6 p-4 m-4 custom-border">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-4xl">
<div>
<label for="providerName" class="mb-1">Provider Name</label>
<input
pInputText
id="providerName"
[(ngModel)]="oidcProvider.providerName"
placeholder="e.g. Authentik"
class="w-full"/>
</div>
<div>
<label for="clientId" class="mb-1">Client ID</label>
<input
pInputText
id="clientId"
[(ngModel)]="oidcProvider.clientId"
placeholder="e.g. my-client-id"
class="w-full"/>
</div>
<div class="md:col-span-2 items-center">
<label for="scope" class="mb-1">Scope</label>
<input
pInputText
id="scope"
class="w-full"
[readonly]="true"
disabled="disabled"
value="openid profile email offline_access"/>
<div class="flex items-center gap-2 text-sm text-gray-400 mt-2 pl-1">
<i class="pi pi-info-circle text-blue-500 mt-1"></i>
<span>
Required scopes for OIDC login and token exchange. Must be supported and advertised in the providers discovery metadata.
</span>
<div class="settings-card">
<div class="auth-option">
<div class="auth-control">
<label class="auth-label">Internal Authentication Enabled</label>
<p-checkbox
[(ngModel)]="internalAuthEnabled"
[binary]="true"
[disabled]="true">
</p-checkbox>
</div>
</div>
<div class="md:col-span-2">
<label for="issuerUri" class="mb-1">Issuer URI</label>
<input
pInputText
id="issuerUri"
[(ngModel)]="oidcProvider.issuerUri"
placeholder="e.g. https://authentik.domain.com/application/o/booklore/ or https://pocket-id.domain.com"
class="w-full"/>
</div>
<div class="md:col-span-2">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="claimUsername" class="mb-1">Username Claim</label>
<input
pInputText
id="claimUsername"
[(ngModel)]="oidcProvider.claimMapping.username"
placeholder="e.g. preferred_username"
class="w-full"/>
</div>
<div>
<label for="claimEmail" class="mb-1">Email Claim</label>
<input
pInputText
id="claimEmail"
[(ngModel)]="oidcProvider.claimMapping.email"
placeholder="e.g. email"
class="w-full"/>
</div>
<div>
<label for="claimName" class="mb-1">Display Name Claim</label>
<input
pInputText
id="claimName"
[(ngModel)]="oidcProvider.claimMapping.name"
placeholder="e.g. name or given_name"
class="w-full"/>
</div>
</div>
<div class="flex items-start gap-2 text-sm text-gray-400 mt-2 pl-1">
<i class="pi pi-info-circle text-blue-500 mt-1"></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>
</div>
</div>
<p-button
[outlined]="true"
type="button"
label="Save Settings"
class="p-button-primary w-full sm:w-auto"
(click)="saveOidcProvider()"
[disabled]="!isOidcFormComplete()">
</p-button>
</div>
</div>
@if (oidcEnabled) {
<div>
<p class=" pt-6">OIDC User Provisioning:</p>
<div class="pl-6 space-y-6 p-4 m-4 custom-border">
<div>
<p class="text-lg pb-3">Automatic user provisioning:</p>
<div class="flex items-center gap-4 pl-6">
<label class="">Enabled</label>
<p-toggleswitch
[(ngModel)]="autoUserProvisioningEnabled"
inputId="autoProvision"
(onChange)="saveOidcAutoProvisionSettings()">
</p-toggleswitch>
<div class="flex items-center gap-2 text-sm text-gray-400">
<i class="pi pi-info-circle text-blue-500"></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>
</div>
</div>
@if (autoUserProvisioningEnabled) {
<div>
<p class=" pt-8">Default permissions for auto-provisioned users:</p>
<div class="flex items-center gap-2 pl-6 pt-2">
<p-checkbox
[binary]="true"
[disabled]="true"
[ngModel]="true"
inputId="permissionRead">
</p-checkbox>
<label class="text-sm">Read Books</label>
</div>
@for (perm of availablePermissions; track perm) {
<div class="flex items-center gap-2 pl-6">
<p-checkbox
[(ngModel)]="perm.selected"
[binary]="true"
[inputId]="perm.value">
</p-checkbox>
<label [for]="perm.value" class="text-sm">{{ perm.label }}</label>
</div>
}
<p class=" pb-3 pt-6">Default libraries for auto-provisioned users:</p>
<div class="pl-6">
<p-multiSelect
[options]="allLibraries"
optionLabel="name"
optionValue="id"
[(ngModel)]="editingLibraryIds"
placeholder="Select Libraries"
appendTo="body">
</p-multiSelect>
</div>
<div class="mt-8">
<p-button label="Save Settings" [outlined]="true" (click)="saveOidcAutoProvisionSettings()"></p-button>
</div>
<div class="preferences-section">
<div class="section-header">
<h3 class="section-title">
<i class="pi pi-key"></i>
OIDC Authentication (Experimental)
</h3>
<p class="section-description">
Configure external authentication providers using OpenID Connect (OIDC) protocol for seamless single sign-on integration.
</p>
</div>
<div class="settings-card">
<div class="auth-option">
<div class="auth-control">
<label class="auth-label">OIDC Enabled</label>
<p-toggleswitch
[(ngModel)]="oidcEnabled"
[disabled]="!isOidcFormComplete()"
(onChange)="toggleOidcEnabled()">
</p-toggleswitch>
@if (!isOidcFormComplete()) {
<div class="auth-status">
(Fill all required fields to enable)
</div>
}
</div>
</div>
<div class="provider-section">
<h4 class="subsection-title">OIDC Provider Settings</h4>
<div class="provider-form">
<div class="form-grid">
<div class="form-field">
<label for="providerName">Provider Name</label>
<input
pInputText
id="providerName"
[(ngModel)]="oidcProvider.providerName"
placeholder="e.g. Authentik"
class="w-full"/>
</div>
<div class="form-field">
<label for="clientId">Client ID</label>
<input
pInputText
id="clientId"
[(ngModel)]="oidcProvider.clientId"
placeholder="e.g. my-client-id"
class="w-full"/>
</div>
<div class="form-field form-field-full">
<label for="scope">Scope</label>
<input
pInputText
id="scope"
class="w-full"
[readonly]="true"
disabled="disabled"
value="openid profile email offline_access"/>
<div class="field-info">
<i class="pi pi-info-circle text-blue-500"></i>
<span>
Required scopes for OIDC login and token exchange. Must be supported and advertised in the provider's discovery metadata.
</span>
</div>
</div>
<div class="form-field form-field-full">
<label for="issuerUri">Issuer URI</label>
<input
pInputText
id="issuerUri"
[(ngModel)]="oidcProvider.issuerUri"
placeholder="e.g. https://authentik.domain.com/application/o/booklore/ or https://pocket-id.domain.com"
class="w-full"/>
</div>
<div class="form-field form-field-full">
<div class="claims-grid">
<div class="form-field">
<label for="claimUsername">Username Claim</label>
<input
pInputText
id="claimUsername"
[(ngModel)]="oidcProvider.claimMapping.username"
placeholder="e.g. preferred_username"
class="w-full"/>
</div>
<div class="form-field">
<label for="claimEmail">Email Claim</label>
<input
pInputText
id="claimEmail"
[(ngModel)]="oidcProvider.claimMapping.email"
placeholder="e.g. email"
class="w-full"/>
</div>
<div class="form-field">
<label for="claimName">Display Name Claim</label>
<input
pInputText
id="claimName"
[(ngModel)]="oidcProvider.claimMapping.name"
placeholder="e.g. name or given_name"
class="w-full"/>
</div>
</div>
<div class="field-info">
<i class="pi pi-info-circle text-blue-500"></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>
</div>
</div>
<div class="form-actions">
<p-button
[outlined]="true"
type="button"
label="Save Settings"
icon="pi pi-save"
severity="success"
(click)="saveOidcProvider()"
[disabled]="!isOidcFormComplete()">
</p-button>
</div>
</div>
</div>
</div>
@if (oidcEnabled) {
<div class="provisioning-section">
<h4 class="subsection-title">OIDC User Provisioning</h4>
<div class="provisioning-form">
<div class="auth-option">
<div class="auth-control">
<label class="auth-label">Automatic user provisioning</label>
<p-toggleswitch
[(ngModel)]="autoUserProvisioningEnabled"
inputId="autoProvision"
(onChange)="saveOidcAutoProvisionSettings()">
</p-toggleswitch>
</div>
<div class="auth-info">
<i class="pi pi-info-circle text-blue-500"></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>
</div>
</div>
@if (autoUserProvisioningEnabled) {
<div class="permissions-section">
<h5 class="permissions-title">Default permissions for auto-provisioned users</h5>
<div class="permissions-list">
<div class="permission-item">
<p-checkbox
[binary]="true"
[disabled]="true"
[ngModel]="true"
inputId="permissionRead">
</p-checkbox>
<label for="permissionRead">Read Books</label>
</div>
@for (perm of availablePermissions; track perm) {
<div class="permission-item">
<p-checkbox
[(ngModel)]="perm.selected"
[binary]="true"
[inputId]="perm.value">
</p-checkbox>
<label [for]="perm.value">{{ perm.label }}</label>
</div>
}
</div>
<h5 class="permissions-title">Default libraries for auto-provisioned users</h5>
<div class="libraries-section">
<p-multiSelect
[options]="allLibraries"
optionLabel="name"
optionValue="id"
[(ngModel)]="editingLibraryIds"
placeholder="Select Libraries"
appendTo="body">
</p-multiSelect>
</div>
<div class="form-actions">
<p-button
icon="pi pi-save"
label="Save Settings"
[outlined]="true"
severity="success"
(click)="saveOidcAutoProvisionSettings()">
</p-button>
</div>
</div>
}
</div>
</div>
}
</div>
}
</div>
</div>
</div>

View File

@@ -1,8 +1,252 @@
.enclosing-container {
border-color: var(--p-content-border-color);
.main-container {
width: 100%;
padding: 1rem;
height: calc(100dvh - 10.5rem);
overflow-y: auto;
border-width: 1px;
border-radius: 0.5rem;
@media (min-width: 768px) {
height: calc(100dvh - 11.65rem);
}
}
.custom-border {
border: 1px solid var(--border-color);
border-radius: var(--card-border);
.enclosing-container {
border-color: var(--p-content-border-color);
background: var(--p-content-background);
}
.settings-header {
margin-top: 1rem;
margin-bottom: 2rem;
}
.settings-title {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.25rem;
font-weight: 700;
color: var(--p-text-color);
margin: 0 0 0.75rem 0;
.pi {
color: var(--p-primary-color);
font-size: 1.25rem;
}
}
.settings-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin-bottom: 1rem;
}
.settings-content {
display: flex;
flex-direction: column;
gap: 3rem;
}
.preferences-section {
@media (min-width: 768px) {
padding: 0 1rem;
}
}
.section-header {
margin-bottom: 1rem;
}
.section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--p-text-color);
margin: 0 0 0.5rem 0;
.pi {
color: var(--p-primary-color);
}
}
.section-description {
display: flex;
align-items: flex-start;
gap: 0.5rem;
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
.pi {
color: var(--p-primary-color);
margin-top: 0.125rem;
flex-shrink: 0;
}
}
.settings-card {
border: 1px solid var(--p-content-border-color);
border-radius: 8px;
background: var(--p-content-background);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.auth-option {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.auth-control {
display: flex;
align-items: center;
gap: 1rem;
}
.auth-label {
font-weight: 500;
color: var(--p-text-color);
min-width: 6rem;
}
.auth-info {
display: flex;
align-items: flex-start;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--p-text-muted-color);
padding-left: 1rem;
.pi {
margin-top: 0.125rem;
flex-shrink: 0;
}
}
.auth-status {
font-size: 0.875rem;
color: var(--p-text-muted-color);
}
.provider-section,
.provisioning-section {
margin-top: 1rem;
}
.subsection-title {
font-size: 1rem;
font-weight: 600;
color: var(--p-text-color);
margin-bottom: 1rem;
}
.provider-form,
.provisioning-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
@media (min-width: 768px) {
grid-template-columns: repeat(2, 1fr);
}
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
&.form-field-full {
@media (min-width: 768px) {
grid-column: 1 / -1;
}
}
label {
font-weight: 600;
color: var(--p-text-color);
font-size: 0.875rem;
}
}
.claims-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
@media (min-width: 768px) {
grid-template-columns: repeat(3, 1fr);
}
}
.field-info {
display: flex;
align-items: flex-start;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--p-text-muted-color);
margin-top: 0.5rem;
.pi {
margin-top: 0.125rem;
flex-shrink: 0;
}
}
.form-actions {
display: flex;
justify-content: flex-start;
margin-top: 1rem;
}
.permissions-section {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
}
.permissions-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--p-text-color);
margin-bottom: 0.5rem;
}
.permissions-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding-left: 1rem;
}
.permission-item {
display: flex;
align-items: center;
gap: 0.5rem;
label {
font-size: 0.875rem;
color: var(--p-text-color);
cursor: pointer;
}
}
.libraries-section {
padding-left: 1rem;
margin-bottom: 1rem;
}

View File

@@ -10,7 +10,6 @@ import {AppSettingsService} from '../../service/app-settings.service';
import {Observable} from 'rxjs';
import {AppSettingKey, AppSettings, OidcProviderDetails} from '../../model/app-settings.model';
import {filter, take} from 'rxjs/operators';
import {Divider} from 'primeng/divider';
import {MultiSelect} from 'primeng/multiselect';
import {Library} from '../../../book/model/library.model';
import {LibraryService} from '../../../book/service/library.service';
@@ -24,7 +23,6 @@ import {LibraryService} from '../../../book/service/library.service';
InputText,
Checkbox,
ToggleSwitch,
Divider,
Button,
MultiSelect,
ReactiveFormsModule

View File

@@ -95,7 +95,7 @@
<div class="flex flex-row gap-6">
<p-button severity="warn" outlined="true" label="Reset Form" (onClick)="reset()"></p-button>
<p-button [label]="submitButtonLabel" outlined="true" (onClick)="submit()"></p-button>
<p-button [label]="submitButtonLabel" severity="success" icon="pi pi-save" outlined="true" (onClick)="submit()"></p-button>
</div>
</div>

View File

@@ -1,10 +1,9 @@
<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="pt-2">
<div class="pb-6">
<app-koreader-settings-component></app-koreader-settings-component>
</div>
<p-divider></p-divider>
<div class="py-2">
<div class="py-4">
<app-kobo-sync-setting-component></app-kobo-sync-setting-component>
</div>
<p-divider></p-divider>
</div>

View File

@@ -1,117 +1,186 @@
<p-confirmDialog></p-confirmDialog>
<p class="text-lg flex items-center gap-2 px-4 pt-4 pb-2">
Kobo Integration Settings:
<i class="pi pi-info-circle text-sky-600"
pTooltip="Click to view Kobo setup documentation"
tooltipPosition="right"
(click)="openKoboDocumentation()"
style="cursor: pointer;">
</i>
</p>
<div class="main-container enclosing-container">
<div class="settings-header">
<h2 class="settings-title">
<i class="pi pi-tablet"></i>
Kobo Integration Settings
<i class="pi pi-external-link external-link-icon"
pTooltip="Click to view Kobo setup documentation"
tooltipPosition="right"
(click)="openKoboDocumentation()"
style="cursor: pointer;">
</i>
</h2>
<p class="settings-description">
Configure Kobo device integration to sync your library books and reading progress with your Kobo e-reader.
</p>
</div>
@if (hasKoboTokenPermission) {
<form #koboForm="ngForm" class="px-4 py-2">
<div class="flex flex-col p-4 space-y-6">
<div class="flex gap-2">
<p-toggle-switch
id="syncEnabled"
name="syncEnabled"
[(ngModel)]="koboSyncSettings.syncEnabled"
(ngModelChange)="onSyncToggle()">
</p-toggle-switch>
<label for="syncEnabled">Enable Kobo Sync</label>
</div>
@if (koboSyncSettings.syncEnabled) {
<div class="flex flex-col gap-1 max-w-[33rem]">
<label for="koboToken">Kobo Sync Token</label>
<div class="flex items-center gap-2">
<input
pInputText
id="koboToken"
[(ngModel)]="koboSyncSettings.token"
[type]="showToken ? 'text' : 'password'"
readonly
fluid
name="koboToken"
/>
<p-button
icon="pi pi-copy"
outlined="true"
severity="info"
(onClick)="copyText(koboSyncSettings.token)">
</p-button>
<p-button
[icon]="showToken ? 'pi pi-eye-slash' : 'pi pi-eye'"
outlined="true"
severity="info"
(onClick)="toggleShowToken()">
</p-button>
</div>
</div>
<p-button
icon="pi pi-refresh"
outlined="true"
severity="warn"
label="Regenerate Token"
(onClick)="confirmRegenerateToken()"
[disabled]="!credentialsSaved">
</p-button>
}
</div>
</form>
@if (isAdmin) {
<div class="px-4 py-4">
<p class="text-lg mb-4">Kobo Settings (Admin Only):</p>
<div class="flex flex-col px-4 py-4 space-y-6 max-w-[75rem]">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<p-toggle-switch
id="convertToKepub"
[(ngModel)]="koboSettings.convertToKepub"
(ngModelChange)="onToggleChange()">
</p-toggle-switch>
<label for="convertToKepub" class="font-medium">Convert to KEPUB</label>
</div>
<p class="text-zinc-400 text-sm">
<strong>Pros:</strong> KEPUB format enables enhanced Kobo features like better typography, reading stats, and faster page turns.
<br>
<strong>Cons:</strong> Conversion takes extra processing time and may occasionally fail for complex layouts.
Books over the size limit below will skip conversion automatically.
@if (hasKoboTokenPermission) {
<div class="settings-content">
<div class="preferences-section">
<div class="section-header">
<h3 class="section-title">
<i class="pi pi-sync"></i>
Sync Configuration
</h3>
<p class="section-description">
Enable and configure synchronization between your Booklore library and Kobo device.
</p>
</div>
<div class="flex flex-col gap-4">
<div class="flex items-center gap-6">
<label for="conversionLimit" class="font-medium">Conversion Size Limit: {{ koboSettings.conversionLimitInMb }} MB</label>
<div class="settings-card">
<form #koboForm="ngForm">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Enable Kobo Sync</label>
<p-toggle-switch
id="syncEnabled"
name="syncEnabled"
[(ngModel)]="koboSyncSettings.syncEnabled"
(ngModelChange)="onSyncToggle()">
</p-toggle-switch>
</div>
<p class="setting-description">
Turn on synchronization to automatically sync your library books to your Kobo device.
</p>
</div>
</div>
@if (koboSyncSettings.syncEnabled) {
<div class="setting-item">
<div class="setting-info">
<label class="setting-label">Kobo Sync Token</label>
<p class="setting-description">
Your unique sync token for authenticating with your Kobo device. Keep this secure.
</p>
</div>
<div class="setting-control">
<div class="token-input-group">
<input
pInputText
id="koboToken"
[(ngModel)]="koboSyncSettings.token"
[type]="showToken ? 'text' : 'password'"
readonly
name="koboToken"
class="token-input"
/>
<p-button
icon="pi pi-copy"
outlined="true"
severity="info"
size="small"
(onClick)="copyText(koboSyncSettings.token)">
</p-button>
<p-button
[icon]="showToken ? 'pi pi-eye-slash' : 'pi pi-eye'"
outlined="true"
severity="info"
size="small"
(onClick)="toggleShowToken()">
</p-button>
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<label class="setting-label">Token Management</label>
<p class="setting-description">
Regenerate your sync token if needed. This will invalidate the current token.
</p>
</div>
<div class="setting-control">
<p-button
icon="pi pi-refresh"
outlined="true"
severity="warn"
size="small"
label="Regenerate Token"
(onClick)="confirmRegenerateToken()"
[disabled]="!credentialsSaved">
</p-button>
</div>
</div>
}
</form>
</div>
</div>
@if (isAdmin) {
<div class="preferences-section">
<div class="section-header">
<h3 class="section-title">
<i class="pi pi-cog"></i>
Administrator Settings
</h3>
<p class="section-description">
Configure advanced Kobo sync settings that affect all users on this server.
</p>
</div>
<p-slider
class="max-w-64"
id="conversionLimit"
[(ngModel)]="koboSettings.conversionLimitInMb"
[min]="1"
[max]="250"
[step]="1"
(ngModelChange)="onSliderChange()">
</p-slider>
<p class="text-zinc-400 text-sm">
Large files (100MB+) can cause conversion timeouts and server strain. Lower limits ensure faster syncing but may skip conversion of larger books.
<div class="settings-card">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Convert to KEPUB</label>
<p-toggle-switch
id="convertToKepub"
[(ngModel)]="koboSettings.convertToKepub"
(ngModelChange)="onToggleChange()">
</p-toggle-switch>
</div>
<p class="setting-description">
<strong>Pros:</strong> KEPUB format enables enhanced Kobo features like better typography, reading stats, and faster page turns.
<br>
<strong>Cons:</strong> Conversion takes extra processing time and may occasionally fail for complex layouts.
Books over the size limit below will skip conversion automatically.
</p>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Conversion Size Limit: {{ koboSettings.conversionLimitInMb }} MB</label>
<div class="slider-container">
<p-slider
id="conversionLimit"
[(ngModel)]="koboSettings.conversionLimitInMb"
[min]="1"
[max]="250"
[step]="1"
(ngModelChange)="onSliderChange()">
</p-slider>
</div>
</div>
<p class="setting-description">
Large files (100MB+) can cause conversion timeouts and server strain. Lower limits ensure faster syncing but may skip conversion of larger books.
<br>
<strong>Recommended:</strong> 50-100MB for most libraries. Very large books will sync as regular EPUB regardless of this setting.
</p>
</div>
</div>
</div>
</div>
}
</div>
} @else {
<div class="settings-content">
<div class="access-denied-card">
<i class="pi pi-lock"></i>
<div class="access-denied-content">
<h3>Access Restricted</h3>
<p>
Access to Kobo sync is restricted.
<br>
<strong>Recommended:</strong> 50-100MB for most libraries. Very large books will sync as regular EPUB regardless of this setting.
Please contact your administrator to request permission.
</p>
</div>
</div>
</div>
}
} @else {
<div class="px-4 py-4 m-4 rounded-lg bg-red-700/30 border border-red-600 text-red-200 flex items-center gap-2 max-w-lg">
<i class="pi pi-lock text-red-400 text-xl"></i>
<span>
Access to Kobo sync is restricted.
<br>
Please contact your administrator to request permission.
</span>
</div>
}
</div>

View File

@@ -0,0 +1,221 @@
.main-container {
width: 100%;
padding: 1rem;
overflow-y: auto;
}
.enclosing-container {
border-color: var(--p-content-border-color);
background: var(--p-content-background);
}
.settings-header {
margin-top: 1rem;
margin-bottom: 2rem;
}
.settings-title {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.25rem;
font-weight: 700;
color: var(--p-text-color);
margin: 0 0 0.75rem 0;
.pi {
color: var(--p-primary-color);
font-size: 1.25rem;
}
.external-link-icon {
color: #0ea5e9 !important; // Sky-600 equivalent
font-size: 1rem !important;
}
}
.settings-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin-bottom: 1rem;
}
.settings-content {
display: flex;
flex-direction: column;
gap: 2rem;
}
.preferences-section {
@media (min-width: 768px) {
padding: 0 1rem;
}
}
.section-header {
margin-bottom: 1rem;
}
.section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--p-text-color);
margin: 0 0 0.5rem 0;
.pi {
color: var(--p-primary-color);
}
}
.section-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
}
.settings-card {
border: 1px solid var(--p-content-border-color);
border-radius: 8px;
background: var(--p-content-background);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.setting-item {
display: flex;
align-items: flex-start;
gap: 2rem;
padding: 0.25rem 0;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
@media (max-width: 768px) {
flex-direction: column;
gap: 1rem;
}
}
.setting-info {
flex: 1;
min-width: 0;
.setting-label {
display: block;
font-weight: 600;
color: var(--p-text-color);
font-size: 1rem;
margin-bottom: 0.5rem;
}
.setting-label-row {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
.setting-label {
margin-bottom: 0;
flex-shrink: 0;
}
.slider-container {
flex: 1;
min-width: 200px;
max-width: 300px;
@media (max-width: 768px) {
min-width: 180px;
max-width: 250px;
}
}
}
.setting-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
}
}
.setting-control {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: flex-end;
@media (max-width: 768px) {
align-items: flex-start;
width: 100%;
}
}
.token-input-group {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
max-width: 500px;
@media (max-width: 768px) {
max-width: 100%;
}
.token-input {
flex: 1;
min-width: 200px;
}
}
.slider-container {
width: 250px;
@media (max-width: 768px) {
width: 200px;
}
}
.access-denied-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.5rem;
margin: 1rem 0;
border-radius: 8px;
background: rgba(220, 38, 38, 0.1);
border: 1px solid rgba(220, 38, 38, 0.3);
color: var(--p-red-400);
max-width: 500px;
.pi {
font-size: 1.25rem;
flex-shrink: 0;
}
.access-denied-content {
h3 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
font-weight: 600;
color: var(--p-red-300);
}
p {
margin: 0;
font-size: 0.875rem;
line-height: 1.5;
}
}
}

View File

@@ -1,128 +1,192 @@
<p-toast position="top-right"></p-toast>
<p class="text-lg flex items-center gap-2 px-4 pt-4 pb-2">
KOReader Sync Settings:
<i class="pi pi-info-circle text-sky-600"
pTooltip="Click to view KOReader setup documentation"
tooltipPosition="right"
(click)="openKoReaderDocumentation()"
style="cursor: pointer;">
</i>
</p>
@if (hasPermission) {
<form #koreaderForm="ngForm" class="px-4 py-4">
<div class="p-field flex items-center px-4 py-2">
<p-toggle-switch
name="syncEnabled"
[(ngModel)]="koReaderSyncEnabled"
(ngModelChange)="onToggleEnabled($event)"
inputId="syncEnabled"
[disabled]="!credentialsSaved">
</p-toggle-switch>
<label for="syncEnabled" class="ml-2">
Enable KOReader Sync
</label>
</div>
<div class="flex flex-col px-4 py-4 space-y-4">
<!-- API Path -->
<div class="flex flex-col gap-1 max-w-[30rem]">
<label for="koreaderEndpoint">KOReader API Path</label>
<div class="flex items-center gap-2">
<input
fluid
pInputText
id="koreaderEndpoint"
[value]="koreaderEndpoint"
readonly/>
<p-button
icon="pi pi-copy"
outlined="true"
severity="info"
(onClick)="copyText(koreaderEndpoint)">
</p-button>
</div>
</div>
<!-- Username -->
<div class="flex flex-col gap-1 max-w-[30rem]">
<label for="username">KOReader Username</label>
<div class="flex items-center gap-2">
<input
fluid
pInputText
id="username"
name="username"
required
[(ngModel)]="koReaderUsername"
#usernameModel="ngModel"
[class.p-invalid]="usernameModel.invalid && usernameModel.touched"
[disabled]="editMode"/>
<p-button
icon="pi pi-copy"
outlined="true"
severity="info"
(onClick)="copyText(koReaderUsername)">
</p-button>
</div>
<small *ngIf="editMode && usernameModel.invalid && usernameModel.touched" class="p-error">
Username is required.
</small>
</div>
<!-- Password -->
<div class="flex flex-col gap-1 max-w-[33rem]">
<label for="password">KOReader Password</label>
<div class="flex items-center gap-2">
<input
fluid
pInputText
id="password"
name="password"
required
minlength="6"
[type]="showPassword ? 'text' : 'password'"
[(ngModel)]="koReaderPassword"
#passwordModel="ngModel"
[class.p-invalid]="passwordModel.invalid && passwordModel.touched"
[disabled]="editMode"/>
<p-button
icon="pi pi-copy"
outlined="true"
severity="info"
(onClick)="copyText(koReaderPassword)">
</p-button>
<p-button
[icon]="showPassword ? 'pi pi-eye-slash' : 'pi pi-eye'"
outlined="true"
severity="info"
(onClick)="toggleShowPassword()">
</p-button>
</div>
<small *ngIf="editMode && passwordModel.invalid && passwordModel.touched" class="p-error">
Password must be at least 6 characters.
</small>
</div>
<p-button
[icon]="!editMode ? 'pi pi-save' : 'pi pi-pencil'"
outlined="true"
[severity]="!editMode ? 'success' : 'warn'"
[label]="!editMode ? 'Save' : 'Edit'"
(onClick)="onEditSave()"
[disabled]="!editMode && !canSave">
</p-button>
</div>
</form>
} @else {
<div class="px-4 py-4 m-4 rounded-lg bg-red-700/30 border border-red-600 text-red-200 flex items-center gap-2 max-w-lg">
<i class="pi pi-lock text-red-400 text-xl"></i>
<span>
Access to KOReader sync is restricted.
<br>
Please contact your administrator to request permission.
</span>
<div class="main-container enclosing-container">
<div class="settings-header">
<h2 class="settings-title">
<i class="pi pi-mobile"></i>
KOReader Sync Settings
<i class="pi pi-external-link external-link-icon"
pTooltip="Click to view KOReader setup documentation"
tooltipPosition="right"
(click)="openKoReaderDocumentation()"
style="cursor: pointer;">
</i>
</h2>
<p class="settings-description">
Configure KOReader integration to sync your reading progress and annotations between your device and Booklore library.
</p>
</div>
}
@if (hasPermission) {
<div class="settings-content">
<div class="preferences-section">
<div class="section-header">
<h3 class="section-title">
<i class="pi pi-sync"></i>
Sync Configuration
</h3>
<p class="section-description">
Enable and configure synchronization between your Booklore library and KOReader device.
</p>
</div>
<div class="settings-card">
<form #koreaderForm="ngForm">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Enable KOReader Sync</label>
<p-toggle-switch
name="syncEnabled"
[(ngModel)]="koReaderSyncEnabled"
(ngModelChange)="onToggleEnabled($event)"
inputId="syncEnabled"
[disabled]="!credentialsSaved">
</p-toggle-switch>
</div>
<p class="setting-description">
Turn on synchronization to automatically sync your reading progress and annotations.
</p>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">KOReader API Path</label>
<div class="token-input-group">
<input
pInputText
id="koreaderEndpoint"
[value]="koreaderEndpoint"
readonly
class="token-input"/>
<p-button
icon="pi pi-copy"
outlined="true"
severity="info"
size="small"
(onClick)="copyText(koreaderEndpoint)">
</p-button>
</div>
</div>
<p class="setting-description">
The API endpoint path for your KOReader sync service.
</p>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">KOReader Username</label>
<div class="token-input-group">
<input
pInputText
id="username"
name="username"
required
[(ngModel)]="koReaderUsername"
#usernameModel="ngModel"
[class.p-invalid]="usernameModel.invalid && usernameModel.touched"
[disabled]="editMode"
class="token-input"/>
<p-button
icon="pi pi-copy"
outlined="true"
severity="info"
size="small"
(onClick)="copyText(koReaderUsername)">
</p-button>
</div>
</div>
<p class="setting-description">
Your username for authenticating with the KOReader sync service.
</p>
<small *ngIf="editMode && usernameModel.invalid && usernameModel.touched" class="error-message">
Username is required.
</small>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">KOReader Password</label>
<div class="token-input-group">
<input
pInputText
id="password"
name="password"
required
minlength="6"
[type]="showPassword ? 'text' : 'password'"
[(ngModel)]="koReaderPassword"
#passwordModel="ngModel"
[class.p-invalid]="passwordModel.invalid && passwordModel.touched"
[disabled]="editMode"
class="token-input-password"/>
<p-button
icon="pi pi-copy"
outlined="true"
severity="info"
size="small"
(onClick)="copyText(koReaderPassword)">
</p-button>
<p-button
[icon]="showPassword ? 'pi pi-eye-slash' : 'pi pi-eye'"
outlined="true"
severity="info"
size="small"
(onClick)="toggleShowPassword()">
</p-button>
</div>
</div>
<p class="setting-description">
Your password for authenticating with the KOReader sync service.
</p>
<small *ngIf="editMode && passwordModel.invalid && passwordModel.touched" class="error-message">
Password must be at least 6 characters.
</small>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Settings Management</label>
<p-button
[icon]="!editMode ? 'pi pi-save' : 'pi pi-pencil'"
outlined="true"
[severity]="!editMode ? 'success' : 'warn'"
[label]="!editMode ? 'Save' : 'Edit'"
size="small"
(onClick)="onEditSave()"
[disabled]="!editMode && !canSave">
</p-button>
</div>
<p class="setting-description">
Save your credentials or edit existing settings.
</p>
</div>
</div>
</form>
</div>
</div>
</div>
} @else {
<div class="settings-content">
<div class="access-denied-card">
<i class="pi pi-lock"></i>
<div class="access-denied-content">
<h3>Access Restricted</h3>
<p>
Access to KOReader sync is restricted.
<br>
Please contact your administrator to request permission.
</p>
</div>
</div>
</div>
}
</div>

View File

@@ -0,0 +1,247 @@
.main-container {
width: 100%;
padding: 1rem;
overflow-y: auto;
}
.enclosing-container {
border-color: var(--p-content-border-color);
background: var(--p-content-background);
}
.settings-header {
margin-top: 1rem;
margin-bottom: 2rem;
}
.settings-title {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.25rem;
font-weight: 700;
color: var(--p-text-color);
margin: 0 0 0.75rem 0;
.pi {
color: var(--p-primary-color);
font-size: 1.25rem;
}
.external-link-icon {
color: #0ea5e9 !important;
font-size: 1rem !important;
}
}
.settings-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin-bottom: 1rem;
}
.settings-content {
display: flex;
flex-direction: column;
gap: 2rem;
@media (max-width: 768px) {
padding-left: 0;
}
}
.preferences-section {
@media (max-width: 768px) {
padding: 0 1rem;
}
}
.section-header {
margin-bottom: 1rem;
margin-left: 1rem;
margin-right: 1rem;
}
.section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--p-text-color);
margin: 0 0 0.5rem 0;
.pi {
color: var(--p-primary-color);
}
}
.section-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
}
.settings-card {
border: 1px solid var(--p-content-border-color);
border-radius: 8px;
background: var(--p-content-background);
padding: 1rem 2rem 2rem 2rem;
margin-left: 1rem;
margin-right: 1rem;
display: flex;
flex-direction: column;
gap: 2rem;
@media (max-width: 768px) {
padding: 1rem;
margin-left: 0;
margin-right: 0;
}
}
.setting-item {
display: flex;
align-items: flex-start;
gap: 2rem;
padding: 0.75rem 0;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
@media (max-width: 768px) {
flex-direction: column;
gap: 1rem;
padding: 0.75rem 0;
}
}
.setting-info {
flex: 1;
min-width: 0;
.setting-label {
display: block;
font-weight: 600;
color: var(--p-text-color);
font-size: 1rem;
margin-bottom: 0.5rem;
}
.setting-label-row {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
.setting-label {
margin-bottom: 0;
flex-shrink: 0;
min-width: 150px;
}
.token-input-group {
flex: 1;
min-width: 300px;
max-width: 500px;
@media (max-width: 768px) {
min-width: 250px;
max-width: 100%;
}
}
}
.setting-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
}
}
.setting-control {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: flex-end;
min-width: 350px;
@media (max-width: 768px) {
align-items: flex-start;
width: 100%;
min-width: unset;
}
}
.token-input-group {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
.token-input {
flex: 1;
min-width: 200px;
@media (max-width: 768px) {
min-width: 150px;
}
}
.token-input-password {
flex: 1;
min-width: 200px;
@media (max-width: 768px) {
min-width: 150px;
}
}
}
.error-message {
color: var(--p-red-500);
font-size: 0.75rem;
margin-top: 0.25rem;
display: block;
}
.access-denied-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.5rem;
margin: 1rem 0;
border-radius: 8px;
background: rgba(220, 38, 38, 0.1);
border: 1px solid rgba(220, 38, 38, 0.3);
color: var(--p-red-400);
max-width: 500px;
.pi {
font-size: 1.25rem;
flex-shrink: 0;
}
.access-denied-content {
h3 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
font-weight: 600;
color: var(--p-red-300);
}
p {
margin: 0;
font-size: 0.875rem;
line-height: 1.5;
}
}
}

View File

@@ -1,127 +1,240 @@
<div class="flex justify-between items-center pb-2">
<h2 class="text-lg flex items-center gap-2">
Email Providers
<i class="pi pi-info-circle text-sky-600"
pTooltip="Configure email-sending services like Gmail, Outlook, or custom SMTP servers for sending books via email. The default email provider will be used for 'Quick Book Send' located in the Book Card menu"
tooltipPosition="right"
style="cursor: pointer;"></i>
</h2>
<p-button outlined="true" icon="pi pi-plus" label="Create New Provider" (onClick)="openCreateProviderDialog()"></p-button>
<div class="main-container enclosing-container">
<div class="settings-header">
<h2 class="settings-title">
<i class="pi pi-envelope"></i>
Email Providers
</h2>
<p class="settings-description">
Configure email-sending services like Gmail, Outlook, or custom SMTP servers for sending books via email. The default email provider will be used for 'Quick Book Send' located in the Book Card menu.
</p>
</div>
<div class="settings-content">
<div class="preferences-section">
<div class="section-header">
<div class="section-title-group">
<h3 class="section-title">
<i class="pi pi-server"></i>
Current Providers
</h3>
<p-button
icon="pi pi-plus"
label="Create Provider"
severity="success"
size="small"
[outlined]="true"
(onClick)="openCreateProviderDialog()">
</p-button>
</div>
</div>
<div class="table-card">
<p-table [value]="emailProviders" [scrollable]="true" scrollHeight="flex">
<ng-template pTemplate="header">
<tr>
<th style="width: 6%;">
<div class="header-content">
<i class="pi pi-star"></i>
<span>Default</span>
</div>
</th>
<th style="width: 10%;">
<div class="header-content">
<i class="pi pi-tag"></i>
<span>Name</span>
</div>
</th>
<th style="width: 15%;">
<div class="header-content">
<i class="pi pi-server"></i>
<span>Host</span>
</div>
</th>
<th style="width: 6%;">
<div class="header-content">
<i class="pi pi-link"></i>
<span>Port</span>
</div>
</th>
<th style="width: 14%;">
<div class="header-content">
<i class="pi pi-user"></i>
<span>Username</span>
</div>
</th>
<th style="width: 12%;">
<div class="header-content">
<i class="pi pi-lock"></i>
<span>Password</span>
</div>
</th>
<th style="width: 12%;">
<div class="header-content">
<i class="pi pi-envelope"></i>
<span>From Address</span>
</div>
</th>
<th style="width: 5%;" class="permission-header">Auth</th>
<th style="width: 5%;" class="permission-header">StartTLS</th>
<th style="width: 7%;" class="actions-header">
<div class="header-content">
<i class="pi pi-pencil"></i>
<span>Edit</span>
</div>
</th>
<th style="width: 8%;" class="actions-header">
<div class="header-content">
<i class="pi pi-trash"></i>
<span>Delete</span>
</div>
</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-provider>
<tr>
<td class="text-center">
<p-radioButton
name="defaultProvider"
[value]="provider.id"
[(ngModel)]="defaultProviderId"
(onClick)="setDefaultProvider(provider)">
</p-radioButton>
</td>
<td>
@if (provider.isEditing) {
<input type="text" [(ngModel)]="provider.name" class="p-inputtext w-full" size="small"/>
}
@if (!provider.isEditing) {
<span>{{ provider.name }}</span>
}
</td>
<td>
@if (provider.isEditing) {
<input type="text" [(ngModel)]="provider.host" class="p-inputtext w-full" size="small"/>
}
@if (!provider.isEditing) {
<span>{{ provider.host }}</span>
}
</td>
<td>
@if (provider.isEditing) {
<input type="number" [(ngModel)]="provider.port" class="p-inputtext w-full" size="small"/>
}
@if (!provider.isEditing) {
<span>{{ provider.port }}</span>
}
</td>
<td>
@if (provider.isEditing) {
<input type="text" [(ngModel)]="provider.username" class="p-inputtext w-full" size="small"/>
}
@if (!provider.isEditing) {
<span>{{ provider.username }}</span>
}
</td>
<td>
@if (provider.isEditing) {
<input [(ngModel)]="provider.password" class="p-inputtext w-full" size="small"/>
}
@if (!provider.isEditing) {
<span class="password-hidden">Hidden</span>
}
</td>
<td>
@if (provider.isEditing) {
<input type="text" [(ngModel)]="provider.fromAddress" class="p-inputtext w-full" size="small"/>
}
@if (!provider.isEditing) {
<span>{{ provider.fromAddress }}</span>
}
</td>
<td class="text-center">
<p-checkbox
[(ngModel)]="provider.auth"
[binary]="true"
[disabled]="!provider.isEditing">
</p-checkbox>
</td>
<td class="text-center">
<p-checkbox
[(ngModel)]="provider.startTls"
[binary]="true"
[disabled]="!provider.isEditing">
</p-checkbox>
</td>
<td class="actions-cell">
@if (!provider.isEditing) {
<p-button
icon="pi pi-pencil"
severity="info"
size="small"
[outlined]="true"
[rounded]="true"
(onClick)="toggleEdit(provider)"
pTooltip="Edit provider">
</p-button>
}
@if (provider.isEditing) {
<div class="flex gap-1">
<p-button
icon="pi pi-check"
severity="success"
size="small"
[outlined]="true"
[rounded]="true"
(onClick)="saveProvider(provider)"
pTooltip="Save changes">
</p-button>
<p-button
icon="pi pi-times"
severity="danger"
size="small"
[outlined]="true"
[rounded]="true"
(onClick)="toggleEdit(provider)"
pTooltip="Cancel">
</p-button>
</div>
}
</td>
<td class="actions-cell">
<p-button
icon="pi pi-trash"
severity="danger"
size="small"
[outlined]="true"
[rounded]="true"
(onClick)="deleteProvider(provider)"
pTooltip="Delete provider">
</p-button>
</td>
</tr>
</ng-template>
<ng-template pTemplate="emptymessage">
<tr>
<td colspan="11">
<div class="empty-message">
<i class="pi pi-envelope"></i>
<p class="empty-title">No email providers found</p>
<p class="empty-subtitle">Create your first email provider to start sending books</p>
</div>
</td>
</tr>
</ng-template>
</p-table>
</div>
</div>
</div>
</div>
<p-table [value]="emailProviders">
<ng-template pTemplate="header">
<tr>
<th style="width: 6%;">Default</th>
<th style="width: 10%;">Name</th>
<th style="width: 15%;">Host</th>
<th style="width: 6%;">Port</th>
<th style="width: 14%;">Username</th>
<th style="width: 12%;">Password</th>
<th style="width: 12%;">From Address</th>
<th style="width: 5%;">Auth</th>
<th style="width: 5%;">StartTLS</th>
<th style="width: 7%;">Edit</th>
<th style="width: 8%;">Delete</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-provider>
<tr>
<td class="text-center">
<p-radioButton
name="defaultProvider"
[value]="provider.id"
[(ngModel)]="defaultProviderId"
(onClick)="setDefaultProvider(provider)">
</p-radioButton>
</td>
<td>
@if (provider.isEditing) {
<input type="text" [(ngModel)]="provider.name" class="p-inputtext w-full"/>
}
@if (!provider.isEditing) {
<span>{{ provider.name }}</span>
}
</td>
<td>
@if (provider.isEditing) {
<input type="text" [(ngModel)]="provider.host" class="p-inputtext w-full"/>
}
@if (!provider.isEditing) {
<span>{{ provider.host }}</span>
}
</td>
<td>
@if (provider.isEditing) {
<input type="number" [(ngModel)]="provider.port" class="p-inputtext w-full"/>
}
@if (!provider.isEditing) {
<span>{{ provider.port }}</span>
}
</td>
<td>
@if (provider.isEditing) {
<input type="text" [(ngModel)]="provider.username" class="p-inputtext w-full"/>
}
@if (!provider.isEditing) {
<span>{{ provider.username }}</span>
}
</td>
<td>
@if (provider.isEditing) {
<input [(ngModel)]="provider.password" class="p-inputtext w-full"/>
}
@if (!provider.isEditing) {
<span class="text-gray-400 italic">Hidden</span>
}
</td>
<td>
@if (provider.isEditing) {
<input type="text" [(ngModel)]="provider.fromAddress" class="p-inputtext w-full"/>
}
@if (!provider.isEditing) {
<span>{{ provider.fromAddress }}</span>
}
</td>
<td class="text-center">
<p-checkbox
[(ngModel)]="provider.auth"
[binary]="true"
[disabled]="!provider.isEditing">
</p-checkbox>
</td>
<td class="text-center">
<p-checkbox
[(ngModel)]="provider.startTls"
[binary]="true"
[disabled]="!provider.isEditing">
</p-checkbox>
</td>
<td class="text-center">
@if (!provider.isEditing) {
<p-button icon="pi pi-pencil" outlined="true" severity="info" (onClick)="toggleEdit(provider)"></p-button>
}
@if (provider.isEditing) {
<p-button icon="pi pi-check" outlined="true" severity="success" (onClick)="saveProvider(provider)"></p-button>
}
@if (provider.isEditing) {
<p-button icon="pi pi-times" outlined="true" severity="danger" (onClick)="toggleEdit(provider)"></p-button>
}
</td>
<td class="text-center">
<p-button icon="pi pi-trash" outlined="true" severity="danger" (onClick)="deleteProvider(provider)"></p-button>
</td>
</tr>
</ng-template>
</p-table>

View File

@@ -0,0 +1,167 @@
.enclosing-container {
border-color: var(--p-content-border-color);
background: var(--p-content-background);
}
.settings-header {
margin-top: 1rem;
}
.settings-title {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.25rem;
font-weight: 700;
color: var(--p-text-color);
margin: 0 0 0.75rem 0;
.pi {
color: var(--p-primary-color);
font-size: 1.25rem;
}
}
.settings-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin-bottom: 1rem;
}
.settings-content {
display: flex;
flex-direction: column;
gap: 2rem;
}
.preferences-section {
@media (min-width: 768px) {
padding: 0.5rem 1rem;
}
}
.section-header {
margin-bottom: 1rem;
.section-title-group {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
@media (max-width: 767px) {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
p-button {
align-self: flex-end;
}
}
}
}
.section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--p-text-color);
.pi {
color: var(--p-primary-color);
}
}
.table-card {
border: 1px solid var(--p-content-border-color);
border-radius: 8px;
overflow: hidden;
background: var(--p-content-background);
}
.p-datatable {
.p-datatable-table {
border-collapse: separate;
border-spacing: 0;
}
.p-datatable-thead > tr > th {
background: var(--p-surface-100);
border-bottom: 2px solid var(--p-content-border-color);
padding: 1rem;
font-weight: 600;
color: var(--p-text-color);
white-space: nowrap;
}
.p-datatable-tbody > tr {
transition: background-color 0.2s;
&:hover {
background: var(--p-surface-50);
}
&:last-child {
border-bottom: none;
}
}
.p-datatable-tbody > tr > td {
padding: 1rem;
border: none;
}
}
.p-datatable th .header-content {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
}
.permission-header {
text-align: center;
font-size: 0.875rem;
padding: 0.75rem 0.5rem !important;
min-width: 80px;
width: 80px;
}
.actions-header {
text-align: center;
min-width: 80px;
}
.actions-cell {
text-align: center;
}
.password-hidden {
color: var(--p-text-muted-color);
font-style: italic;
}
.empty-message {
text-align: center;
padding: 2rem 1rem;
color: var(--p-text-muted-color);
.pi {
font-size: 2rem;
margin-bottom: 1rem;
color: var(--p-surface-400);
}
.empty-title {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.empty-subtitle {
font-size: 0.875rem;
}
}

View File

@@ -114,7 +114,7 @@ export class EmailProviderComponent implements OnInit {
openCreateProviderDialog() {
this.ref = this.dialogService.open(CreateEmailProviderDialogComponent, {
header: 'Create New Email Provider',
header: 'Create Email Provider',
modal: true,
closable: true,
style: { position: 'absolute', top: '15%' },

View File

@@ -1,69 +1,162 @@
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg flex items-center gap-2">
Recipient Emails
<i class="pi pi-info-circle text-sky-600"
pTooltip="Manage the list of recipients who will receive books via email. The 'Default' recipient will be used for 'Quick Book Send,' located in the Book Card menu."
tooltipPosition="right"
style="cursor: pointer;"></i>
</h2>
<p-button outlined="true" icon="pi pi-plus" label="Add New Recipient" (onClick)="openAddRecipientDialog()"></p-button>
<div class="main-container enclosing-container">
<div class="settings-header">
<h2 class="settings-title">
<i class="pi pi-users"></i>
Recipient Emails
</h2>
<p class="settings-description">
Manage the list of recipients who will receive books via email. The 'Default' recipient will be used for 'Quick Book Send,' located in the Book Card menu.
</p>
</div>
<div class="settings-content">
<div class="preferences-section">
<div class="section-header">
<div class="section-title-group">
<h3 class="section-title">
<i class="pi pi-envelope"></i>
Current Recipients
</h3>
<p-button
icon="pi pi-plus"
label="Create Recipient"
severity="success"
size="small"
[outlined]="true"
(onClick)="openAddRecipientDialog()">
</p-button>
</div>
</div>
<div class="table-card">
<p-table [value]="recipientEmails" [scrollable]="true" scrollHeight="flex">
<ng-template pTemplate="header">
<tr>
<th style="width: 10%;">
<div class="header-content">
<i class="pi pi-star"></i>
<span>Default</span>
</div>
</th>
<th style="width: 45%;">
<div class="header-content">
<i class="pi pi-envelope"></i>
<span>Email Address</span>
</div>
</th>
<th style="width: 30%;">
<div class="header-content">
<i class="pi pi-user"></i>
<span>Name</span>
</div>
</th>
<th style="width: 8%;" class="actions-header">
<div class="header-content">
<i class="pi pi-pencil"></i>
<span>Edit</span>
</div>
</th>
<th style="width: 7%;" class="actions-header">
<div class="header-content">
<i class="pi pi-trash"></i>
<span>Delete</span>
</div>
</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-recipient>
<tr>
<td class="text-center">
<p-radioButton
name="defaultRecipient"
[value]="recipient.id"
[(ngModel)]="defaultRecipientId"
(onClick)="setDefaultRecipient(recipient)">
</p-radioButton>
</td>
<td>
@if (recipient.isEditing) {
<input type="email" [(ngModel)]="recipient.email" class="p-inputtext w-full" size="small"/>
}
@if (!recipient.isEditing) {
<span>{{ recipient.email }}</span>
}
</td>
<td>
@if (recipient.isEditing) {
<input type="text" [(ngModel)]="recipient.name" class="p-inputtext w-full" size="small"/>
}
@if (!recipient.isEditing) {
<span>{{ recipient.name }}</span>
}
</td>
<td class="actions-cell">
@if (!recipient.isEditing) {
<p-button
icon="pi pi-pencil"
severity="info"
size="small"
[outlined]="true"
[rounded]="true"
(onClick)="toggleEditRecipient(recipient)"
pTooltip="Edit recipient">
</p-button>
}
@if (recipient.isEditing) {
<div class="flex gap-1">
<p-button
icon="pi pi-check"
severity="success"
size="small"
[outlined]="true"
[rounded]="true"
(onClick)="saveRecipient(recipient)"
pTooltip="Save changes">
</p-button>
<p-button
icon="pi pi-times"
severity="danger"
size="small"
[outlined]="true"
[rounded]="true"
(onClick)="toggleEditRecipient(recipient)"
pTooltip="Cancel">
</p-button>
</div>
}
</td>
<td class="actions-cell">
<p-button
icon="pi pi-trash"
severity="danger"
size="small"
[outlined]="true"
[rounded]="true"
(onClick)="deleteRecipient(recipient)"
pTooltip="Delete recipient">
</p-button>
</td>
</tr>
</ng-template>
<ng-template pTemplate="emptymessage">
<tr>
<td colspan="5">
<div class="empty-message">
<i class="pi pi-users"></i>
<p class="empty-title">No recipients found</p>
<p class="empty-subtitle">Add your first email recipient to start sending books</p>
</div>
</td>
</tr>
</ng-template>
</p-table>
</div>
</div>
</div>
</div>
<p-table [value]="recipientEmails">
<ng-template pTemplate="header">
<tr>
<th style="width: 5%;">Default</th>
<th style="width: 40%;">Email Address</th>
<th style="width: 40%;">Name</th>
<th style="width: 15%;">Edit</th>
<th style="width: 5%;">Delete</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-recipient>
<tr>
<td class="text-center">
<p-radioButton
name="defaultRecipient"
[value]="recipient.id"
[(ngModel)]="defaultRecipientId"
(onClick)="setDefaultRecipient(recipient)">
</p-radioButton>
</td>
<td>
@if (recipient.isEditing) {
<input type="email" [(ngModel)]="recipient.email" class="p-inputtext w-full"/>
}
@if (!recipient.isEditing) {
<span>{{ recipient.email }}</span>
}
</td>
<td>
@if (recipient.isEditing) {
<input type="text" [(ngModel)]="recipient.name" class="p-inputtext w-full"/>
}
@if (!recipient.isEditing) {
<span>{{ recipient.name }}</span>
}
</td>
<td class="text-center">
@if (!recipient.isEditing) {
<p-button icon="pi pi-pencil" outlined="true" severity="info" (onClick)="toggleEditRecipient(recipient)"></p-button>
}
@if (recipient.isEditing) {
<p-button icon="pi pi-check" outlined="true" severity="success" (onClick)="saveRecipient(recipient)"></p-button>
}
@if (recipient.isEditing) {
<p-button icon="pi pi-times" outlined="true" severity="danger" (onClick)="toggleEditRecipient(recipient)"></p-button>
}
</td>
<td class="text-center">
<p-button icon="pi pi-trash" outlined="true" severity="danger" (onClick)="deleteRecipient(recipient)"></p-button>
</td>
</tr>
</ng-template>
</p-table>

View File

@@ -0,0 +1,144 @@
.enclosing-container {
border-color: var(--p-content-border-color);
background: var(--p-content-background);
}
.settings-header {
margin-top: 1rem;
}
.settings-title {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.25rem;
font-weight: 700;
color: var(--p-text-color);
margin: 0 0 0.75rem 0;
.pi {
color: var(--p-primary-color);
font-size: 1.25rem;
}
}
.settings-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin-bottom: 1rem;
}
.settings-content {
display: flex;
flex-direction: column;
gap: 2rem;
}
.preferences-section {
@media (min-width: 768px) {
padding: 0 1rem;
}
}
.section-header {
margin-bottom: 1rem;
.section-title-group {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
}
.section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--p-text-color);
.pi {
color: var(--p-primary-color);
}
}
.table-card {
border: 1px solid var(--p-content-border-color);
border-radius: 8px;
overflow: hidden;
background: var(--p-content-background);
}
.p-datatable {
.p-datatable-table {
border-collapse: separate;
border-spacing: 0;
}
.p-datatable-thead > tr > th {
background: var(--p-surface-100);
border-bottom: 2px solid var(--p-content-border-color);
padding: 1rem;
font-weight: 600;
color: var(--p-text-color);
white-space: nowrap;
}
.p-datatable-tbody > tr {
transition: background-color 0.2s;
&:hover {
background: var(--p-surface-50);
}
&:last-child {
border-bottom: none;
}
}
.p-datatable-tbody > tr > td {
padding: 1rem;
border: none;
}
}
.p-datatable th .header-content {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
}
.actions-header {
text-align: center;
min-width: 80px;
}
.actions-cell {
text-align: center;
}
.empty-message {
text-align: center;
padding: 2rem 1rem;
color: var(--p-text-muted-color);
.pi {
font-size: 2rem;
margin-bottom: 1rem;
color: var(--p-surface-400);
}
.empty-title {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.empty-subtitle {
font-size: 0.875rem;
}
}

View File

@@ -1,8 +1,9 @@
<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="pt-2">
<div class="pb-8">
<app-email-provider></app-email-provider>
</div>
<div class="pb-12 pt-12">
<p-divider></p-divider>
<div class="pt-4">
<app-email-recipient></app-email-recipient>
</div>
</div>

View File

@@ -3,6 +3,7 @@ import {FormsModule} from '@angular/forms';
import {TableModule} from 'primeng/table';
import {EmailProviderComponent} from './email-provider/email-provider.component';
import {EmailRecipientComponent} from './email-recipient/email-recipient.component';
import {Divider} from 'primeng/divider';
@Component({
selector: 'app-email',
@@ -10,7 +11,8 @@ import {EmailRecipientComponent} from './email-recipient/email-recipient.compone
FormsModule,
TableModule,
EmailProviderComponent,
EmailRecipientComponent
EmailRecipientComponent,
Divider
],
templateUrl: './email.component.html',
styleUrls: ['./email.component.scss'],

View File

@@ -1,258 +1,263 @@
<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="pt-8 px-4">
<p class="text-lg flex items-center gap-2">
<div class="main-container enclosing-container">
<div class="settings-header">
<h2 class="settings-title">
<i class="pi pi-file"></i>
File Naming Patterns
<i
class="pi pi-info-circle text-sky-600"
pTooltip="Define custom naming patterns for uploaded files and for moving files within your library. Use metadata placeholders to automate organization."
tooltipPosition="right"
style="cursor: pointer;">
</i>
</h2>
<p class="settings-description">
Define custom naming patterns for uploaded files and for moving files within your library. Use metadata placeholders to automate organization.
</p>
<div class="flex flex-col space-y-6 p-4 m-4 custom-border">
</div>
<div>
<h2 class="mb-2 text-lg">Default File Naming Pattern:</h2>
<p class="mb-3 text-sm text-gray-400">
<div class="settings-content">
<div class="preferences-section">
<div class="section-header">
<h3 class="section-title">
<i class="pi pi-cog"></i>
Default File Naming Pattern
</h3>
<p class="section-description">
Define the default naming pattern for files. This pattern applies to all libraries unless overridden.
</p>
<div class="flex gap-4 items-center">
<input type="text" [(ngModel)]="defaultPattern" class="p-inputtext w-[700px]" placeholder="e.g., {title} - {authors}" (input)="onDefaultPatternChange(defaultPattern)"/>
<p-button label="Save" (onClick)="savePatterns()" severity="primary" outlined="true" [disabled]="!!defaultErrorMessage"></p-button>
</div>
@if (defaultErrorMessage) {
<div class="text-red-500 mt-1">{{ defaultErrorMessage }}</div>
}
<div class="flex gap-2 pt-2">
<p class="text-zinc-400">Preview:</p>
<p class="text-green-500">{{ generateDefaultPreview() }}</p>
</div>
</div>
<div>
<h2 class="py-2 text-lg">Overrides for Libraries:</h2>
<p class="mb-3 text-sm text-gray-400">
<div class="settings-card">
<div class="form-field">
<div class="library-header pb-2">
<i [class]="'pi pi-bolt'"></i>
<span class="library-name">Default Pattern</span>
</div>
<div class="pattern-input-group">
<input
pInputText
id="defaultPattern"
[(ngModel)]="defaultPattern"
placeholder="e.g., {title} - {authors}"
(input)="onDefaultPatternChange(defaultPattern)"
class="w-full"/>
<p-button
label="Save"
icon="pi pi-save"
(onClick)="savePatterns()"
severity="success"
[outlined]="true"
[disabled]="!!defaultErrorMessage">
</p-button>
</div>
@if (defaultErrorMessage) {
<div class="error-message">{{ defaultErrorMessage }}</div>
}
</div>
<div class="preview-section">
<div class="preview-item">
<span class="preview-label">Preview:</span>
<span class="preview-value">{{ generateDefaultPreview() }}</span>
</div>
</div>
</div>
</div>
<div class="preferences-section">
<div class="section-header">
<h3 class="section-title">
<i class="pi pi-folder"></i>
Library-Specific Overrides
</h3>
<p class="section-description">
Define custom naming patterns for specific libraries. Leave empty to use the default pattern.
</p>
@for (library of libraries; track library.id) {
<div class="flex flex-col gap-2 p-3">
<div class="flex gap-3 items-center">
<i [class]="'pi pi-' + library.icon"></i>
<span class="min-w-[100px] font-medium">{{ library.name }}</span>
<input
type="text"
[(ngModel)]="library.fileNamingPattern"
class="p-inputtext flex-1"
placeholder="Leave empty to use default pattern"
(input)="onLibraryPatternChange(library)"/>
<p-button
label="Clear"
(onClick)="clearLibraryPattern(library)"
severity="secondary"
outlined="true"
size="small"
[disabled]="!library.fileNamingPattern">
</p-button>
</div>
<div class="ml-8 flex gap-2">
<p class="text-sm text-zinc-400">Preview:</p>
<p class="text-sm text-green-500">{{ generateLibraryPreview(library) }}</p>
</div>
</div>
}
<div class="flex justify-end">
<p-button label="Save All Library Patterns" (onClick)="saveLibraryPatterns()" severity="primary" outlined="true"></p-button>
</div>
</div>
<div>
<p-divider></p-divider>
<div class="settings-card">
<div class="library-patterns">
@for (library of libraries; track library.id) {
<div class="library-pattern-item">
<div class="library-header">
<i [class]="'pi pi-' + library.icon"></i>
<span class="library-name">{{ library.name }}</span>
</div>
<div class="library-input-group">
<input
pInputText
[(ngModel)]="library.fileNamingPattern"
placeholder="Leave empty to use default pattern"
(input)="onLibraryPatternChange(library)"
class="w-full"/>
<p-button
label="Clear"
(onClick)="clearLibraryPattern(library)"
severity="secondary"
[outlined]="true"
[disabled]="!library.fileNamingPattern">
</p-button>
</div>
<div class="preview-section">
<span class="preview-label">Preview:</span>
<span class="preview-value">{{ generateLibraryPreview(library) }}</span>
</div>
</div>
}
</div>
<p class="pt-4">Available Placeholders:</p>
<div class="form-actions">
<p-button
label="Save All Library Patterns"
(onClick)="saveLibraryPatterns()"
severity="success"
icon="pi pi-save"
[outlined]="true">
</p-button>
</div>
</div>
</div>
<p class="text-sm text-gray-400 mb-2 mt-2">
<div class="preferences-section">
<div class="section-header">
<h3 class="section-title">
<i class="pi pi-info-circle"></i>
Available Placeholders
</h3>
<p class="section-description">
Use placeholders to dynamically insert book metadata into file names and folder paths. These will be replaced with actual values when uploading or moving files.
You can also wrap parts of the pattern in <code>{{ '{{<...>}' }}</code> to make them optional, if any placeholder inside is missing, that section will be omitted entirely.
</p>
</div>
<div class="mt-2">
<div class="mx-4 my-2 flex flex-wrap gap-4">
<ul class="list-disc pl-4 min-w-[250px] text-gray-300">
<li><code class="text-orange-400">{{ '{title}' }}</code> Book title</li>
<li><code class="text-orange-400">{{ '{authors}' }}</code> Author(s)</li>
<li><code class="text-orange-400">{{ '{year}' }}</code> Full year (e.g. 2025)</li>
<li><code class="text-orange-400">{{ '{series}' }}</code> Series name</li>
<li><code class="text-orange-400">{{ '{seriesIndex}' }}</code> Series index (e.g. 01)</li>
<li><code class="text-orange-400">{{ '{language}' }}</code> Language code (e.g. en)</li>
<li><code class="text-orange-400">{{ '{publisher}' }}</code> Publisher name</li>
<li><code class="text-orange-400">{{ '{isbn}' }}</code> ISBN number</li>
<li><code class="text-orange-400">{{ '{currentFilename}' }}</code> Original file name (with extension)</li>
</ul>
<div class="settings-card">
<div class="placeholders-info">
<div class="placeholders-grid">
<div class="placeholders-list">
<h4 class="subsection-title">Available Placeholders</h4>
<ul>
<li><code class="placeholder-code">{{ '{title}' }}</code> Book title</li>
<li><code class="placeholder-code">{{ '{authors}' }}</code> Author(s)</li>
<li><code class="placeholder-code">{{ '{year}' }}</code> Full year (e.g. 2025)</li>
<li><code class="placeholder-code">{{ '{series}' }}</code> Series name</li>
<li><code class="placeholder-code">{{ '{seriesIndex}' }}</code> Series index (e.g. 01)</li>
<li><code class="placeholder-code">{{ '{language}' }}</code> Language code (e.g. en)</li>
<li><code class="placeholder-code">{{ '{publisher}' }}</code> Publisher name</li>
<li><code class="placeholder-code">{{ '{isbn}' }}</code> ISBN number</li>
<li><code class="placeholder-code">{{ '{currentFilename}' }}</code> Original file name (with extension)</li>
</ul>
</div>
<p-divider layout="vertical"></p-divider>
<div class="text-sm p-3 min-w-[250px]">
<p class="mb-1 font-bold text-gray-200 text">Optional blocks</p>
<p class="text-gray-300">
<div class="optional-blocks-info">
<h4 class="subsection-title">Optional blocks</h4>
<p>
Surround parts of your pattern with angle brackets
<code class="text-blue-400">{{ '{<...>}' }}</code>
to make them optional. <br> If any placeholder inside the block has no value, the whole block is excluded.
<code class="placeholder-code">{{ '{<...>}' }}</code>
<br>
to make them optional. If any placeholder inside the block has no value, the whole block is excluded.
</p>
<p class="mt-2 font-semibold text-gray-300">Example:</p>
<p><code>{{ '{<{seriesIndex} - >{title}' }}</code></p>
<p class="pl-4 mt-1 text-gray-300"><code>01 - Dune</code> (if <code>{{ '{seriesIndex}' }}</code> exists)</p>
<p class="pl-4 text-gray-300"><code>Dune</code> (if <code>{{ '{seriesIndex}' }}</code> is missing)</p>
<div class="example-section">
<p class="example-label">Example:</p>
<p><code class="placeholder-code">{{ '{<{seriesIndex} - >{title}' }}</code></p>
<ul class="example-list">
<li><code>01 - Dune</code> (if <code class="placeholder-code">{{ '{seriesIndex}' }}</code> exists)</li>
<li><code>Dune</code> (if <code class="placeholder-code">{{ '{seriesIndex}' }}</code> is missing)</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<p-divider></p-divider>
<div class="preferences-section">
<div class="section-header">
<h3 class="section-title">
<i class="pi pi-eye"></i>
Example Patterns & Output
</h3>
<p class="section-description">
See how different patterns work with sample book metadata and learn best practices for organizing your library.
</p>
</div>
<div class="mt-6">
<p class="mb-4">Example Patterns & Output:</p>
<div class="px-4 text-sm space-y-4">
<p class="text-base font-semibold text-gray-200 mt-6 mb-2">Examples with Full Metadata</p>
<p class="text-gray-400 mb-4">
<span class="block">title: <code>Harry Potter and the Sorcerer's Stone</code></span>
<span class="block">authors: <code>J.K. Rowling</code></span>
<span class="block">series: <code>Harry Potter</code></span>
<span class="block">seriesIndex: <code>01</code></span>
<span class="block">year: <code>1997</code></span>
<span class="block">currentFilename: <code>harry1_original.epub</code></span>
</p>
<div>
<p class="mb-1"><strong class="text-gray-300">Basic pattern:</strong><code class="text-gray-400"> {{ '{authors} - {title}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> J.K. Rowling - Harry Potter and the Sorcerer's Stone.epub</code></p>
<div class="settings-card">
<div class="examples-section">
<div class="example-category">
<h4 class="subsection-title">Examples with Full Metadata</h4>
<div class="metadata-sample">
<span>title: <code>Harry Potter and the Sorcerer's Stone</code></span>
<span>authors: <code>J.K. Rowling</code></span>
<span>series: <code>Harry Potter</code></span>
<span>seriesIndex: <code>01</code></span>
<span>year: <code>1997</code></span>
<span>currentFilename: <code>harry1_original.epub</code></span>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Pattern with punctuation:</strong><code class="text-gray-400"> {{ '{title}: {series}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Harry Potter and the Sorcerer's Stone: Harry Potter.epub</code></p>
</div>
<div class="example-list">
<div class="example-item">
<p class="example-pattern"><strong>Basic pattern:</strong> <code>{{ '{authors} - {title}' }}</code></p>
<p class="example-output"><strong>Output:</strong> <code>J.K. Rowling - Harry Potter and the Sorcerer's Stone.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Series in folder path:</strong><code class="text-gray-400"> {{ '{authors}/{series}/{seriesIndex} - {title}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> J.K. Rowling/Harry Potter/01 - Harry Potter and the Sorcerer's Stone.epub</code></p>
</div>
<div class="example-item">
<p class="example-pattern"><strong>Pattern with punctuation:</strong> <code>{{ '{title}: {series}' }}</code></p>
<p class="example-output"><strong>Output:</strong> <code>Harry Potter and the Sorcerer's Stone: Harry Potter.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Folder only:</strong><code class="text-gray-400"> {{ '{title}/' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> /Harry Potter and the Sorcerer's Stone/harry1_original.epub</code></p>
</div>
<div class="example-item">
<p class="example-pattern"><strong>Series in folder path:</strong> <code>{{ '{authors}/{series}/{seriesIndex} - {title}' }}</code></p>
<p class="example-output"><strong>Output:</strong> <code>J.K. Rowling/Harry Potter/01 - Harry Potter and the Sorcerer's Stone.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Absolute path:</strong><code class="text-gray-400"> {{ '/{authors}/{title}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> /J.K. Rowling/Harry Potter and the Sorcerer's Stone.epub</code></p>
</div>
<div class="example-item">
<p class="example-pattern"><strong>Folder only:</strong> <code>{{ '{title}/' }}</code></p>
<p class="example-output"><strong>Output:</strong> <code>/Harry Potter and the Sorcerer's Stone/harry1_original.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Reuse original filename in path:</strong><code class="text-gray-400"> {{ '{authors}/{series}/{currentFilename}' }}</code>
</p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> J.K. Rowling/Harry Potter/harry1_original.epub</code></p>
</div>
<div class="example-item">
<p class="example-pattern"><strong>Absolute path:</strong> <code>{{ '/{authors}/{title}' }}</code></p>
<p class="example-output"><strong>Output:</strong> <code>/J.K. Rowling/Harry Potter and the Sorcerer's Stone.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Preserve original filename only:</strong><code class="text-gray-400"> {{ '{currentFilename}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> harry1_original.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Empty pattern (defaults to current filename):</strong><code class="text-gray-400"> {{ '' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> harry1_original.epub</code></p>
<div class="example-item">
<p class="example-pattern"><strong>Reuse original filename in path:</strong> <code>{{ '{authors}/{series}/{currentFilename}' }}</code></p>
<p class="example-output"><strong>Output:</strong> <code>J.K. Rowling/Harry Potter/harry1_original.epub</code></p>
</div>
</div>
</div>
<div class="px-4 text-sm space-y-4 mt-10">
<p class="text-base font-semibold text-gray-200 mb-2">Examples with Missing Optional Fields</p>
<p class="text-gray-400 mb-4">
<span class="block">title: <code>Project Hail Mary</code></span>
<span class="block">authors: <code>Andy Weir</code></span>
<span class="block">year: <code>2021</code></span>
<span class="block">series: <code>(not provided)</code></span>
<span class="block">seriesIndex: <code>(not provided)</code></span>
<span class="block">currentFilename: <code>project_hail_mary_final.epub</code></span>
</p>
<p-divider></p-divider>
<div>
<p class="mb-1"><strong class="text-gray-300">Pattern with optional blocks:</strong><code
class="text-gray-400"> {{ '{authors}/<{series}/><{seriesIndex}. >{title}< ({year})>' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Andy Weir/Project Hail Mary (2021).epub</code></p>
<div class="example-category">
<h4 class="subsection-title">Examples with Missing Optional Fields</h4>
<div class="metadata-sample">
<span>title: <code>Project Hail Mary</code></span>
<span>authors: <code>Andy Weir</code></span>
<span>year: <code>2021</code></span>
<span>series: <code>(not provided)</code></span>
<span>seriesIndex: <code>(not provided)</code></span>
<span>currentFilename: <code>project_hail_mary_final.epub</code></span>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Fallback with seriesIndex and dash:</strong><code
class="text-gray-400"> {{ '<{seriesIndex}. >{title}< - {authors}>' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Project Hail Mary - Andy Weir.epub</code></p>
</div>
<div class="example-list">
<div class="example-item">
<p class="example-pattern"><strong>Pattern with optional blocks:</strong> <code>{{ '{authors}/<{series}/><{seriesIndex}. >{title}< ({year})>' }}</code></p>
<p class="example-output"><strong>Output:</strong> <code>Andy Weir/Project Hail Mary (2021).epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Brackets & punctuation fallback:</strong><code class="text-gray-400"> {{ '<[{series}] >{title} - {authors}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Project Hail Mary - Andy Weir.epub</code></p>
</div>
<div class="example-item">
<p class="example-pattern"><strong>Fallback with seriesIndex and dash:</strong> <code>{{ '<{seriesIndex}. >{title}< - {authors}>' }}</code></p>
<p class="example-output"><strong>Output:</strong> <code>Project Hail Mary - Andy Weir.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Series + punctuation fallback:</strong><code class="text-gray-400"> {{ '<{series}: >{title} by {authors}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Project Hail Mary by Andy Weir.epub</code></p>
</div>
<div class="example-item">
<p class="example-pattern"><strong>Brackets & punctuation fallback:</strong> <code>{{ '<[{series}] >{title} - {authors}' }}</code></p>
<p class="example-output"><strong>Output:</strong> <code>Project Hail Mary - Andy Weir.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Only folders, no filename:</strong><code class="text-gray-400"> {{ '{authors}/' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Andy Weir/project_hail_mary_final.epub</code></p>
</div>
<div class="example-item">
<p class="example-pattern"><strong>Series + punctuation fallback:</strong> <code>{{ '<{series}: >{title} by {authors}' }}</code></p>
<p class="example-output"><strong>Output:</strong> <code>Project Hail Mary by Andy Weir.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Deep nested folders:</strong><code class="text-gray-400"> {{ '{authors}/books/<{series}/>{title}/' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Andy Weir/books/Project Hail Mary/project_hail_mary_final.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">With static folder prefix:</strong><code class="text-gray-400"> {{ 'Books/<{series}/>{authors} - {title}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Books/Andy Weir - Project Hail Mary.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Use original filename in path:</strong><code
class="text-gray-400"> {{ '{authors}/books/<{series}/>{currentFilename}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Andy Weir/books/project_hail_mary_final.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Prefix current filename manually:</strong><code class="text-gray-400"> {{ '{authors}/final__{currentFilename}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Andy Weir/final__project_hail_mary_final.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Filename preserved, renamed folder:</strong><code class="text-gray-400"> {{ '{title}/source/{currentFilename}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Project Hail Mary/source/project_hail_mary_final.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Use current filename with year suffix:</strong><code
class="text-gray-400"> {{ '{authors}/{year}__{currentFilename}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Andy Weir/2021__project_hail_mary_final.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Fallback with optional + extras folder:</strong><code
class="text-gray-400"> {{ '<{series}/>{authors}/extras/{currentFilename}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Andy Weir/extras/project_hail_mary_final.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Archive structure using original name:</strong><code
class="text-gray-400"> {{ 'archive/<{series}/>{year}/{currentFilename}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> archive/2021/project_hail_mary_final.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Structured folder + renamed file:</strong><code
class="text-gray-400"> {{ '{authors}/<{series}/>{title} ({year}) by {authors}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Andy Weir/Project Hail Mary (2021) by Andy Weir.epub</code></p>
<div class="example-item">
<p class="example-pattern"><strong>Use original filename with year suffix:</strong> <code>{{ '{authors}/{year}__{currentFilename}' }}</code></p>
<p class="example-output"><strong>Output:</strong> <code>Andy Weir/2021__project_hail_mary_final.epub</code></p>
</div>
</div>
</div>
</div>

View File

@@ -1,8 +1,408 @@
.enclosing-container {
border-color: var(--p-content-border-color);
.main-container {
width: 100%;
padding: 1rem;
height: calc(100dvh - 10.5rem);
overflow-y: auto;
border-width: 1px;
border-radius: 0.5rem;
@media (min-width: 768px) {
height: calc(100dvh - 11.65rem);
}
}
.custom-border {
border: 1px solid var(--border-color);
border-radius: var(--card-border);
.enclosing-container {
border-color: var(--p-content-border-color);
background: var(--p-content-background);
}
.settings-header {
margin-top: 1rem;
margin-bottom: 2rem;
}
.settings-title {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.25rem;
font-weight: 700;
color: var(--p-text-color);
margin: 0 0 0.75rem 0;
.pi {
color: var(--p-primary-color);
font-size: 1.25rem;
}
}
.settings-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin-bottom: 1rem;
}
.settings-content {
display: flex;
flex-direction: column;
gap: 3rem;
}
.preferences-section {
@media (min-width: 768px) {
padding: 0 1rem;
}
}
.section-header {
margin-bottom: 1rem;
}
.section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--p-text-color);
margin: 0 0 0.5rem 0;
.pi {
color: var(--p-primary-color);
}
}
.section-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
}
.settings-card {
border: 1px solid var(--p-content-border-color);
border-radius: 8px;
background: var(--p-content-background);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
@media (max-width: 767px) {
padding: 1rem;
gap: 1rem;
}
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
label {
font-weight: 600;
color: var(--p-text-color);
font-size: 0.875rem;
}
}
.pattern-input-group {
display: flex;
gap: 1rem;
align-items: flex-start;
@media (max-width: 768px) {
flex-direction: column;
gap: 0.75rem;
}
}
.error-message {
color: var(--p-red-500);
font-size: 0.875rem;
margin-top: 0.25rem;
}
.preview-section {
display: flex;
gap: 0.5rem;
align-items: center;
@media (max-width: 767px) {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
}
.preview-label {
color: var(--p-text-muted-color);
font-size: 0.875rem;
}
.preview-value {
color: var(--p-green-500);
font-size: 0.8rem;
font-family: monospace;
}
.library-patterns {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.library-pattern-item {
display: flex;
flex-direction: column;
gap: 0.75rem;
border: 1px solid var(--p-surface-800);
border-left: 3px solid var(--p-primary-color);
border-radius: 6px;
background: var(--p-surface-900);
padding: 1rem;
@media (max-width: 767px) {
padding: 0.75rem;
}
}
.library-header {
display: flex;
align-items: center;
gap: 0.5rem;
.pi {
color: var(--p-primary-color);
}
}
.library-name {
font-weight: 500;
color: var(--p-text-color);
min-width: 100px;
}
.library-input-group {
display: flex;
gap: 1rem;
align-items: flex-start;
@media (max-width: 768px) {
flex-direction: column;
gap: 0.75rem;
}
}
.form-actions {
display: flex;
justify-content: flex-end;
@media (max-width: 767px) {
justify-content: stretch;
p-button {
width: 100%;
}
}
}
.subsection-title {
font-size: 1rem;
font-weight: 600;
color: var(--p-text-color);
margin-bottom: 1rem;
}
.placeholders-info {
display: flex;
flex-direction: column;
gap: 1.5rem;
@media (max-width: 767px) {
gap: 1rem;
}
}
.placeholders-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
code {
padding: 0.125rem 0.25rem;
border-radius: 3px;
font-size: 0.75rem;
}
}
.placeholders-grid {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
@media (min-width: 768px) {
grid-template-columns: 1fr auto 1fr;
}
@media (max-width: 767px) {
gap: 1.5rem;
}
}
.placeholders-list {
ul {
list-style: disc;
padding-left: 1.5rem;
margin: 0;
@media (max-width: 767px) {
padding-left: 1rem;
}
li {
color: var(--p-text-color);
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
}
}
.placeholder-code {
color: var(--p-orange-400);
padding: 0.125rem 0.25rem;
border-radius: 3px;
font-size: 0.75rem;
font-family: monospace;
}
.optional-blocks-info {
p {
color: var(--p-text-color);
font-size: 0.875rem;
line-height: 1.5;
margin-bottom: 1rem;
}
}
.example-section {
margin-top: 1rem;
}
.example-label {
font-weight: 600;
color: var(--p-text-color);
margin-bottom: 0.5rem;
}
.example-list {
list-style: disc;
padding-left: 1.5rem;
@media (max-width: 767px) {
padding-left: 0;
list-style: none;
}
li {
color: var(--p-text-color);
font-size: 0.875rem;
margin-bottom: 0.25rem;
}
}
.examples-section {
display: flex;
flex-direction: column;
gap: 2rem;
@media (max-width: 767px) {
gap: 1.5rem;
}
}
.example-category {
display: flex;
flex-direction: column;
gap: 1rem;
}
.metadata-sample {
display: flex;
flex-direction: column;
gap: 0.25rem;
background: var(--p-surface-800);
padding: 1rem;
border-radius: 6px;
margin-bottom: 1rem;
@media (max-width: 767px) {
padding: 0.75rem;
}
span {
font-size: 0.875rem;
color: var(--p-text-muted-color);
code {
color: var(--p-text-color);
padding: 0.125rem 0.25rem;
border-radius: 3px;
font-size: 0.75rem;
}
}
}
.example-list {
display: flex;
flex-direction: column;
gap: 1rem;
@media (max-width: 767px) {
gap: 0.75rem;
}
}
.example-item {
background: var(--p-surface-800);
padding: 1rem;
border-radius: 6px;
border-left: 3px solid var(--p-primary-color);
@media (max-width: 767px) {
padding: 0.75rem;
}
}
.example-pattern {
font-size: 0.875rem;
margin-bottom: 0.5rem;
strong {
color: var(--p-text-color);
}
code {
color: var(--p-text-muted-color);
padding: 0.125rem 0.25rem;
border-radius: 3px;
font-size: 0.75rem;
}
}
.example-output {
font-size: 0.875rem;
margin: 0;
strong {
color: var(--p-text-color);
}
code {
color: var(--p-text-muted-color);
padding: 0.125rem 0.25rem;
border-radius: 3px;
font-size: 0.75rem;
}
}

View File

@@ -6,16 +6,16 @@ import {AppSettingsService} from '../../core/service/app-settings.service';
import {forkJoin, Observable, of} from 'rxjs';
import {AppSettingKey, AppSettings} from '../../core/model/app-settings.model';
import {catchError, filter, take} from 'rxjs/operators';
import {Divider} from 'primeng/divider';
import {Tooltip} from 'primeng/tooltip';
import {Library} from '../../book/model/library.model';
import {LibraryService} from '../../book/service/library.service';
import {InputText} from 'primeng/inputtext';
import {Divider} from 'primeng/divider';
@Component({
selector: 'app-file-naming-pattern',
templateUrl: './file-naming-pattern.component.html',
standalone: true,
imports: [FormsModule, Button, Divider, Tooltip],
imports: [FormsModule, Button, InputText, Divider],
styleUrls: ['./file-naming-pattern.component.scss'],
})
export class FileNamingPatternComponent implements OnInit {

View File

@@ -1,117 +1,148 @@
<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="px-4 pt-4 pb-2">
<div class="flex items-center gap-2 pb-4 pt-4">
<p class="text-lg ">Book Cover Image</p>
<i class="pi pi-info-circle text-sky-600"
pTooltip="Settings related to book cover images, such as resolution and regeneration options."
tooltipPosition="right"
style="cursor: pointer;">
</i>
</div>
<div class="grid grid-cols-[auto,1fr] pl-6 gap-y-4 gap-x-4 items-center">
<p class=" py-1">Regenerate Covers?
<i class="pi pi-info-circle text-sky-600"
pTooltip="Regenerates cover images for all EPUB and PDF books (excluding locked ones) from the embedded covers in the file."
tooltipPosition="right"
style="cursor: pointer;">
</i>
</p>
<p-button
label="Regenerate"
outlined="true"
(onClick)="regenerateCovers()"></p-button>
</div>
</div>
<div class="pt-2 pb-4">
<p-divider></p-divider>
</div>
<div class="grid grid-cols-[auto,1fr] pl-6 gap-y-4 gap-x-4 items-center">
<p class="text-lg pb-4">Auto Book Search
<i class="pi pi-info-circle text-sky-600"
pTooltip="Automatically attempts metadata matching when the book information panel is opened."
tooltipPosition="right"
style="cursor: pointer;">
</i>
<div class="main-container enclosing-container">
<div class="settings-header">
<h2 class="settings-title">
<i class="pi pi-cog"></i>
Global Preferences
</h2>
<p class="settings-description">
Configure global settings for your Booklore instance, including cover image handling, search preferences, and file upload limits.
</p>
<div class="flex gap-4 justify-start">
<p-toggleswitch
[(ngModel)]="toggles.autoBookSearch"
(onChange)="onToggleChange('autoBookSearch', $event.checked)"
/>
</div>
<p class="text-lg pb-4">Similar Book Recommendation
<i class="pi pi-info-circle text-sky-600"
pTooltip="Enables or disables similar book recommendations based on your library."
tooltipPosition="right"
style="cursor: pointer;">
</i>
</p>
<div class="flex gap-2 justify-start">
<p-toggleswitch
[(ngModel)]="toggles.similarBookRecommendation"
(onChange)="onToggleChange('similarBookRecommendation', $event.checked)"
/>
</div>
</div>
<div class="pt-2 pb-4">
<p-divider></p-divider>
</div>
<div class="grid grid-cols-[auto,1fr] pl-6 gap-y-4 gap-x-4 items-center">
<p class="text-lg pb-4">Max File Upload Size:
<i class="pi pi-info-circle text-sky-600"
pTooltip="Defines the maximum allowed size (in MB) for each uploaded file. Applies to EPUB, PDF, CBZ, CBR, and CB7 formats."
tooltipPosition="right"
style="cursor: pointer;">
</i>
</p>
<div class="flex gap-4 items-center">
<div class="relative w-28">
<input
type="text"
pInputText
[(ngModel)]="maxFileUploadSizeInMb"
class="pr-10 w-full min-w-24"
placeholder="Max size"/>
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 text-sm pointer-events-none">MB</span>
<div class="settings-content">
<div class="preferences-section">
<div class="section-header">
<h3 class="section-title">
<i class="pi pi-image"></i>
Book Cover Image
</h3>
</div>
<p-button label="Save" outlined (onClick)="saveFileSize()"></p-button>
<div class="text-sm text-gray-400 flex items-start gap-2">
<i class="pi pi-exclamation-triangle text-yellow-500 mt-0.5"></i>
<span>
Changes will take effect after restarting the server
</span>
<div class="settings-card">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Regenerate Covers</label>
<p-button
label="Regenerate"
icon="pi pi-refresh"
outlined="true"
size="small"
severity="info"
(onClick)="regenerateCovers()">
</p-button>
</div>
<p class="setting-description">
<i class="pi pi-info-circle"></i>
Regenerates cover images for all EPUB and PDF books (excluding locked ones) from the embedded covers in the file.
</p>
</div>
</div>
</div>
</div>
<p class="text-lg pb-4 ">CBX Cache Size:
<i class="pi pi-info-circle text-sky-600"
pTooltip="Limits the total size (in MB) of the CBX image cache. If exceeded, older cache entries are removed automatically."
tooltipPosition="right"
style="cursor: pointer;">
</i>
</p>
<div class="flex gap-4 items-center">
<div class="relative w-28">
<input
type="text"
pInputText
[(ngModel)]="cbxCacheValue"
class="pr-10 w-full min-w-24"
placeholder="Cache size"/>
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 text-sm pointer-events-none">MB</span>
<div class="preferences-section">
<div class="section-header">
<h3 class="section-title">
<i class="pi pi-search"></i>
Search & Recommendations
</h3>
</div>
<div class="settings-card">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Auto Book Search</label>
<p-toggleswitch
[(ngModel)]="toggles.autoBookSearch"
(onChange)="onToggleChange('autoBookSearch', $event.checked)">
</p-toggleswitch>
</div>
<p class="setting-description">
<i class="pi pi-info-circle"></i>
Automatically attempts metadata matching when the book information panel is opened.
</p>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Similar Book Recommendation</label>
<p-toggleswitch
[(ngModel)]="toggles.similarBookRecommendation"
(onChange)="onToggleChange('similarBookRecommendation', $event.checked)">
</p-toggleswitch>
</div>
<p class="setting-description">
<i class="pi pi-info-circle"></i>
Enables or disables similar book recommendations based on your library.
</p>
</div>
</div>
</div>
</div>
<div class="preferences-section">
<div class="section-header">
<h3 class="section-title">
<i class="pi pi-upload"></i>
File Management
</h3>
</div>
<div class="settings-card">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Max File Upload Size</label>
<div class="input-group">
<div class="input-with-unit">
<input
type="text"
pInputText
[(ngModel)]="maxFileUploadSizeInMb"
placeholder="Max size"/>
<span class="unit">MB</span>
</div>
<p-button label="Save" outlined severity="success" (onClick)="saveFileSize()"></p-button>
</div>
</div>
<p class="setting-description">
<i class="pi pi-info-circle"></i>
Defines the maximum allowed size (in MB) for each uploaded file. Applies to EPUB, PDF, CBZ, CBR, and CB7 formats.
</p>
<div class="warning-message">
<i class="pi pi-exclamation-triangle"></i>
<span>Changes will take effect after restarting the server</span>
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">CBX Cache Size</label>
<div class="input-group">
<div class="input-with-unit">
<input
type="text"
pInputText
[(ngModel)]="cbxCacheValue"
placeholder="Cache size"/>
<span class="unit">MB</span>
</div>
<p-button label="Save" severity="success" outlined (onClick)="saveCacheSize()"></p-button>
</div>
</div>
<p class="setting-description">
<i class="pi pi-info-circle"></i>
Limits the total size (in MB) of the CBX image cache. If exceeded, older cache entries are removed automatically.
</p>
</div>
</div>
</div>
<p-button label="Save" outlined (onClick)="saveCacheSize()"></p-button>
</div>
</div>
<div class="pt-2 pb-4">
<p-divider></p-divider>
</div>
</div>

View File

@@ -1,3 +1,217 @@
.main-container {
width: 100%;
padding: 1rem;
height: calc(100dvh - 10.5rem);
overflow-y: auto;
border-width: 1px;
border-radius: 0.5rem;
@media (min-width: 768px) {
height: calc(100dvh - 11.65rem);
}
}
.enclosing-container {
border-color: var(--p-content-border-color);
background: var(--p-content-background);
}
.settings-header {
margin-top: 1rem;
margin-bottom: 2rem;
}
.settings-title {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.25rem;
font-weight: 700;
color: var(--p-text-color);
margin: 0 0 0.75rem 0;
.pi {
color: var(--p-primary-color);
font-size: 1.25rem;
}
}
.settings-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin-bottom: 1rem;
}
.settings-content {
display: flex;
flex-direction: column;
gap: 2rem;
}
.preferences-section {
@media (min-width: 768px) {
padding: 0 1rem;
}
}
.section-header {
margin-bottom: 1rem;
}
.section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--p-text-color);
margin: 0;
.pi {
color: var(--p-primary-color);
}
}
.settings-card {
border: 1px solid var(--p-content-border-color);
border-radius: 8px;
background: var(--p-content-background);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.setting-item {
display: flex;
align-items: flex-start;
gap: 2rem;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
@media (max-width: 768px) {
flex-direction: column;
gap: 1rem;
}
}
.setting-info {
flex: 1;
min-width: 0;
.setting-label {
display: block;
font-weight: 600;
color: var(--p-text-color);
font-size: 1rem;
margin-bottom: 0.5rem;
}
.setting-label-row {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
.setting-label {
margin-bottom: 0;
flex-shrink: 0;
min-width: 180px;
}
.input-group {
display: flex;
align-items: center;
gap: 0.75rem;
@media (max-width: 768px) {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
}
}
.setting-description {
display: flex;
align-items: flex-start;
gap: 0.5rem;
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
.pi {
color: var(--p-primary-color);
margin-top: 0.125rem;
flex-shrink: 0;
}
}
}
.setting-control {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: flex-end;
@media (max-width: 768px) {
align-items: flex-start;
width: 100%;
}
}
.input-group {
display: flex;
align-items: center;
gap: 0.75rem;
@media (max-width: 768px) {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
}
.input-with-unit {
position: relative;
display: flex;
align-items: center;
input {
padding-right: 2.5rem;
width: 120px;
}
.unit {
position: absolute;
right: 0.75rem;
color: var(--p-text-muted-color);
font-size: 0.875rem;
pointer-events: none;
}
}
.warning-message {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
color: var(--p-text-muted-color);
margin-top: 0.5rem;
.pi {
color: var(--p-orange-500);
font-size: 0.875rem;
}
@media (max-width: 768px) {
margin-top: 0.5rem;
}
}

View File

@@ -1,10 +1,7 @@
import {Component, inject, OnInit} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {Observable} from 'rxjs';
import {Divider} from 'primeng/divider';
import {Button} from 'primeng/button';
import {Tooltip} from 'primeng/tooltip';
import {ToggleSwitch} from 'primeng/toggleswitch';
import {MessageService} from 'primeng/api';
@@ -18,9 +15,7 @@ import {InputText} from 'primeng/inputtext';
selector: 'app-global-preferences',
standalone: true,
imports: [
Divider,
Button,
Tooltip,
ToggleSwitch,
FormsModule,
InputText

View File

@@ -53,6 +53,7 @@
<div class="mt-4 flex justify-end">
<p-button
type="button"
severity="success"
label="Save"
icon="pi pi-save"
outlined

View File

@@ -1,112 +1,131 @@
<p class="text-lg flex items-center gap-2">
Enabled Metadata Providers:
<i
class="pi pi-info-circle text-sky-600"
pTooltip="Enable or configure third-party metadata sources for enhanced book information."
tooltipPosition="right"
style="cursor: pointer;">
</i>
</p>
<div class="metadata-settings">
<div class="setting-item">
<div class="setting-info">
<label class="setting-label">Enabled Metadata Providers</label>
<p class="setting-description">
<i class="pi pi-info-circle"></i>
Enable or configure third-party metadata sources for enhanced book information. Each provider offers different types of data and coverage.
</p>
</div>
</div>
<div class="flex flex-col gap-4 p-4">
<div class="providers-list">
<div class="provider-item">
<div class="provider-control">
<div class="provider-info">
<p-checkbox [(ngModel)]="amazonEnabled" [binary]="true"></p-checkbox>
<label class="provider-label">Amazon</label>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-4">
<p-checkbox [(ngModel)]="amazonEnabled" [binary]="true"></p-checkbox>
<label class="font-medium">Amazon</label>
<div class="provider-config">
<div class="config-field">
<label class="config-label">Region</label>
<p-select
[options]="amazonDomains"
[(ngModel)]="selectedAmazonDomain"
placeholder="Select Domain"
class="config-select">
</p-select>
</div>
<div class="config-field">
<label class="config-label">Cookie (Optional)</label>
<p class="config-description">
<i class="pi pi-info-circle"></i>
Providing your Amazon session cookie allows the app to access richer book metadata and bypass rate limits. Optional but recommended for best results.
</p>
<input
type="text"
pInputText
placeholder="Paste your Amazon cookie"
class="config-input"
[(ngModel)]="amazonCookie" />
</div>
</div>
</div>
<div class="flex flex-col gap-2 pl-9 pt-1">
<label class="font-medium text-sm">Region</label>
<p-select
[options]="amazonDomains"
[(ngModel)]="selectedAmazonDomain"
placeholder="Select Domain"
class="w-full max-w-52">
</p-select>
<div class="provider-item">
<div class="provider-control">
<div class="provider-info">
<p-checkbox [(ngModel)]="goodreadsEnabled" [binary]="true"></p-checkbox>
<label class="provider-label">Goodreads</label>
</div>
</div>
</div>
<label class="font-medium text-sm">Cookie (Optional)</label>
<div class="flex items-center gap-2">
<input
type="text"
pInputText
placeholder="Paste your Amazon cookie"
class="w-full max-w-3xl"
[(ngModel)]="amazonCookie" />
<i
class="pi pi-info-circle text-sky-600"
pTooltip="Providing your Amazon session cookie allows the app to access richer book metadata and bypass rate limits. Optional but recommended for best results."
tooltipPosition="right"
style="cursor: pointer;">
</i>
<div class="provider-item">
<div class="provider-control">
<div class="provider-info">
<p-checkbox [(ngModel)]="googleEnabled" [binary]="true"></p-checkbox>
<label class="provider-label">Google</label>
</div>
</div>
</div>
<div class="provider-item">
<div class="provider-control">
<div class="provider-info">
<p-checkbox
[binary]="true"
[(ngModel)]="hardcoverEnabled"
[disabled]="!hardcoverToken">
</p-checkbox>
<label class="provider-label">Hardcover</label>
</div>
</div>
<div class="provider-config">
<div class="config-field">
<label class="config-label">API Token</label>
<input
type="text"
pInputText
placeholder="Enter Hardcover API token"
class="config-input"
[(ngModel)]="hardcoverToken"
(ngModelChange)="onTokenChange($event)" />
</div>
</div>
</div>
<div class="provider-item">
<div class="provider-control">
<div class="provider-info">
<p-checkbox
[binary]="true"
[(ngModel)]="comicvineEnabled"
[disabled]="!comicvineToken">
</p-checkbox>
<label class="provider-label">Comic Vine</label>
</div>
</div>
<div class="provider-config">
<div class="config-field">
<label class="config-label">API Token</label>
<input
type="text"
pInputText
placeholder="Enter Comic Vine API token"
class="config-input"
[(ngModel)]="comicvineToken"
(ngModelChange)="onComicTokenChange($event)" />
</div>
</div>
</div>
<div class="provider-item">
<div class="provider-control">
<div class="provider-info">
<p-checkbox [(ngModel)]="doubanEnabled" [binary]="true"></p-checkbox>
<label class="provider-label">Douban</label>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-4">
<p-checkbox [(ngModel)]="goodreadsEnabled" [binary]="true"></p-checkbox>
<label class="font-medium">Goodreads</label>
<div class="setting-actions">
<p-button label="Save" icon="pi pi-save" severity="success" [outlined]="true" (click)="saveSettings()"></p-button>
</div>
<div class="flex items-center gap-4">
<p-checkbox [(ngModel)]="googleEnabled" [binary]="true"></p-checkbox>
<label class="font-medium">Google</label>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-4">
<p-checkbox
[binary]="true"
[(ngModel)]="hardcoverEnabled"
[disabled]="!hardcoverToken">
</p-checkbox>
<label class="font-medium">Hardcover</label>
</div>
<div class="flex flex-col gap-2 pl-9 pt-1">
<label class="font-medium text-sm">API Token</label>
<input
type="text"
pInputText
placeholder="Enter Hardcover API token"
class="w-full max-w-3xl"
[(ngModel)]="hardcoverToken"
(ngModelChange)="onTokenChange($event)" />
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-4">
<p-checkbox
[binary]="true"
[(ngModel)]="comicvineEnabled"
[disabled]="!comicvineToken">
</p-checkbox>
<label class="font-medium">Comic Vine</label>
</div>
<div class="flex flex-col gap-2 pl-9 pt-1">
<label class="font-medium text-sm">API Token</label>
<input
type="text"
pInputText
placeholder="Enter Comic Vine API token"
class="w-full max-w-3xl"
[(ngModel)]="comicvineToken"
(ngModelChange)="onComicTokenChange($event)" />
</div>
</div>
<div class="flex items-center gap-4">
<p-checkbox [(ngModel)]="doubanEnabled" [binary]="true"></p-checkbox>
<label class="font-medium">Douban</label>
</div>
</div>
<div class="flex justify-start px-4">
<p-button label="Save" icon="pi pi-save" [outlined]="true" (click)="saveSettings()"></p-button>
</div>

View File

@@ -0,0 +1,137 @@
.metadata-settings {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.setting-item {
display: flex;
align-items: flex-start;
gap: 2rem;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
@media (max-width: 768px) {
flex-direction: column;
gap: 1rem;
}
}
.setting-info {
flex: 1;
min-width: 0;
.setting-label {
display: block;
font-weight: 600;
color: var(--p-text-color);
font-size: 1rem;
margin-bottom: 0.5rem;
}
.setting-description {
display: flex;
align-items: flex-start;
gap: 0.5rem;
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
.pi {
margin-top: 0.125rem;
flex-shrink: 0;
color: var(--p-primary-color);
}
}
}
.providers-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.provider-control {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
@media (max-width: 768px) {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
}
.provider-info {
display: flex;
align-items: center;
gap: 0.75rem;
.provider-label {
font-weight: 500;
color: var(--p-text-color);
}
}
.provider-config {
margin-top: 1rem;
padding-left: 2rem;
display: flex;
flex-direction: column;
gap: 1rem;
@media (max-width: 768px) {
padding-left: 0;
}
}
.config-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
.config-label {
font-weight: 500;
color: var(--p-text-color);
font-size: 0.875rem;
}
.config-description {
display: flex;
align-items: flex-start;
gap: 0.5rem;
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
.pi {
margin-top: 0.125rem;
flex-shrink: 0;
color: var(--p-primary-color);
}
}
.config-input {
width: 100%;
max-width: 24rem;
}
.config-select {
width: 100%;
max-width: 13rem;
}
}
.setting-actions {
display: flex;
justify-content: flex-start;
padding-top: 1rem;
}

View File

@@ -1,7 +1,6 @@
import {Component, inject, OnInit} from '@angular/core';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {TableModule} from 'primeng/table';
import {Tooltip} from 'primeng/tooltip';
import {Checkbox} from 'primeng/checkbox';
import {InputText} from 'primeng/inputtext';
import {Button} from 'primeng/button';
@@ -16,7 +15,6 @@ import {Select} from 'primeng/select';
imports: [
ReactiveFormsModule,
TableModule,
Tooltip,
Checkbox,
InputText,
Button,
@@ -112,12 +110,12 @@ export class MetadataProviderSettingsComponent implements OnInit {
cookie: this.amazonCookie,
domain: this.selectedAmazonDomain
},
comicvine: {
enabled: this.comicvineEnabled,
apiKey: this.comicvineToken.trim()
},
goodReads: {enabled: this.goodreadsEnabled},
google: {enabled: this.googleEnabled},
hardcover: {

View File

@@ -1,47 +1,60 @@
<div class="p-4 min-w-[50rem]">
<p class="text-lg pb-4 pt-2">Metadata Persistence:</p>
<div class="flex flex-col gap-4 pl-6 ">
<div class="preferences-section">
<div class="section-header">
<h3 class="section-title">
<i class="pi pi-save"></i>
Metadata Persistence
</h3>
</div>
<div class="pl-4 md:pl-6 border-l-2 border-zinc-600">
<div class="flex flex-col gap-4">
<div class="flex items-center gap-4">
<label>Write to File</label>
<div class="settings-card">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Write to File</label>
<p-toggleswitch
[ngModel]="metadataPersistence.saveToOriginalFile"
(onChange)="onPersistenceToggle('saveToOriginalFile')">
</p-toggleswitch>
<div class="text-sm text-gray-400 flex items-start gap-2">
<i class="pi pi-exclamation-triangle text-yellow-500 mt-0.5"></i>
<span>
Writes metadata directly into the original file. A backup of the metadata and/or cover is created <b>only if enabled below</b>. Proceed with caution, restoration may fail if the file is moved or renamed.
</span>
</div>
</div>
<div class="flex items-center gap-4">
<label>Backup Metadata</label>
<p class="setting-description">
<i class="pi pi-exclamation-triangle"></i>
Writes metadata directly into the original file. A backup of the metadata and/or cover is created only if enabled below. Proceed with caution, restoration may fail if the file is moved or renamed.
</p>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Backup Metadata</label>
<p-toggleswitch
[ngModel]="metadataPersistence.backupMetadata"
(onChange)="onPersistenceToggle('backupMetadata')"
[disabled]="!metadataPersistence.saveToOriginalFile">
</p-toggleswitch>
<span class="text-sm text-gray-400">
Save a JSON copy of the current metadata before writing new data to the file.
</span>
</div>
<p class="setting-description">
<i class="pi pi-info-circle"></i>
Save a JSON copy of the current metadata before writing new data to the file.
</p>
</div>
</div>
<div class="flex items-center gap-4">
<label>Backup Cover</label>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Backup Cover</label>
<p-toggleswitch
[ngModel]="metadataPersistence.backupCover"
(onChange)="onPersistenceToggle('backupCover')"
[disabled]="!metadataPersistence.saveToOriginalFile">
</p-toggleswitch>
<span class="text-sm text-gray-400">
Save a copy of the existing embedded cover image before it is replaced.
</span>
</div>
<p class="setting-description">
<i class="pi pi-info-circle"></i>
Save a copy of the existing embedded cover image before it is replaced.
</p>
</div>
</div>
</div>
</div>

View File

@@ -1,3 +1,115 @@
.enclosing-container {
border-color: var(--p-content-border-color);
}
.preferences-section {
@media (min-width: 768px) {
padding: 0 1rem;
}
}
.section-header {
margin-bottom: 1rem;
}
.section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--p-text-color);
margin: 0;
.pi {
color: var(--p-primary-color);
}
}
.settings-card {
border: 1px solid var(--p-content-border-color);
border-radius: 8px;
background: var(--p-content-background);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.setting-item {
display: flex;
align-items: flex-start;
gap: 2rem;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
@media (max-width: 768px) {
flex-direction: column;
gap: 1rem;
}
}
.setting-info {
flex: 1;
min-width: 0;
.setting-label {
display: block;
font-weight: 600;
color: var(--p-text-color);
font-size: 1rem;
margin-bottom: 0.5rem;
}
.setting-label-row {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
.setting-label {
margin-bottom: 0;
flex-shrink: 0;
}
}
.setting-description {
display: flex;
align-items: flex-start;
gap: 0.5rem;
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
.pi {
margin-top: 0.125rem;
flex-shrink: 0;
}
.pi-exclamation-triangle {
color: var(--p-orange-500);
}
.pi-info-circle {
color: var(--p-primary-color);
}
}
}
.setting-control {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: flex-end;
@media (max-width: 768px) {
align-items: flex-start;
width: 100%;
}
}

View File

@@ -1,73 +1,103 @@
<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="main-container enclosing-container">
<div class="settings-header">
<h2 class="settings-title">
<i class="pi pi-database"></i>
Metadata Settings
</h2>
<p class="settings-description">
Configure how metadata is retrieved, stored, and managed for your books. Set up automatic downloads, persistence options, and provider preferences.
</p>
</div>
<div class="p-4 pt-6 min-w-[50rem]">
<p class="text-lg pb-4 pt-2">Auto-Download Metadata for Files in BookDrop Folder:</p>
<div class="flex flex-col gap-4 pl-6">
<div class="flex items-center gap-4">
<p-toggleswitch
[(ngModel)]="metadataDownloadOnBookdrop"
(onChange)="onMetadataDownloadOnBookdropToggle($event.checked)">
</p-toggleswitch>
<div class="text-sm text-gray-400 flex items-start gap-2">
<i class="pi pi-exclamation-triangle text-yellow-500 mt-0.5"></i>
<span>
Automatically downloads metadata from your configured sources (Amazon, Goodreads, etc.) when files are added to the Bookdrop folder. Use with caution if adding many files at once as metadata fetching can take time.
</span>
<div class="settings-content">
<div class="preferences-section">
<div class="section-header">
<h3 class="section-title">
<i class="pi pi-download"></i>
Auto-Download Metadata
</h3>
</div>
<div class="settings-card">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Auto-Download for BookDrop Files</label>
<p-toggleswitch
[(ngModel)]="metadataDownloadOnBookdrop"
(onChange)="onMetadataDownloadOnBookdropToggle($event.checked)">
</p-toggleswitch>
</div>
<p class="setting-description">
<i class="pi pi-exclamation-triangle"></i>
Automatically downloads metadata from your configured sources (Amazon, Goodreads, etc.) when files are added to the Bookdrop folder. Use with caution if adding many files at once as metadata fetching can take time.
</p>
</div>
</div>
</div>
</div>
</div>
<div class="pb-4">
<p-divider></p-divider>
</div>
<div class="metadata-persistence-section">
<app-metadata-persistence-settings-component></app-metadata-persistence-settings-component>
</div>
<div>
<app-metadata-persistence-settings-component></app-metadata-persistence-settings-component>
</div>
<div class="preferences-section">
<div class="section-header">
<h3 class="section-title">
<i class="pi pi-cloud"></i>
Metadata Providers
</h3>
</div>
<div class="pb-4">
<p-divider></p-divider>
</div>
<div class="settings-card">
<app-metadata-provider-settings></app-metadata-provider-settings>
</div>
</div>
<div class="p-4">
<app-metadata-provider-settings></app-metadata-provider-settings>
</div>
<div class="preferences-section">
<div class="section-header">
<h3 class="section-title">
<i class="pi pi-star"></i>
Public Reviews
</h3>
</div>
<div class="pb-4">
<p-divider></p-divider>
</div>
<div class="settings-card">
<app-public-reviews-settings-component></app-public-reviews-settings-component>
</div>
</div>
<div>
<app-public-reviews-settings-component></app-public-reviews-settings-component>
</div>
<div class="preferences-section">
<div class="section-header">
<h3 class="section-title">
<i class="pi pi-search"></i>
Quick Book Match Preferences
</h3>
<p class="section-description">
Choose how metadata fields (title, description, authors, categories, cover) are retrieved by setting priority providers. You can apply the same provider settings to all fields or customize them individually. Enable 'Refresh Covers' to update book covers, and 'Merge Categories' to consolidate categories/genres from all sources while preserving the existing categories/genres in the book.
</p>
</div>
<div class="pb-4">
<p-divider></p-divider>
</div>
<div class="settings-card">
<app-metadata-advanced-fetch-options
(metadataOptionsSubmitted)="onMetadataSubmit($event)"
[currentMetadataOptions]="currentMetadataOptions"
[submitButtonLabel]="'Save'">
</app-metadata-advanced-fetch-options>
</div>
</div>
<div class="p-4">
<p class="text-lg ">
Quick Book Match Preferences
<i class="pi pi-info-circle text-sky-600"
pTooltip="Choose how metadata fields (title, description, authors, categories, cover) are retrieved by setting priority providers. You can apply the same provider settings to all fields or customize them individually. Enable 'Refresh Covers' to update book covers, and 'Merge Categories' to consolidate categories/genres from all sources while preserving the existing categories/genres in the book."
tooltipPosition="right"
style="cursor: pointer;">
</i>
</p>
<app-metadata-advanced-fetch-options
(metadataOptionsSubmitted)="onMetadataSubmit($event)"
[currentMetadataOptions]="currentMetadataOptions"
[submitButtonLabel]="'Save'">
</app-metadata-advanced-fetch-options>
</div>
<div class="preferences-section">
<div class="section-header">
<h3 class="section-title">
<i class="pi pi-sliders-h"></i>
Metadata Match Weights
</h3>
</div>
<div class="pb-4">
<p-divider></p-divider>
<div class="settings-card">
<app-metadata-match-weights-component></app-metadata-match-weights-component>
</div>
</div>
</div>
<div class="p-4 pt-4">
<app-metadata-match-weights-component></app-metadata-match-weights-component>
</div>
</div>

View File

@@ -1,3 +1,179 @@
.main-container {
width: 100%;
padding: 1rem;
height: calc(100dvh - 10.5rem);
overflow-y: auto;
border-width: 1px;
border-radius: 0.5rem;
@media (min-width: 768px) {
height: calc(100dvh - 11.65rem);
}
}
.enclosing-container {
border-color: var(--p-content-border-color);
background: var(--p-content-background);
}
.settings-header {
margin-top: 1rem;
margin-bottom: 2rem;
}
.settings-title {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.25rem;
font-weight: 700;
color: var(--p-text-color);
margin: 0 0 0.75rem 0;
.pi {
color: var(--p-primary-color);
font-size: 1.25rem;
}
}
.settings-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin-bottom: 1rem;
}
.settings-content {
display: flex;
flex-direction: column;
gap: 2rem;
}
.preferences-section {
@media (min-width: 768px) {
padding: 0 1rem;
}
}
.section-header {
margin-bottom: 1rem;
.section-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin: 0.5rem 0 0 0;
display: flex;
align-items: flex-start;
gap: 0.5rem;
.pi {
color: var(--p-primary-color);
margin-top: 0.125rem;
flex-shrink: 0;
}
}
}
.section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--p-text-color);
margin: 0;
.pi {
color: var(--p-primary-color);
}
}
.settings-card {
border: 1px solid var(--p-content-border-color);
border-radius: 8px;
background: var(--p-content-background);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.setting-item {
display: flex;
align-items: flex-start;
gap: 2rem;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
@media (max-width: 768px) {
flex-direction: column;
gap: 1rem;
}
}
.setting-info {
flex: 1;
min-width: 0;
.setting-label {
display: block;
font-weight: 600;
color: var(--p-text-color);
font-size: 1rem;
margin-bottom: 0.5rem;
}
.setting-label-row {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
.setting-label {
margin-bottom: 0;
flex-shrink: 0;
}
}
.setting-description {
display: flex;
align-items: flex-start;
gap: 0.5rem;
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
.pi {
margin-top: 0.125rem;
flex-shrink: 0;
}
.pi-exclamation-triangle {
color: var(--p-orange-500);
}
.pi-info-circle {
color: var(--p-primary-color);
}
}
}
.setting-control {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: flex-end;
@media (max-width: 768px) {
align-items: flex-start;
width: 100%;
}
}

View File

@@ -1,9 +1,7 @@
import {Component, inject, OnInit} from '@angular/core';
import {Divider} from 'primeng/divider';
import {MetadataAdvancedFetchOptionsComponent} from '../../metadata/metadata-options-dialog/metadata-advanced-fetch-options/metadata-advanced-fetch-options.component';
import {MetadataProviderSettingsComponent} from '../global-preferences/metadata-provider-settings/metadata-provider-settings.component';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {Tooltip} from 'primeng/tooltip';
import {MetadataRefreshOptions} from '../../metadata/model/request/metadata-refresh-options.model';
import {AppSettingsService} from '../../core/service/app-settings.service';
import {SettingsHelperService} from '../../core/service/settings-helper.service';
@@ -19,11 +17,9 @@ import {PublicReviewsSettingsComponent} from './public-reviews-settings-componen
selector: 'app-metadata-settings-component',
standalone: true,
imports: [
Divider,
MetadataAdvancedFetchOptionsComponent,
MetadataProviderSettingsComponent,
ReactiveFormsModule,
Tooltip,
FormsModule,
MetadataMatchWeightsComponent,
ToggleSwitch,

View File

@@ -1,59 +1,64 @@
<div class="p-4 min-w-[50rem]">
<p class="text-lg pb-4 pt-2">Public Reviews:</p>
<div class="flex flex-col gap-4 pl-6">
<div class="flex items-center gap-4">
<label>Download Public Reviews</label>
<p-toggleswitch
[(ngModel)]="publicReviewSettings.downloadEnabled"
(onChange)="onPublicReviewsToggle($event.checked)">
</p-toggleswitch>
<span class="text-sm text-gray-400">
Automatically download user reviews from enabled platforms (Amazon, Goodreads, Hardcover) when fetching book metadata. Reviews will be stored with your books for offline access.
</span>
</div>
@if (publicReviewSettings.downloadEnabled) {
<div class="pl-4 md:pl-6 border-l-2 border-zinc-600">
<div class="space-y-4">
<div class="space-y-2">
<p class="text-md font-medium text-gray-300">Review Sources:</p>
<p class="text-sm text-gray-400">Configure which platforms to download reviews from and set limits for each source.</p>
</div>
@for (provider of publicReviewSettings.providers; track provider.provider) {
<div class="flex items-center justify-between gap-4 p-2 bg-zinc-800/50 rounded-lg border border-zinc-700 max-w-[22rem]">
<div class="flex items-center gap-2 min-w-[8rem]">
<p-toggleswitch
[(ngModel)]="provider.enabled"
(onChange)="onProviderToggle(provider.provider, $event.checked)">
</p-toggleswitch>
<label class="font-medium text-gray-200">{{ provider.provider }}</label>
</div>
@if (provider.enabled) {
<div class="flex items-center gap-2">
<label class="text-sm text-gray-400">Max Reviews:</label>
<input
type="number"
class="w-20 px-2 py-1 border border-gray-600 bg-gray-800 text-gray-200 rounded text-sm focus:border-blue-500 focus:outline-none"
min="1"
max="10"
[(ngModel)]="provider.maxReviews"
(blur)="onMaxReviewsChange(provider.provider, provider.maxReviews)"
title="Number of reviews to download (1-10)">
</div>
}
</div>
}
@if (publicReviewSettings.providers.length === 0) {
<div class="text-sm text-gray-500 italic">
No review sources available. Please ensure metadata providers are configured in the Provider Settings section above.
</div>
}
</div>
<div class="reviews-settings">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Download Public Reviews</label>
<p-toggleswitch
[(ngModel)]="publicReviewSettings.downloadEnabled"
(onChange)="onPublicReviewsToggle($event.checked)">
</p-toggleswitch>
</div>
}
<p class="setting-description">
<i class="pi pi-info-circle"></i>
Automatically download user reviews from enabled platforms (Amazon, Goodreads, Hardcover) when fetching book metadata. Reviews will be stored with your books for offline access.
</p>
</div>
</div>
@if (publicReviewSettings.downloadEnabled) {
<div class="review-sources-section">
<div class="sources-header">
<h4 class="subsection-title">Review Sources</h4>
<p class="subsection-description">Configure which platforms to download reviews from and set limits for each source.</p>
</div>
<div class="providers-list">
@for (provider of publicReviewSettings.providers; track provider.provider) {
<div class="provider-item">
<div class="provider-setting">
<div class="provider-label-row">
<label class="provider-label">{{ provider.provider }}</label>
<div class="provider-controls">
<p-toggleswitch
[(ngModel)]="provider.enabled"
(onChange)="onProviderToggle(provider.provider, $event.checked)">
</p-toggleswitch>
@if (provider.enabled) {
<div class="max-reviews-control">
<label class="max-reviews-label">Max Reviews:</label>
<input
type="number"
class="max-reviews-input"
min="1"
max="10"
[(ngModel)]="provider.maxReviews"
(blur)="onMaxReviewsChange(provider.provider, provider.maxReviews)"
title="Number of reviews to download (1-10)">
</div>
}
</div>
</div>
</div>
</div>
}
@if (publicReviewSettings.providers.length === 0) {
<div class="no-providers-message">
<i class="pi pi-exclamation-triangle"></i>
<span>No review sources available. Please ensure metadata providers are configured in the Provider Settings section above.</span>
</div>
}
</div>
</div>
}
</div>

View File

@@ -0,0 +1,240 @@
.reviews-settings {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.setting-item {
display: flex;
align-items: flex-start;
gap: 2rem;
padding-bottom: 1.5rem;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
@media (max-width: 768px) {
flex-direction: column;
gap: 1rem;
}
}
.setting-info {
flex: 1;
min-width: 0;
.setting-label {
display: block;
font-weight: 600;
color: var(--p-text-color);
font-size: 1rem;
margin-bottom: 0.5rem;
}
.setting-label-row {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
.setting-label {
margin-bottom: 0;
flex-shrink: 0;
}
}
.setting-description {
display: flex;
align-items: flex-start;
gap: 0.5rem;
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
.pi {
margin-top: 0.125rem;
flex-shrink: 0;
color: var(--p-primary-color);
}
}
}
.setting-control {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: flex-end;
@media (max-width: 768px) {
align-items: flex-start;
width: 100%;
}
}
.review-sources-section {
padding-left: 2rem;
margin-top: -1rem;
display: flex;
flex-direction: column;
gap: 1rem;
@media (max-width: 768px) {
padding-left: 0;
}
}
.sources-header {
.subsection-title {
font-size: 1rem;
font-weight: 600;
color: var(--p-text-color);
margin-bottom: 0.5rem;
}
.subsection-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
}
}
.providers-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.provider-item {
width: 25rem;
border-radius: 6px;
padding: 0.75rem;
background: var(--p-surface-800);
@media (max-width: 768px) {
width: 100%;
max-width: none;
}
}
.provider-setting {
display: flex;
align-items: flex-start;
gap: 1rem;
@media (max-width: 768px) {
flex-direction: column;
gap: 0.75rem;
}
}
.provider-label-row {
display: flex;
align-items: center;
@media (max-width: 768px) {
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
}
.provider-label {
font-weight: 500;
width: 6rem;
color: var(--p-text-color);
flex-shrink: 0;
@media (max-width: 768px) {
width: auto;
margin-bottom: 0.5rem;
}
}
.provider-controls {
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0;
@media (max-width: 768px) {
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
}
}
}
.provider-control {
display: flex;
align-items: center;
gap: 1rem;
@media (max-width: 768px) {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
}
.provider-info {
display: flex;
align-items: center;
gap: 0.75rem;
.provider-label {
font-weight: 500;
color: var(--p-text-color);
}
}
.max-reviews-control {
display: flex;
align-items: center;
gap: 0.5rem;
@media (max-width: 768px) {
justify-content: space-between;
}
.max-reviews-label {
color: var(--p-text-muted-color);
font-size: 0.875rem;
white-space: nowrap;
}
.max-reviews-input {
width: 4rem;
padding: 0.25rem;
border-radius: 4px;
color: var(--p-text-color);
font-size: 0.875rem;
text-align: center;
&:focus {
outline: none;
border-color: var(--p-primary-color);
box-shadow: 0 0 0 2px var(--p-primary-color-alpha-20);
}
}
}
.no-providers-message {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--p-text-muted-color);
font-size: 0.875rem;
font-style: italic;
padding: 1rem;
background: var(--p-surface-100);
border-radius: 6px;
.pi {
color: var(--p-orange-500);
}
}

View File

@@ -143,7 +143,7 @@
</div>
<p-dialog
header="Create New User"
header="Create User"
[(visible)]="showCreateUserDialog"
[modal]="true"
styleClass="user-dialog"

View File

@@ -56,7 +56,6 @@
}
.p-datatable-tbody > tr {
border-bottom: 1px solid var(--p-surface-border);
transition: background-color 0.2s;
&:hover {
@@ -112,7 +111,7 @@
color: var(--p-text-muted-color);
.pi {
font-size: 3rem;
font-size: 2rem;
margin-bottom: 1rem;
color: var(--p-surface-400);
}
@@ -130,7 +129,6 @@
.user-dialog {
.p-dialog-header {
border-bottom: 1px solid var(--p-surface-border);
}
.p-dialog-content {
@@ -138,7 +136,6 @@
}
.p-dialog-footer {
border-top: 1px solid var(--p-surface-border);
padding: 1rem 1.5rem;
}
}
@@ -186,7 +183,6 @@
.settings-header {
margin-top: 1rem;
margin-bottom: 2rem;
border-bottom: 1px solid var(--p-surface-border);
}
.settings-title {
@@ -218,11 +214,10 @@
.endpoint-section {
background: var(--p-content-background);
border: 1px solid var(--p-surface-border);
border-radius: 8px;
@media (min-width: 768px) {
padding: 1rem;
padding: 0 1rem 0 1rem;
}
}

View File

@@ -1,110 +1,169 @@
<div class="w-full h-[calc(100dvh-10.5rem)] md:h-[calc(100dvh-11.65rem)] overflow-y-auto border rounded-lg enclosing-container">
<p class="text-lg flex items-center gap-2 px-4 pt-4 pb-2">
OPDS Server Settings
<i
class="pi pi-info-circle text-sky-600"
pTooltip="OPDS allows your book collection to be accessed by compatible reading apps through your private server."
tooltipPosition="right"
style="cursor: pointer;">
</i>
</p>
<div class="main-container enclosing-container">
<div class="settings-header">
<h2 class="settings-title">
<i class="pi pi-server"></i>
OPDS Settings (v1)
</h2>
<p class="settings-description">
Legacy OPDS server settings for managing your book collection access.
<i
class="pi pi-info-circle text-sky-600 ml-1"
pTooltip="OPDS allows your book collection to be accessed by compatible reading apps through your private server."
tooltipPosition="right"
style="cursor: pointer;">
</i>
</p>
<div class="px-4 mb-4">
<div class="border-l-8 border border-red-500 text-red-400 p-3 rounded-md flex items-center gap-2">
<i class="pi pi-exclamation-triangle mt-1"></i>
<p class="text-sm">
<b class="text-red-500">Deprecated:</b> OPDS (v1) support will be removed in a future release.
Please migrate to <b>OPDS v2</b> for continued support and improvements.
</p>
<div class="deprecation-notice">
<i class="pi pi-exclamation-triangle"></i>
<div>
<p class="notice-title">Deprecated:</p>
<p class="notice-text">
OPDS (v1) support will be removed in a future release.
Please migrate to <strong>OPDS v2</strong> for continued support and improvements.
</p>
</div>
</div>
</div>
<div class="flex flex-col space-y-6 p-4 m-4 custom-border">
<div class="flex items-center gap-4">
<p class="py-1">OPDS Server Enabled:</p>
<p-toggleswitch
[(ngModel)]="opdsEnabled"
(onChange)="toggleOpdsServer()">
</p-toggleswitch>
<div class="settings-content">
<div class="server-section">
<h3 class="section-title">
<i class="pi pi-power-off"></i>
Server Control
</h3>
<div class="server-control">
<label class="control-label">OPDS Server Enabled:</label>
<p-toggleswitch
[(ngModel)]="opdsEnabled"
(onChange)="toggleOpdsServer()">
</p-toggleswitch>
</div>
</div>
@if (opdsEnabled) {
<div class="flex items-center gap-2 mt-4">
<p class="text-lg py-1">OPDS Endpoint:</p>
<div class="flex items-center gap-4">
<input
class="min-w-[600px] max-w-[600px]"
type="text"
pInputText
[value]="opdsEndpoint"
readonly/>
<p-button
icon="pi pi-copy"
severity="info"
(onClick)="copyOpdsEndpoint()"
label="Copy"
outlined="true">
</p-button>
<div class="endpoint-section">
<h3 class="section-title">
<i class="pi pi-link"></i>
OPDS Endpoint
</h3>
<div class="endpoint-form">
<div class="endpoint-field">
<input
id="endpoint-url"
fluid
class="endpoint-input"
type="text"
pInputText
[value]="opdsEndpoint"
readonly/>
<p-button
icon="pi pi-copy"
label="Copy"
severity="info"
outlined
size="small"
(onClick)="copyOpdsEndpoint()">
</p-button>
</div>
</div>
</div>
}
@if (opdsEnabled) {
<div class="mt-6">
<div class="flex justify-between items-center">
<h2 class="text-lg flex items-center gap-2">
Current OPDS Users:
</h2>
<p-button
label="Create OPDS User"
icon="pi pi-plus"
outlined="true"
(onClick)="openCreateUserDialog()">
</p-button>
<div class="users-section">
<div class="section-header">
<div class="section-title-group">
<h3 class="section-title">
<i class="pi pi-users"></i>
OPDS Users
</h3>
</div>
</div>
<div class="mt-3">
<div class="table-card">
<p-table [value]="users">
<ng-template pTemplate="header">
<tr>
<th>Username</th>
<th>Password</th>
<th>Reset Password</th>
<th>Delete</th>
<th>
<div class="header-content">
<i class="pi pi-user"></i>
<span>Username</span>
</div>
</th>
<th>
<div class="header-content">
<i class="pi pi-key"></i>
<span>Password</span>
</div>
</th>
<th class="actions-header">
<div class="header-content">
<i class="pi pi-cog"></i>
<span>Actions</span>
</div>
</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-user>
<tr>
<td>{{ user.username }}</td>
<td class="flex items-center gap-2">
<p-password
[(ngModel)]="dummyPassword"
[disabled]="true">
</p-password>
<i
class="pi pi-info-circle text-sky-600"
pTooltip="The password is hidden for security reasons and is only visible once during creation. You can reset the password if needed."
tooltipPosition="right"
style="cursor: pointer;">
</i>
<td>
<div class="user-info">
<div class="user-avatar">
{{ user.username.charAt(0).toUpperCase() }}
</div>
<span class="username">{{ user.username }}</span>
</div>
</td>
<td>
<p-button
icon="pi pi-key"
severity="warn"
size="small"
outlined="true"
(onClick)="openResetPasswordDialog(user)">
</p-button>
<div class="flex items-center gap-2">
<p-password
fluid
class="w-32 md:w-56"
[(ngModel)]="dummyPassword"
[feedback]="false"
size="small"
[disabled]="true"
[toggleMask]="false">
</p-password>
<i
class="pi pi-info-circle text-gray-400"
pTooltip="The password is hidden for security reasons and is only visible once during creation. You can reset the password if needed."
tooltipPosition="right"
style="cursor: pointer;">
</i>
</div>
</td>
<td>
<p-button
icon="pi pi-trash"
severity="danger"
size="small"
outlined="true"
(onClick)="deleteUser(user.id)">
</p-button>
<td class="actions-cell">
<div class="action-buttons">
<p-button
icon="pi pi-key"
severity="warn"
size="small"
[outlined]="true"
[rounded]="true"
(onClick)="openResetPasswordDialog(user)"
pTooltip="Reset password">
</p-button>
<p-button
icon="pi pi-trash"
severity="danger"
size="small"
[outlined]="true"
[rounded]="true"
(onClick)="deleteUser(user.id)"
pTooltip="Delete user">
</p-button>
</div>
</td>
</tr>
</ng-template>
<ng-template pTemplate="emptymessage">
<tr>
<td colspan="3">
<div class="empty-message">
<i class="pi pi-users"></i>
<p class="empty-title">No users found</p>
<p class="empty-subtitle">Create your first OPDS user to get started</p>
</div>
</td>
</tr>
</ng-template>
@@ -113,80 +172,100 @@
</div>
}
</div>
<p-dialog
header="Create New OPDS User"
header="Create User"
[(visible)]="createUserDialogVisible"
[modal]="true"
[style]="{ width: '400px' }">
<div>
<div>
styleClass="user-dialog"
[style]="{width: '400px'}">
<div class="dialog-form">
<div class="form-field">
<label for="username">Username</label>
<input id="username" type="text" pInputText [(ngModel)]="newUser.username" class="w-full"/>
<input
id="username"
type="text"
pInputText
[(ngModel)]="newUser.username"
placeholder="Enter username"/>
</div>
<div class="mt-4">
<div class="flex items-center gap-2">
<div class="form-field">
<div class="label-with-info">
<label for="password">Password</label>
<i
class="pi pi-info-circle text-yellow-500"
pTooltip="Password can only be seen now. It wont be retrievable later. Please save it securely."
pTooltip="Password can only be seen now. It won't be retrievable later. Please save it securely."
tooltipPosition="right"
style="cursor: pointer;">
</i>
</div>
<input id="password" pInputText [(ngModel)]="newUser.password" class="w-full"/>
<input
id="password"
type="password"
pInputText
[(ngModel)]="newUser.password"
placeholder="Enter password"/>
</div>
<div class="flex justify-end mt-6 gap-4">
</div>
<ng-template pTemplate="footer">
<div class="dialog-actions">
<p-button
label="Cancel"
icon="pi pi-times"
severity="secondary"
outlined="true"
(onClick)="cancelCreateUser()">
</p-button>
<p-button
label="Save"
icon="pi pi-check"
label="Create"
severity="success"
[disabled]="!newUser.username || !newUser.password"
(onClick)="saveNewUser()">
</p-button>
</div>
</div>
</ng-template>
</p-dialog>
<p-dialog
header="Reset User Password"
[(visible)]="resetPasswordDialogVisible"
[modal]="true"
[style]="{ width: '400px' }">
<div>
<p class="mb-6 mt-2">Reset password for: <b>{{ selectedUser?.username }}</b></p>
<div>
<div class="flex items-center">
styleClass="user-dialog"
[style]="{width: '400px'}">
<div class="dialog-form">
<p class="reset-info">Reset password for: <strong>{{ selectedUser?.username }}</strong></p>
<div class="form-field">
<div class="label-with-info">
<label for="newPassword">New Password</label>
<i
class="pi pi-info-circle text-yellow-500 ml-2"
pTooltip="Password can only be seen now. It wont be retrievable later. Please save it securely."
class="pi pi-info-circle text-yellow-500"
pTooltip="Password can only be seen now. It won't be retrievable later. Please save it securely."
tooltipPosition="right"
style="cursor: pointer;">
</i>
</div>
<input id="newPassword" pInputText [(ngModel)]="newPassword" class="w-full"/>
<input
id="newPassword"
type="password"
pInputText
[(ngModel)]="newPassword"
placeholder="Enter new password"/>
</div>
<div class="flex justify-end mt-4 gap-4">
</div>
<ng-template pTemplate="footer">
<div class="dialog-actions">
<p-button
label="Cancel"
icon="pi pi-times"
severity="secondary"
outlined="true"
(onClick)="cancelResetPassword()">
</p-button>
<p-button
label="Save"
icon="pi pi-check"
label="Reset"
severity="success"
[disabled]="!newPassword"
(onClick)="confirmResetPassword()">
</p-button>
</div>
</div>
</ng-template>
</p-dialog>
</div>

View File

@@ -1,5 +1,321 @@
.main-container {
width: 100%;
padding: 1rem;
height: calc(100dvh - 10.5rem);
overflow-y: auto;
border-width: 1px;
border-radius: 0.5rem;
@media (min-width: 768px) {
height: calc(100dvh - 11.65rem);
}
}
.enclosing-container {
border-color: var(--p-content-border-color);
background: var(--p-content-background);
}
.settings-header {
margin-top: 1rem;
margin-bottom: 2rem;
}
.settings-title {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.25rem;
font-weight: 700;
color: var(--p-text-color);
margin: 0 0 0.75rem 0;
.pi {
color: var(--p-primary-color);
font-size: 1.25rem;
}
}
.settings-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin-bottom: 1rem;
}
.deprecation-notice {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem;
border-radius: 8px;
border: 1px solid var(--p-red-500);
background: rgba(239, 68, 68, 0.1);
color: var(--p-red-400);
.pi {
color: var(--p-red-500);
margin-top: 0.125rem;
font-size: 1rem;
}
.notice-title {
font-weight: 600;
color: var(--p-red-500);
margin: 0 0 0.25rem 0;
}
.notice-text {
font-size: 0.875rem;
margin: 0;
line-height: 1.4;
strong {
font-weight: 600;
}
}
}
.settings-content {
display: flex;
flex-direction: column;
gap: 2rem;
}
.server-section {
background: var(--p-content-background);
border-radius: 8px;
@media (min-width: 768px) {
padding: 0 1rem 0 1rem;
}
}
.section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--p-text-color);
margin-bottom: 1rem;
.pi {
color: var(--p-primary-color);
}
}
.server-control {
display: flex;
align-items: center;
gap: 1rem;
}
.control-label {
font-weight: 500;
color: var(--p-text-color);
}
.endpoint-section {
background: var(--p-content-background);
border-radius: 8px;
@media (min-width: 768px) {
padding: 0 1rem 0 1rem;
}
}
.endpoint-form {
margin-top: 1rem;
}
.endpoint-field {
display: flex;
align-items: center;
gap: 0.5rem;
}
.endpoint-input {
min-width: 9rem;
max-width: 50rem;
}
.users-section {
@media (min-width: 768px) {
padding: 0 1rem;
}
.section-title-group {
display: flex;
align-items: center;
justify-content: space-between;
}
}
.table-card {
border: 1px solid var(--p-content-border-color);
border-radius: 8px;
overflow: hidden;
background: var(--p-content-background);
}
.p-datatable {
.p-datatable-table {
border-collapse: separate;
border-spacing: 0;
}
.p-datatable-thead > tr > th {
background: var(--p-surface-100);
border-bottom: 2px solid var(--p-content-border-color);
padding: 1rem;
font-weight: 600;
color: var(--p-text-color);
}
.p-datatable-tbody > tr {
transition: background-color 0.2s;
&:hover {
background: var(--p-surface-50);
}
&:last-child {
border-bottom: none;
}
}
.p-datatable-tbody > tr > td {
padding: 1rem;
border: none;
text-align: left;
vertical-align: middle;
}
}
.p-datatable th .header-content {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
}
.user-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.user-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--p-primary-color);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.875rem;
}
.username {
font-weight: 500;
}
.actions-cell {
text-align: center;
}
.action-buttons {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.empty-message {
text-align: center;
padding: 2rem 1rem;
color: var(--p-text-muted-color);
.pi {
font-size: 2rem;
margin-bottom: 1rem;
color: var(--p-surface-400);
}
.empty-title {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.empty-subtitle {
font-size: 0.875rem;
}
}
.user-dialog {
.p-dialog-content {
padding: 1.5rem;
}
.p-dialog-footer {
padding: 1rem 1.5rem;
}
}
.dialog-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
label {
font-weight: 600;
color: var(--p-text-color);
font-size: 0.875rem;
}
input {
width: 100%;
padding: 0.75rem;
transition: border-color 0.2s;
&:focus {
outline: none;
border-color: var(--p-primary-color);
box-shadow: 0 0 0 2px var(--p-primary-color-alpha-20);
}
&::placeholder {
color: var(--p-text-muted-color);
}
}
}
.label-with-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.reset-info {
color: var(--p-text-color);
margin-bottom: 1rem;
font-size: 0.875rem;
}
.custom-border {

View File

@@ -1,41 +1,52 @@
<div class="p-4 pb-12">
<p class="text-lg pb-6 pt-4 ">
CBX Reader: Global Preferences
<i class="pi pi-info-circle text-sky-600"
pTooltip="Configure global PDF reading options such as page spread, zoom level, and sidebar visibility that apply to all PDFs."
tooltipPosition="right"
style="cursor: pointer;"></i>
</p>
<div class="grid grid-cols-[auto,1fr] pl-6 gap-y-4 gap-x-4 items-center">
<p class="py-1">Page Spread:</p>
<div class="flex gap-4 justify-start">
@for (spread of cbxSpreads; track spread) {
<div class="flex items-center gap-2">
<p-radiobutton
[inputId]="spread.key"
name="spread"
[value]="spread.key"
[(ngModel)]="selectedCbxSpread">
</p-radiobutton>
<label [for]="spread.key">{{ spread.name }}</label>
<div class="cbx-preferences-container">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Page Spread</label>
<div class="radio-group">
@for (spread of cbxSpreads; track spread) {
<div class="radio-option">
<p-radiobutton
[inputId]="spread.key"
name="spread"
[value]="spread.key"
[(ngModel)]="selectedCbxSpread">
</p-radiobutton>
<label [for]="spread.key">{{ spread.name }}</label>
</div>
}
</div>
</div>
}
<p class="setting-description">
Configure how comic book pages are displayed - single page or double page spread.
</p>
</div>
</div>
<p class="py-1">Page Zoom:</p>
<div class="flex gap-4 justify-start">
@for (mode of cbxViewModes; track mode) {
<div class="flex items-center gap-2">
<p-radiobutton
[inputId]="mode.key"
name="zoom"
[value]="mode.key"
[(ngModel)]="selectedCbxViewMode">
</p-radiobutton>
<label [for]="mode.key">{{ mode.name }}</label>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Page Layout</label>
<div class="radio-group">
@for (mode of cbxViewModes; track mode) {
<div class="radio-option">
<p-radiobutton
[inputId]="mode.key"
name="zoom"
[value]="mode.key"
[(ngModel)]="selectedCbxViewMode">
</p-radiobutton>
<label [for]="mode.key">{{ mode.name }}</label>
</div>
}
</div>
</div>
}
<p class="setting-description">
Choose how comic pages are displayed while reading.
</p>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,101 @@
.cbx-preferences-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.setting-item {
display: flex;
align-items: flex-start;
gap: 2rem;
padding: 0.25rem 0;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
@media (max-width: 768px) {
flex-direction: column;
gap: 1rem;
}
}
.setting-info {
flex: 1;
min-width: 0;
.setting-label {
display: block;
font-weight: 600;
color: var(--p-text-color);
font-size: 1rem;
margin-bottom: 0.5rem;
}
.setting-label-row {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
.setting-label {
margin-bottom: 0;
flex-shrink: 0;
min-width: 120px;
}
.radio-group {
display: flex;
gap: 1.5rem;
@media (max-width: 768px) {
flex-direction: column;
gap: 0.75rem;
}
}
}
.setting-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
}
}
.setting-control {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: flex-end;
@media (max-width: 768px) {
align-items: flex-start;
width: 100%;
}
}
.radio-group {
display: flex;
gap: 1.5rem;
@media (max-width: 768px) {
flex-direction: column;
gap: 0.75rem;
}
}
.radio-option {
display: flex;
align-items: center;
gap: 0.5rem;
label {
font-size: 0.875rem;
color: var(--p-text-color);
cursor: pointer;
}
}

View File

@@ -1,6 +1,5 @@
import {Component, inject, Input} from '@angular/core';
import {RadioButton} from 'primeng/radiobutton';
import {Tooltip} from 'primeng/tooltip';
import {FormsModule} from '@angular/forms';
import {CbxPageSpread, CbxPageViewMode} from '../../../book/model/book.model';
import {UserSettings} from '../../user-management/user.service';
@@ -10,7 +9,6 @@ import {ReaderPreferencesService} from '../reader-preferences-service';
selector: 'app-cbx-reader-preferences-component',
imports: [
RadioButton,
Tooltip,
FormsModule
],
templateUrl: './cbx-reader-preferences-component.html',

View File

@@ -1,58 +1,77 @@
<div class="p-4">
<p class="text-lg pb-6 pt-4">
Epub Reader: Global Preferences
<i class="pi pi-info-circle text-sky-600"
pTooltip="Set global EPUB reader options such as themes, font families, font sizes, and spacing applied to all EPUBs."
tooltipPosition="right"
style="cursor: pointer;"></i>
</p>
<div class="grid grid-cols-[auto,1fr] pl-6 gap-y-4 gap-x-4 items-center">
<p>Theme:</p>
<div class="flex gap-4 justify-start">
<p-select
size="small"
[options]="themes"
[(ngModel)]="selectedTheme"
optionLabel="name"
optionValue="key"
placeholder="Select a Theme"
class="w-full md:w-44">
</p-select>
<div class="epub-preferences-container">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Theme</label>
<p-select
size="small"
[options]="themes"
[(ngModel)]="selectedTheme"
optionLabel="name"
optionValue="key"
placeholder="Select a Theme"
class="w-full md:w-60">
</p-select>
</div>
<p class="setting-description">
Choose the visual theme for EPUB reading experience.
</p>
</div>
</div>
<p>Font:</p>
<div class="flex gap-4 justify-start">
<p-select
size="small"
[options]="fonts"
[(ngModel)]="selectedFont"
optionLabel="name"
optionValue="key"
placeholder="Select a Font"
class="w-full md:w-44">
</p-select>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Font</label>
<p-select
size="small"
[options]="fonts"
[(ngModel)]="selectedFont"
optionLabel="name"
optionValue="key"
placeholder="Select a Font"
class="w-full md:w-60">
</p-select>
</div>
<p class="setting-description">
Select the font family for text display.
</p>
</div>
</div>
<p>Flow:</p>
<div class="flex gap-4 justify-start">
<p-select
size="small"
[options]="flowOptions"
[(ngModel)]="selectedFlow"
optionLabel="name"
optionValue="key"
placeholder="Select a Flow"
class="w-full md:w-44">
</p-select>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Flow</label>
<p-select
size="small"
[options]="flowOptions"
[(ngModel)]="selectedFlow"
optionLabel="name"
optionValue="key"
placeholder="Select a Flow"
class="w-full md:w-60">
</p-select>
</div>
<p class="setting-description">
Configure text flow and reading direction.
</p>
</div>
</div>
<p class="py-1">Font Size:</p>
<div class="flex gap-4 justify-start items-center">
<p-button icon="pi pi-minus" size="small" (click)="decreaseFontSize()"></p-button>
<p>{{ fontSize }}%</p>
<p-button icon="pi pi-plus" size="small" (click)="increaseFontSize()"></p-button>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Font Size</label>
<div class="font-size-controls">
<p-button icon="pi pi-minus" size="small" (click)="decreaseFontSize()"></p-button>
<span class="font-size-value">{{ fontSize }}%</span>
<p-button icon="pi pi-plus" size="small" (click)="increaseFontSize()"></p-button>
</div>
</div>
<p class="setting-description">
Adjust the text size for comfortable reading.
</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,79 @@
.epub-preferences-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.setting-item {
display: flex;
align-items: flex-start;
gap: 2rem;
padding: 0.25rem 0;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
@media (max-width: 768px) {
flex-direction: column;
gap: 1rem;
}
}
.setting-info {
flex: 1;
min-width: 0;
.setting-label {
display: block;
font-weight: 600;
color: var(--p-text-color);
font-size: 1rem;
margin-bottom: 0.5rem;
}
.setting-label-row {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
.setting-label {
margin-bottom: 0;
flex-shrink: 0;
min-width: 100px;
}
p-select {
flex: 1;
min-width: 200px;
max-width: 300px;
@media (max-width: 768px) {
min-width: 180px;
}
}
.font-size-controls {
display: flex;
align-items: center;
gap: 1rem;
.font-size-value {
min-width: 3rem;
text-align: center;
font-weight: 500;
color: var(--p-text-color);
}
}
}
.setting-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
}
}

View File

@@ -1,7 +1,6 @@
import {Component, inject, Input} from '@angular/core';
import {Button} from 'primeng/button';
import {Select} from 'primeng/select';
import {Tooltip} from 'primeng/tooltip';
import {FormsModule} from '@angular/forms';
import {ReaderPreferencesService} from '../reader-preferences-service';
import {UserSettings} from '../../user-management/user.service';
@@ -11,7 +10,6 @@ import {UserSettings} from '../../user-management/user.service';
imports: [
Button,
Select,
Tooltip,
FormsModule
],
templateUrl: './epub-reader-preferences-component.html',

View File

@@ -1,41 +1,52 @@
<div class="p-4">
<p class="text-lg pb-6 pt-4 ">
PDF Reader: Global Preferences
<i class="pi pi-info-circle text-sky-600"
pTooltip="Configure global PDF reading options such as page spread, zoom level, and sidebar visibility that apply to all PDFs."
tooltipPosition="right"
style="cursor: pointer;"></i>
</p>
<div class="grid grid-cols-[auto,1fr] pl-6 gap-y-4 gap-x-4 items-center">
<p class="py-1">Page Spread:</p>
<div class="flex gap-4 justify-start">
@for (spread of spreads; track spread) {
<div class="flex items-center gap-2">
<p-radiobutton
[inputId]="spread.key"
name="spread"
[value]="spread.key"
[(ngModel)]="selectedSpread">
</p-radiobutton>
<label [for]="spread.key">{{ spread.name }}</label>
<div class="pdf-preferences-container">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Page Spread</label>
<div class="radio-group">
@for (spread of spreads; track spread) {
<div class="radio-option">
<p-radiobutton
[inputId]="spread.key"
name="spread"
[value]="spread.key"
[(ngModel)]="selectedSpread">
</p-radiobutton>
<label [for]="spread.key">{{ spread.name }}</label>
</div>
}
</div>
</div>
}
<p class="setting-description">
Choose how PDF pages are displayed - single page or double page spread view.
</p>
</div>
</div>
<p class="py-1">Page Zoom:</p>
<div class="flex gap-4 justify-start">
@for (zoom of zooms; track zoom) {
<div class="flex items-center gap-2">
<p-radiobutton
[inputId]="zoom.key"
name="zoom"
[value]="zoom.key"
[(ngModel)]="selectedZoom">
</p-radiobutton>
<label [for]="zoom.key">{{ zoom.name }}</label>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Page Zoom</label>
<div class="radio-group">
@for (zoom of zooms; track zoom) {
<div class="radio-option">
<p-radiobutton
[inputId]="zoom.key"
name="zoom"
[value]="zoom.key"
[(ngModel)]="selectedZoom">
</p-radiobutton>
<label [for]="zoom.key">{{ zoom.name }}</label>
</div>
}
</div>
</div>
}
<p class="setting-description">
Set the default zoom level for PDF documents to enhance readability.
</p>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,101 @@
.pdf-preferences-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.setting-item {
display: flex;
align-items: flex-start;
gap: 2rem;
padding: 0.25rem 0;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
@media (max-width: 768px) {
flex-direction: column;
gap: 1rem;
}
}
.setting-info {
flex: 1;
min-width: 0;
.setting-label {
display: block;
font-weight: 600;
color: var(--p-text-color);
font-size: 1rem;
margin-bottom: 0.5rem;
}
.setting-label-row {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
.setting-label {
margin-bottom: 0;
flex-shrink: 0;
min-width: 120px;
}
.radio-group {
display: flex;
gap: 1.5rem;
@media (max-width: 768px) {
flex-direction: column;
gap: 0.75rem;
}
}
}
.setting-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
}
}
.setting-control {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: flex-end;
@media (max-width: 768px) {
align-items: flex-start;
width: 100%;
}
}
.radio-group {
display: flex;
gap: 1.5rem;
@media (max-width: 768px) {
flex-direction: column;
gap: 0.75rem;
}
}
.radio-option {
display: flex;
align-items: center;
gap: 0.5rem;
label {
font-size: 0.875rem;
color: var(--p-text-color);
cursor: pointer;
}
}

View File

@@ -1,7 +1,6 @@
import {Component, inject, Input} from '@angular/core';
import {RadioButton} from 'primeng/radiobutton';
import {FormsModule} from '@angular/forms';
import {Tooltip} from 'primeng/tooltip';
import {ReaderPreferencesService} from '../reader-preferences-service';
import {UserSettings} from '../../user-management/user.service';
@@ -9,8 +8,7 @@ import {UserSettings} from '../../user-management/user.service';
selector: 'app-pdf-reader-preferences-component',
imports: [
RadioButton,
FormsModule,
Tooltip
FormsModule
],
templateUrl: './pdf-reader-preferences-component.html',
styleUrl: './pdf-reader-preferences-component.scss'

View File

@@ -1,77 +1,156 @@
<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="p-4">
<p class="text-lg pb-4 pt-4 ">
Book Reader Settings: Global or Per-Book
<i class="pi pi-info-circle text-sky-600"
pTooltip="Select whether to apply the same reader settings (font, theme, spread, zoom, etc.) globally for all books or individually per book."
tooltipPosition="right"
style="cursor: pointer;">
</i>
<div class="main-container enclosing-container">
<div class="settings-header">
<h2 class="settings-title">
<i class="pi pi-book"></i>
Reader Preferences
</h2>
<p class="settings-description">
Configure reader settings scope and customize reading experience for different book formats including PDF, EPUB, and CBX files.
</p>
<div class="grid grid-cols-[auto,1fr] pl-6 gap-y-4 gap-x-4 items-center">
<p class="py-1">PDF:</p>
<div class="flex gap-4 justify-start">
@for (item of scopeOptions; track item) {
<div class="flex items-center gap-2">
<p-radiobutton
[inputId]="item"
name="spread"
[value]="item"
[(ngModel)]="selectedPdfScope"
(ngModelChange)="onPdfScopeChange()">
</p-radiobutton>
<label [for]="item">{{ item }}</label>
</div>
}
</div>
<div class="settings-content">
<div class="preferences-section">
<div class="section-header">
<h3 class="section-title">
<i class="pi pi-cog"></i>
Reader Settings Scope
</h3>
<p class="section-description">
Select whether to apply the same reader settings (font, theme, spread, zoom, etc.) globally for all books or individually per book.
</p>
</div>
<p class="py-1">Epub:</p>
<div class="flex gap-4 justify-start">
@for (item of scopeOptions; track item) {
<div class="flex items-center gap-2">
<p-radiobutton
[inputId]="item"
name="spread"
[value]="item"
[(ngModel)]="selectedEpubScope"
(ngModelChange)="onEpubScopeChange()">
</p-radiobutton>
<label [for]="item">{{ item }}</label>
<div class="settings-card">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">PDF Reader Settings</label>
<div class="radio-group">
@for (item of scopeOptions; track item) {
<div class="radio-option">
<p-radiobutton
[inputId]="'pdf-' + item"
name="pdfScope"
[value]="item"
[(ngModel)]="selectedPdfScope"
(ngModelChange)="onPdfScopeChange()">
</p-radiobutton>
<label [for]="'pdf-' + item">{{ item }}</label>
</div>
}
</div>
</div>
<p class="setting-description">
Choose how PDF reader settings are applied across your library.
</p>
</div>
}
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">EPUB Reader Settings</label>
<div class="radio-group">
@for (item of scopeOptions; track item) {
<div class="radio-option">
<p-radiobutton
[inputId]="'epub-' + item"
name="epubScope"
[value]="item"
[(ngModel)]="selectedEpubScope"
(ngModelChange)="onEpubScopeChange()">
</p-radiobutton>
<label [for]="'epub-' + item">{{ item }}</label>
</div>
}
</div>
</div>
<p class="setting-description">
Choose how EPUB reader settings are applied across your library.
</p>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">CBX Reader Settings</label>
<div class="radio-group">
@for (item of scopeOptions; track item) {
<div class="radio-option">
<p-radiobutton
[inputId]="'cbx-' + item"
name="cbxScope"
[value]="item"
[(ngModel)]="selectedCbxScope"
(ngModelChange)="onCbxScopeChange()">
</p-radiobutton>
<label [for]="'cbx-' + item">{{ item }}</label>
</div>
}
</div>
</div>
<p class="setting-description">
Choose how CBX (comic book) reader settings are applied across your library.
</p>
</div>
</div>
</div>
<p class="py-1">CBX:</p>
<div class="flex gap-4 justify-start">
@for (item of scopeOptions; track item) {
<div class="flex items-center gap-2">
<p-radiobutton
[inputId]="item"
name="spread"
[value]="item"
[(ngModel)]="selectedCbxScope"
(ngModelChange)="onCbxScopeChange()">
</p-radiobutton>
<label [for]="item">{{ item }}</label>
</div>
}
</div>
<div class="preferences-section">
<div class="section-header">
<h3 class="section-title">
<i class="pi pi-book"></i>
EPUB Preferences: Global
</h3>
<p class="section-description">
Set default reading options that will apply to all EPUB books in your library, including font, theme, text flow, and layout preferences.
</p>
</div>
<div class="settings-card">
<app-epub-reader-preferences-component
[userSettings]="userSettings">
</app-epub-reader-preferences-component>
</div>
</div>
<div class="preferences-section">
<div class="section-header">
<h3 class="section-title">
<i class="pi pi-file-pdf"></i>
PDF Preferences: Global
</h3>
<p class="section-description">
Set default reading options that apply to all PDF books in your library, including zoom level, page layout, and navigation behavior.
</p>
</div>
<div class="settings-card">
<app-pdf-reader-preferences-component
[userSettings]="userSettings">
</app-pdf-reader-preferences-component>
</div>
</div>
<div class="preferences-section">
<div class="section-header">
<h3 class="section-title">
<i class="pi pi-images"></i>
CBX Preferences: Global
</h3>
<p class="section-description">
Configure default reading settings for all comic books in your library, including page layout, reading direction, and image scaling.
</p>
</div>
<div class="settings-card">
<app-cbx-reader-preferences-component
[userSettings]="userSettings">
</app-cbx-reader-preferences-component>
</div>
</div>
</div>
<p-divider></p-divider>
<app-epub-reader-preferences-component
[userSettings]="userSettings">
</app-epub-reader-preferences-component>
<p-divider></p-divider>
<app-pdf-reader-preferences-component
[userSettings]="userSettings">
</app-pdf-reader-preferences-component>
<p-divider></p-divider>
<app-cbx-reader-preferences-component
[userSettings]="userSettings">
</app-cbx-reader-preferences-component>
</div>

View File

@@ -1,3 +1,215 @@
.main-container {
width: 100%;
padding: 1rem;
height: calc(100dvh - 10.5rem);
overflow-y: auto;
border-width: 1px;
border-radius: 0.5rem;
@media (min-width: 768px) {
height: calc(100dvh - 11.65rem);
}
}
.enclosing-container {
border-color: var(--p-content-border-color);
background: var(--p-content-background);
}
.settings-header {
margin-top: 1rem;
margin-bottom: 2rem;
}
.settings-title {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.25rem;
font-weight: 700;
color: var(--p-text-color);
margin: 0 0 0.75rem 0;
.pi {
color: var(--p-primary-color);
font-size: 1.25rem;
}
}
.settings-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin-bottom: 1rem;
}
.settings-content {
display: flex;
flex-direction: column;
gap: 2rem;
}
.preferences-section {
@media (min-width: 768px) {
padding: 0 1rem;
}
}
.section-header {
margin-bottom: 1rem;
}
.section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--p-text-color);
margin: 0 0 0.5rem 0;
.pi {
color: var(--p-primary-color);
}
}
.section-description {
display: flex;
align-items: flex-start;
gap: 0.5rem;
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
.pi {
color: var(--p-primary-color);
margin-top: 0.125rem;
flex-shrink: 0;
}
}
.settings-card {
border: 1px solid var(--p-content-border-color);
border-radius: 8px;
background: var(--p-content-background);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
app-epub-reader-preferences-component,
app-pdf-reader-preferences-component,
app-cbx-reader-preferences-component {
display: block;
width: 100%;
// Remove any default margins/padding that might conflict with card styling
::ng-deep {
> div,
> .container,
> .main-container {
margin: 0 !important;
padding: 0 !important;
border: none !important;
background: transparent !important;
}
}
}
}
.setting-item {
display: flex;
align-items: flex-start;
gap: 2rem;
padding: 0.25rem 0;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
@media (max-width: 768px) {
flex-direction: column;
gap: 1rem;
}
}
.setting-info {
flex: 1;
min-width: 0;
.setting-label {
display: block;
font-weight: 600;
color: var(--p-text-color);
font-size: 1rem;
margin-bottom: 0.5rem;
}
.setting-label-row {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
.setting-label {
margin-bottom: 0;
flex-shrink: 0;
min-width: 150px;
}
.radio-group {
display: flex;
gap: 1.5rem;
@media (max-width: 768px) {
flex-direction: column;
gap: 0.75rem;
}
}
}
.setting-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
}
}
.setting-control {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: flex-end;
@media (max-width: 768px) {
align-items: flex-start;
width: 100%;
}
}
.radio-group {
display: flex;
gap: 1.5rem;
@media (max-width: 768px) {
flex-direction: column;
gap: 0.75rem;
}
}
.radio-option {
display: flex;
align-items: center;
gap: 0.5rem;
label {
font-size: 0.875rem;
color: var(--p-text-color);
cursor: pointer;
}
}

View File

@@ -4,8 +4,6 @@ import {filter, takeUntil} from 'rxjs/operators';
import {Observable, Subject} from 'rxjs';
import {RadioButton} from 'primeng/radiobutton';
import {Divider} from 'primeng/divider';
import {Tooltip} from 'primeng/tooltip';
import {UserService, UserSettings, UserState} from '../user-management/user.service';
import {EpubReaderPreferencesComponent} from './epub-reader-preferences-component/epub-reader-preferences-component';
import {PdfReaderPreferencesComponent} from './pdf-reader-preferences-component/pdf-reader-preferences-component';
@@ -17,7 +15,7 @@ import {ReaderPreferencesService} from './reader-preferences-service';
templateUrl: './reader-preferences.component.html',
standalone: true,
styleUrls: ['./reader-preferences.component.scss'],
imports: [FormsModule, RadioButton, Divider, Tooltip, EpubReaderPreferencesComponent, PdfReaderPreferencesComponent, CbxReaderPreferencesComponent]
imports: [FormsModule, RadioButton, EpubReaderPreferencesComponent, PdfReaderPreferencesComponent, CbxReaderPreferencesComponent]
})
export class ReaderPreferences implements OnInit, OnDestroy {
readonly scopeOptions = ['Global', 'Individual'];

View File

@@ -1,201 +1,288 @@
<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="flex justify-between items-center pb-2 pt-2">
<h2 class="text-lg flex items-center gap-2">
Current Booklore Users
<div class="main-container enclosing-container">
<div class="settings-header">
<h2 class="settings-title">
<i class="pi pi-users"></i>
User Management
</h2>
<p-button outlined="true" label="Create User" icon="pi pi-plus" (onClick)="openCreateUserDialog()"></p-button>
<p class="settings-description">
Manage Booklore users, their permissions, and library access. Configure user roles and capabilities for your book collection.
</p>
</div>
<p-table [value]="users" class="mt-4">
<ng-template pTemplate="header">
<tr>
<th>Type</th>
<th>Username</th>
<th>Full Name</th>
<th>Email</th>
<th>Assigned Libraries</th>
<th style="width: 60px;">Admin</th>
<th style="width: 60px;">Upload</th>
<th style="width: 60px;">Download</th>
<th style="width: 80px;">Edit Metadata</th>
<th style="width: 80px;">Manage Library</th>
<th style="width: 80px;">Email Books</th>
<th style="width: 80px;">Delete Books</th>
<th style="width: 80px;">Access OPDS</th>
<th style="width: 80px;">KOReader Sync</th>
<th style="width: 80px;">Kobo Sync</th>
<th style="width: 120px;">Edit</th>
<th style="width: 80px;">Change Password</th>
<th style="width: 80px;">Delete</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-user>
<tr>
<td class="min-w-16">
{{ (user.provisioningMethod || 'LOCAL') | lowercase | titlecase }}
</td>
<td class="min-w-16 max-w-32">
{{ user.username }}
</td>
<td class="min-w-36 max-w-72">
@if (user.isEditing) {
<input type="text" [(ngModel)]="user.name" class="p-inputtext w-full"/>
}
@if (!user.isEditing) {
<span>{{ user.name }}</span>
}
</td>
<td>
@if (user.isEditing) {
<input type="email" [(ngModel)]="user.email" class="p-inputtext w-full"/>
}
@if (!user.isEditing) {
<span>{{ user.email }}</span>
}
</td>
<td>
@if (user.isEditing) {
<p-multiSelect
[options]="allLibraries"
optionLabel="name"
optionValue="id"
[(ngModel)]="editingLibraryIds"
placeholder="Select Libraries"
appendTo="body">
</p-multiSelect>
}
@if (!user.isEditing) {
<span>
{{ user.libraryNames }}
</span>
}
</td>
<td class="text-center">
@if (user.isEditing) {
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.admin"></p-checkbox>
}
@if (!user.isEditing) {
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.admin" disabled></p-checkbox>
}
</td>
<td class="text-center">
@if (user.isEditing) {
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canUpload"></p-checkbox>
}
@if (!user.isEditing) {
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canUpload" disabled></p-checkbox>
}
</td>
<td class="text-center">
@if (user.isEditing) {
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canDownload"></p-checkbox>
}
@if (!user.isEditing) {
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canDownload" disabled></p-checkbox>
}
</td>
<td class="text-center">
@if (user.isEditing) {
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canEditMetadata"></p-checkbox>
}
@if (!user.isEditing) {
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canEditMetadata" disabled></p-checkbox>
}
</td>
<td class="text-center">
@if (user.isEditing) {
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canManipulateLibrary"></p-checkbox>
}
@if (!user.isEditing) {
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canManipulateLibrary" disabled></p-checkbox>
}
</td>
<td class="text-center">
@if (user.isEditing) {
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canEmailBook"></p-checkbox>
}
@if (!user.isEditing) {
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canEmailBook" disabled></p-checkbox>
}
</td>
<td class="text-center">
@if (user.isEditing) {
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canDeleteBook"></p-checkbox>
}
@if (!user.isEditing) {
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canDeleteBook" disabled></p-checkbox>
}
</td>
<td class="text-center">
@if (user.isEditing) {
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canAccessOpds"></p-checkbox>
}
@if (!user.isEditing) {
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canAccessOpds" disabled></p-checkbox>
}
</td>
<td class="text-center">
@if (user.isEditing) {
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canSyncKoReader"></p-checkbox>
}
@if (!user.isEditing) {
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canSyncKoReader" disabled></p-checkbox>
}
</td>
<td class="text-center">
@if (user.isEditing) {
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canSyncKobo"></p-checkbox>
}
@if (!user.isEditing) {
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canSyncKobo" disabled></p-checkbox>
}
</td>
<td class="flex text-center gap-2">
<ng-container>
@if (!user.isEditing) {
<p-button icon="pi pi-pencil" outlined="true" severity="info" (onClick)="toggleEdit(user)"></p-button>
}
@if (user.isEditing) {
<p-button icon="pi pi-check" outlined="true" severity="success" (onClick)="saveUser(user)"></p-button>
}
@if (user.isEditing) {
<p-button icon="pi pi-times" outlined="true" severity="danger" (onClick)="toggleEdit(user)"></p-button>
}
</ng-container>
<div class="settings-content">
<div class="users-section">
<div class="section-header">
<div class="section-title-group">
<h3 class="section-title">
<i class="pi pi-user"></i>
Current Users
</h3>
<p-button
icon="pi pi-pencil"
outlined="true"
severity="primary"
[ngStyle]="{'display': 'none'}">
icon="pi pi-plus"
label="Create User"
severity="success"
size="small"
outlined
(onClick)="openCreateUserDialog()">
</p-button>
</td>
<td class="text-center">
<ng-container>
<p-button icon="pi pi-key" outlined="true" severity="warn" (onClick)="openChangePasswordDialog(user)"></p-button>
</ng-container>
</td>
<td class="text-center">
<ng-container>
<p-button [disabled]="user.id === currentUser?.id" icon="pi pi-trash" outlined="true" severity="danger" (onClick)="deleteUser(user)"></p-button>
</ng-container>
</td>
</tr>
</ng-template>
</p-table>
</div>
</div>
<p-dialog header="Change Password" [(visible)]="isPasswordDialogVisible" [modal]="true" [closable]="false" [style]="{width: '20vw'}">
<div class="flex flex-col gap-8 items-center">
<p-password [(ngModel)]="newPassword" placeholder="New Password" [feedback]="false" class="w-full" fluid></p-password>
<p-password [(ngModel)]="confirmNewPassword" placeholder="Confirm Password" [feedback]="false" class="w-full" fluid></p-password>
@if (passwordError) {
<p class="p-error text-red-500 w-full text-left">{{ passwordError }}</p>
}
<div class="flex gap-4 w-full justify-end">
<p-button label="Cancel" icon="pi pi-times" severity="secondary" (onClick)="isPasswordDialogVisible = false"></p-button>
<p-button label="Change" icon="pi pi-check" severity="success" (onClick)="submitPasswordChange()"></p-button>
<div class="table-card">
<p-table [value]="users" [scrollable]="true" scrollHeight="flex">
<ng-template pTemplate="header">
<tr>
<th>
<div class="header-content">
<i class="pi pi-user"></i>
<span>Username</span>
</div>
</th>
<th>
<div class="header-content">
<i class="pi pi-tag"></i>
<span>Type</span>
</div>
</th>
<th class="full-name-column">
<div class="header-content">
<i class="pi pi-id-card"></i>
<span>Full Name</span>
</div>
</th>
<th class="email-column">
<div class="header-content">
<i class="pi pi-envelope"></i>
<span>Email</span>
</div>
</th>
<th class="libraries-column">
<div class="header-content">
<i class="pi pi-book"></i>
<span>Assigned Libraries</span>
</div>
</th>
<th class="permission-header">Admin</th>
<th class="permission-header">Upload</th>
<th class="permission-header">Download</th>
<th class="permission-header">Manage Metadata</th>
<th class="permission-header">Manage Library</th>
<th class="permission-header">Email Books</th>
<th class="permission-header">Delete Books</th>
<th class="permission-header">Access OPDS</th>
<th class="permission-header">KOReader Sync</th>
<th class="permission-header">Kobo Sync</th>
<th class="actions-header">
<div class="header-content">
<i class="pi pi-cog"></i>
<span>Edit</span>
</div>
</th>
<th class="actions-header">
<div class="header-content">
<i class="pi pi-key"></i>
<span>Password</span>
</div>
</th>
<th class="actions-header">
<div class="header-content">
<i class="pi pi-trash"></i>
<span>Delete</span>
</div>
</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-user>
<tr>
<td>
<div class="user-info">
<div class="user-avatar">
{{ user.username.charAt(0).toUpperCase() }}
</div>
<span class="username">{{ user.username }}</span>
</div>
</td>
<td>
<span class="user-type-badge">
{{ (user.provisioningMethod || 'LOCAL') | lowercase | titlecase }}
</span>
</td>
<td class="full-name-column">
@if (user.isEditing) {
<input type="text" [(ngModel)]="user.name" class="p-inputtext w-full" size="small"/>
}
@if (!user.isEditing) {
<span>{{ user.name }}</span>
}
</td>
<td class="email-column">
@if (user.isEditing) {
<input type="email" [(ngModel)]="user.email" class="p-inputtext w-full" size="small"/>
}
@if (!user.isEditing) {
<span>{{ user.email }}</span>
}
</td>
<td class="libraries-column">
@if (user.isEditing) {
<p-multiSelect
[options]="allLibraries"
optionLabel="name"
optionValue="id"
[(ngModel)]="editingLibraryIds"
placeholder="Select Libraries"
appendTo="body"
size="small">
</p-multiSelect>
}
@if (!user.isEditing) {
<span class="library-names">{{ user.libraryNames }}</span>
}
</td>
<td class="text-center">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.admin" [disabled]="!user.isEditing"></p-checkbox>
</td>
<td class="text-center">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canUpload" [disabled]="!user.isEditing"></p-checkbox>
</td>
<td class="text-center">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canDownload" [disabled]="!user.isEditing"></p-checkbox>
</td>
<td class="text-center">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canEditMetadata" [disabled]="!user.isEditing"></p-checkbox>
</td>
<td class="text-center">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canManipulateLibrary" [disabled]="!user.isEditing"></p-checkbox>
</td>
<td class="text-center">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canEmailBook" [disabled]="!user.isEditing"></p-checkbox>
</td>
<td class="text-center">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canDeleteBook" [disabled]="!user.isEditing"></p-checkbox>
</td>
<td class="text-center">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canAccessOpds" [disabled]="!user.isEditing"></p-checkbox>
</td>
<td class="text-center">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canSyncKoReader" [disabled]="!user.isEditing"></p-checkbox>
</td>
<td class="text-center">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canSyncKobo" [disabled]="!user.isEditing"></p-checkbox>
</td>
<td class="actions-cell">
@if (!user.isEditing) {
<p-button
icon="pi pi-pencil"
severity="info"
size="small"
[outlined]="true"
[rounded]="true"
(onClick)="toggleEdit(user)"
pTooltip="Edit user">
</p-button>
}
@if (user.isEditing) {
<div class="flex gap-1">
<p-button
icon="pi pi-check"
severity="success"
size="small"
[outlined]="true"
[rounded]="true"
(onClick)="saveUser(user)"
pTooltip="Save changes">
</p-button>
<p-button
icon="pi pi-times"
severity="danger"
size="small"
[outlined]="true"
[rounded]="true"
(onClick)="toggleEdit(user)"
pTooltip="Cancel">
</p-button>
</div>
}
</td>
<td class="actions-cell">
<p-button
icon="pi pi-key"
severity="warn"
size="small"
[outlined]="true"
[rounded]="true"
(onClick)="openChangePasswordDialog(user)"
pTooltip="Change password">
</p-button>
</td>
<td class="actions-cell">
<p-button
[disabled]="user.id === currentUser?.id"
icon="pi pi-trash"
severity="danger"
size="small"
[outlined]="true"
[rounded]="true"
(onClick)="deleteUser(user)"
pTooltip="Delete user">
</p-button>
</td>
</tr>
</ng-template>
</p-table>
</div>
</div>
</div>
<p-dialog
header="Change Password"
[(visible)]="isPasswordDialogVisible"
[modal]="true"
styleClass="user-dialog"
[style]="{width: '400px'}">
<div class="dialog-form">
<div class="form-field">
<label for="newPassword">New Password</label>
<p-password
id="newPassword"
[(ngModel)]="newPassword"
placeholder="Enter new password"
[feedback]="false"
fluid>
</p-password>
</div>
<div class="form-field">
<label for="confirmPassword">Confirm Password</label>
<p-password
id="confirmPassword"
[(ngModel)]="confirmNewPassword"
placeholder="Confirm new password"
[feedback]="false"
fluid>
</p-password>
</div>
@if (passwordError) {
<div class="error-message">
<i class="pi pi-exclamation-triangle"></i>
<span>{{ passwordError }}</span>
</div>
}
</div>
<ng-template pTemplate="footer">
<div class="dialog-actions">
<p-button
label="Cancel"
severity="secondary"
outlined="true"
(onClick)="isPasswordDialogVisible = false">
</p-button>
<p-button
label="Change Password"
severity="success"
(onClick)="submitPasswordChange()">
</p-button>
</div>
</ng-template>
</p-dialog>
</div>

View File

@@ -1,3 +1,243 @@
.main-container {
width: 100%;
padding: 1rem;
height: calc(100dvh - 10.5rem);
overflow-y: auto;
border-width: 1px;
border-radius: 0.5rem;
@media (min-width: 768px) {
height: calc(100dvh - 11.65rem);
}
}
.enclosing-container {
border-color: var(--p-content-border-color);
background: var(--p-content-background);
}
.settings-header {
margin-top: 1rem;
}
.settings-title {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.25rem;
font-weight: 700;
color: var(--p-text-color);
margin: 0 0 0.75rem 0;
.pi {
color: var(--p-primary-color);
font-size: 1.25rem;
}
}
.settings-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin-bottom: 1rem;
}
.settings-content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.users-section {
@media (min-width: 768px) {
padding: 0.5rem 1rem 0 1rem;
}
.section-title-group {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
}
.section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--p-text-color);
.pi {
color: var(--p-primary-color);
}
}
.table-card {
border: 1px solid var(--p-content-border-color);
border-radius: 8px;
overflow: hidden;
background: var(--p-content-background);
}
.p-datatable {
.p-datatable-table {
border-collapse: separate;
border-spacing: 0;
}
.p-datatable-thead > tr > th {
background: var(--p-surface-100);
border-bottom: 2px solid var(--p-content-border-color);
padding: 1rem;
font-weight: 600;
color: var(--p-text-color);
white-space: nowrap;
}
.p-datatable-tbody > tr {
transition: background-color 0.2s;
&:hover {
background: var(--p-surface-50);
}
&:last-child {
border-bottom: none;
}
}
.p-datatable-tbody > tr > td {
padding: 1rem;
border: none;
}
}
.p-datatable th .header-content {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
}
.permission-header {
text-align: center;
font-size: 0.875rem;
padding: 0.75rem 0.5rem !important;
min-width: 50px;
width: 50px;
}
.actions-header {
text-align: center;
min-width: 80px;
}
.user-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.user-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--p-primary-color);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.875rem;
}
.username {
font-weight: 500;
}
.user-type-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border: 1px solid var(--p-primary-color);
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
color: var(--p-text-color);
}
.library-names {
font-size: 0.875rem;
color: var(--p-text-color);
}
.actions-cell {
text-align: center;
}
.user-dialog {
.p-dialog-content {
padding: 1.5rem;
}
.p-dialog-footer {
padding: 1rem 1.5rem;
}
}
.dialog-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
label {
font-weight: 600;
color: var(--p-text-color);
font-size: 0.875rem;
}
}
.error-message {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: var(--p-red-50);
border: 1px solid var(--p-red-200);
border-radius: 4px;
color: var(--p-red-700);
font-size: 0.875rem;
.pi {
color: var(--p-red-500);
}
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.full-name-column {
min-width: 150px;
width: 150px;
}
.email-column {
min-width: 150px;
width: 200px;
}
.libraries-column {
min-width: 200px;
width: 250px;
}

View File

@@ -4,7 +4,7 @@ import {Button} from 'primeng/button';
import {DialogService, DynamicDialogRef} from 'primeng/dynamicdialog';
import {CreateUserDialogComponent} from './create-user-dialog/create-user-dialog.component';
import {TableModule} from 'primeng/table';
import {LowerCasePipe, NgStyle, TitleCasePipe} from '@angular/common';
import {LowerCasePipe, TitleCasePipe} from '@angular/common';
import {User, UserService} from './user.service';
import {MessageService} from 'primeng/api';
import {Checkbox} from 'primeng/checkbox';
@@ -15,6 +15,7 @@ import {Dialog} from 'primeng/dialog';
import {Password} from 'primeng/password';
import {filter, take, takeUntil} from 'rxjs/operators';
import {Subject} from 'rxjs';
import {Tooltip} from 'primeng/tooltip';
@Component({
selector: 'app-user-management',
@@ -23,12 +24,12 @@ import {Subject} from 'rxjs';
Button,
TableModule,
Checkbox,
NgStyle,
MultiSelect,
Dialog,
Password,
LowerCasePipe,
TitleCasePipe
TitleCasePipe,
Tooltip
],
templateUrl: './user-management.component.html',
styleUrls: ['./user-management.component.scss'],

View File

@@ -1,38 +1,52 @@
<div class="px-4">
<div class="flex items-center gap-2 pb-6 pt-4">
<p class="text-lg font-medium">Open Metadata Center</p>
<i class="pi pi-info-circle text-sky-600"
pTooltip="Decide how you want to view book details — inline as a full page or in a pop-up dialog."
tooltipPosition="right"
style="cursor: pointer;">
</i>
<div class="main-container enclosing-container">
<div class="settings-header">
<h2 class="settings-title">
<i class="pi pi-window-maximize"></i>
Open Metadata Center
</h2>
<p class="settings-description">
Decide how you want to view book details — inline as a full page or in a pop-up dialog.
</p>
</div>
<div class="grid grid-cols-[auto,1fr] pl-6 gap-y-4 gap-x-4 items-center">
<p class="py-1">Display Mode:
<i class="pi pi-info-circle text-sky-600"
pTooltip="Choose whether to open the Metadata Center in the current page or as a popup dialog window."
tooltipPosition="right"
style="cursor: pointer;">
</i>
</p>
<div class="flex gap-4 justify-start">
<div class="flex items-center gap-2">
<p-radioButton name="viewMode"
[value]="'route'"
[(ngModel)]="viewMode"
inputId="viewModeRoute"
(onClick)="onViewModeChange('route')"></p-radioButton>
<label for="viewModeRoute">Route</label>
</div>
<div class="flex items-center gap-2">
<p-radioButton name="viewMode"
[value]="'dialog'"
[(ngModel)]="viewMode"
inputId="viewModeDialog"
(onClick)="onViewModeChange('dialog')"></p-radioButton>
<label for="viewModeDialog">Dialog</label>
<div class="settings-content">
<div class="preferences-section">
<div class="settings-card">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">
Display Mode
<i class="pi pi-info-circle text-sky-600"
pTooltip="Choose whether to open the Metadata Center in the current page or as a popup dialog window."
tooltipPosition="right"
style="cursor: pointer;">
</i>
</label>
<div class="radio-group">
<div class="radio-option">
<p-radioButton name="viewMode"
[value]="'route'"
[(ngModel)]="viewMode"
inputId="viewModeRoute"
(onClick)="onViewModeChange('route')"></p-radioButton>
<label for="viewModeRoute">Route</label>
</div>
<div class="radio-option">
<p-radioButton name="viewMode"
[value]="'dialog'"
[(ngModel)]="viewMode"
inputId="viewModeDialog"
(onClick)="onViewModeChange('dialog')"></p-radioButton>
<label for="viewModeDialog">Dialog</label>
</div>
</div>
</div>
<p class="setting-description">
Choose whether to open the Metadata Center in the current page or as a popup dialog window.
</p>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,152 @@
.main-container {
width: 100%;
padding: 1rem;
overflow-y: auto;
}
.enclosing-container {
border-color: var(--p-content-border-color);
background: var(--p-content-background);
}
.settings-header {
margin-top: 1rem;
margin-bottom: 2rem;
}
.settings-title {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.25rem;
font-weight: 700;
color: var(--p-text-color);
margin: 0 0 0.75rem 0;
.pi {
color: var(--p-primary-color);
font-size: 1.25rem;
}
}
.settings-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin-bottom: 1rem;
}
.settings-content {
display: flex;
flex-direction: column;
gap: 2rem;
}
.preferences-section {
@media (min-width: 768px) {
padding: 0 1rem;
}
}
.settings-card {
border: 1px solid var(--p-content-border-color);
border-radius: 8px;
background: var(--p-content-background);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.setting-item {
display: flex;
align-items: flex-start;
gap: 2rem;
padding: 0.25rem 0;
@media (max-width: 768px) {
flex-direction: column;
gap: 1rem;
}
}
.setting-info {
flex: 1;
min-width: 0;
.setting-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
color: var(--p-text-color);
font-size: 1rem;
margin-bottom: 0.5rem;
}
.setting-label-row {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
.setting-label {
margin-bottom: 0;
flex-shrink: 0;
min-width: 150px;
}
.radio-group {
display: flex;
gap: 1.5rem;
@media (max-width: 768px) {
flex-direction: column;
gap: 0.75rem;
}
}
}
.setting-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
}
}
.setting-control {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: flex-end;
@media (max-width: 768px) {
align-items: flex-start;
width: 100%;
}
}
.radio-group {
display: flex;
gap: 1.5rem;
@media (max-width: 768px) {
flex-direction: column;
gap: 0.75rem;
}
}
.radio-option {
display: flex;
align-items: center;
gap: 0.5rem;
label {
font-size: 0.875rem;
color: var(--p-text-color);
cursor: pointer;
}
}

View File

@@ -1,46 +1,67 @@
<div class="px-4 pb-2">
<div class="flex items-center gap-2 pb-6 pt-4">
<p class="text-lg">Sidebar Library and Shelf Sorting Preference</p>
<i class="pi pi-info-circle text-sky-600"
pTooltip="Configure sorting options for your library and shelves shown in the sidebar."
tooltipPosition="right"
style="cursor: pointer;">
</i>
<div class="main-container enclosing-container">
<div class="settings-header">
<h2 class="settings-title">
<i class="pi pi-sort-alt"></i>
Sidebar Library and Shelf Sorting Preference
</h2>
<p class="settings-description">
Configure sorting options for your library and shelves shown in the sidebar.
</p>
</div>
<div class="grid grid-cols-[auto,1fr] pl-6 gap-y-4 gap-x-4 items-center">
<p class="py-1">Library:
<i class="pi pi-info-circle text-sky-600"
pTooltip="Choose how books are sorted and displayed in the library sidebar."
tooltipPosition="right"
style="cursor: pointer;">
</i>
</p>
<div class="flex gap-4 justify-start">
<p-select
size="small"
[options]="sortingOptions"
[(ngModel)]="selectedLibrarySorting"
(ngModelChange)="onLibrarySortingChange()"
placeholder="Select Sorting">
</p-select>
</div>
<div class="settings-content">
<div class="preferences-section">
<div class="settings-card">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">
Library
<i class="pi pi-info-circle text-sky-600"
pTooltip="Choose how books are sorted and displayed in the library sidebar."
tooltipPosition="right"
style="cursor: pointer;">
</i>
</label>
<p-select
size="small"
[options]="sortingOptions"
[(ngModel)]="selectedLibrarySorting"
(ngModelChange)="onLibrarySortingChange()"
placeholder="Select Sorting">
</p-select>
</div>
<p class="setting-description">
Choose how books are sorted and displayed in the library sidebar.
</p>
</div>
</div>
<p class="py-4">Shelf:
<i class="pi pi-info-circle text-sky-600"
pTooltip="Choose how books are sorted and displayed in the shelf sidebar."
tooltipPosition="right"
style="cursor: pointer;">
</i>
</p>
<div class="flex gap-4 justify-start">
<p-select
size="small"
[options]="sortingOptions"
[(ngModel)]="selectedShelfSorting"
(ngModelChange)="onShelfSortingChange()"
placeholder="Select Sorting">
</p-select>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">
Shelf
<i class="pi pi-info-circle text-sky-600"
pTooltip="Choose how books are sorted and displayed in the shelf sidebar."
tooltipPosition="right"
style="cursor: pointer;">
</i>
</label>
<p-select
size="small"
[options]="sortingOptions"
[(ngModel)]="selectedShelfSorting"
(ngModelChange)="onShelfSortingChange()"
placeholder="Select Sorting">
</p-select>
</div>
<p class="setting-description">
Choose how books are sorted and displayed in the shelf sidebar.
</p>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,133 @@
.main-container {
width: 100%;
padding: 1rem;
overflow-y: auto;
}
.enclosing-container {
border-color: var(--p-content-border-color);
background: var(--p-content-background);
}
.settings-header {
margin-top: 1rem;
margin-bottom: 2rem;
}
.settings-title {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.25rem;
font-weight: 700;
color: var(--p-text-color);
margin: 0 0 0.75rem 0;
.pi {
color: var(--p-primary-color);
font-size: 1.25rem;
}
}
.settings-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin-bottom: 1rem;
}
.settings-content {
display: flex;
flex-direction: column;
gap: 2rem;
}
.preferences-section {
@media (min-width: 768px) {
padding: 0 1rem;
}
}
.settings-card {
border: 1px solid var(--p-content-border-color);
border-radius: 8px;
background: var(--p-content-background);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.setting-item {
display: flex;
align-items: flex-start;
gap: 2rem;
padding: 0.25rem 0;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
@media (max-width: 768px) {
flex-direction: column;
gap: 1rem;
}
}
.setting-info {
flex: 1;
min-width: 0;
.setting-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
color: var(--p-text-color);
font-size: 1rem;
margin-bottom: 0.5rem;
}
.setting-label-row {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
.setting-label {
margin-bottom: 0;
flex-shrink: 0;
min-width: 120px;
}
p-select {
min-width: 200px;
@media (max-width: 768px) {
min-width: 180px;
}
}
}
.setting-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
}
}
.setting-control {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: flex-end;
@media (max-width: 768px) {
align-items: flex-start;
width: 100%;
}
}

View File

@@ -1,7 +1,14 @@
<div class="w-full h-[calc(100dvh-10.5rem)] md:h-[calc(100dvh-11.65rem)] overflow-y-auto border rounded-lg enclosing-container">
<app-view-preferences></app-view-preferences>
<p-divider></p-divider>
<div class="pt-8">
<p-divider></p-divider>
</div>
<app-sidebar-sorting-preferences></app-sidebar-sorting-preferences>
<p-divider></p-divider>
<div class="pt-8">
<p-divider></p-divider>
</div>
<app-meta-center-view-mode-component></app-meta-center-view-mode-component>
<div class="pt-8">
<p-divider></p-divider>
</div>
</div>

View File

@@ -1,74 +1,127 @@
<div class="p-4">
<p class="text-lg pb-6 pt-4">
Library and Shelf View & Sort Preferences:
<i class="pi pi-info-circle text-sky-600"
pTooltip="Configure default and per-library/shelf preferences for sorting and view mode (grid or table) in the browser."
tooltipPosition="right"
style="cursor: pointer;"></i>
</p>
<div class="main-container enclosing-container">
<div class="settings-header">
<h2 class="settings-title">
<i class="pi pi-table"></i>
Library and Shelf View & Sort Preferences
</h2>
<p class="settings-description">
Configure default and per-library/shelf preferences for sorting and view mode (grid or table) in the browser.
</p>
</div>
<div class="grid grid-cols-[auto,1fr] pl-6 gap-y-4 gap-x-4 items-center">
<p>Default:</p>
<div class="flex gap-4 pl-4">
<p-select size="small" [options]="sortOptions" optionLabel="label" optionValue="field"
[(ngModel)]="selectedSort" placeholder="Sort" class="w-full md:w-72"></p-select>
<p-select size="small" [options]="sortDirectionOptions" [(ngModel)]="selectedSortDir"
placeholder="Direction" class="w-full md:w-44"></p-select>
<p-select size="small" [options]="viewModeOptions" [(ngModel)]="selectedView"
placeholder="View" class="w-full md:w-44"></p-select>
<div class="settings-content">
<div class="preferences-section">
<div class="section-header">
<h3 class="section-title">
<i class="pi pi-cog"></i>
Default Settings
</h3>
<p class="section-description">
Set the default sorting and view preferences that will apply to all libraries and shelves unless overridden.
</p>
</div>
<div class="settings-card">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Default Preferences</label>
<div class="select-group">
<p-select size="small" [options]="sortOptions" optionLabel="label" optionValue="field"
[(ngModel)]="selectedSort" placeholder="Sort" class="select-item"></p-select>
<p-select size="small" [options]="sortDirectionOptions" [(ngModel)]="selectedSortDir"
placeholder="Direction" class="select-item"></p-select>
<p-select size="small" [options]="viewModeOptions" [(ngModel)]="selectedView"
placeholder="View" class="select-item"></p-select>
</div>
</div>
<p class="setting-description">
Configure the default sorting field, direction, and view mode for all libraries and shelves.
</p>
</div>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-[auto,1fr] pl-6 gap-y-4 gap-x-4 items-center mt-8">
<p>Overrides:</p>
<div class="flex gap-4">
<p-button label="Add New Override" outlined size="small" (onClick)="addOverride()" severity="info" [disabled]="availableLibraries.length === 0"></p-button>
<div class="preferences-section">
<div class="section-header">
<h3 class="section-title">
<i class="pi pi-list"></i>
Specific Overrides
</h3>
<p class="section-description">
Create specific sorting and view preferences for individual libraries or shelves that override the default settings.
</p>
</div>
<div class="settings-card">
<div class="setting-item no-border">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Override Management</label>
<p-button label="Add New Override" outlined size="small" (onClick)="addOverride()"
severity="info" [disabled]="availableLibraries.length === 0"></p-button>
</div>
<p class="setting-description">
Add custom preferences for specific libraries or shelves.
</p>
</div>
</div>
@if (overrides.length > 0) {
<div class="overrides-table">
<p-table [value]="overrides">
<ng-template pTemplate="header">
<tr>
<th class="min-w-[150px] max-w-[220px]">Type</th>
<th class="min-w-[220px] max-w-[280px]">Name</th>
<th class="min-w-[190px] max-w-[250px]">Sort By</th>
<th class="min-w-[125px]">Sort Dir</th>
<th class="min-w-[125px]">View</th>
<th class="w-[75px] text-center"></th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-override let-rowIndex="rowIndex">
<tr>
<td>
<p-select size="small" [options]="entityTypeOptions" [(ngModel)]="override.entityType"
class="w-full" appendTo="body"></p-select>
</td>
<td>
@if (override.entityType) {
<p-select size="small"
[options]="getAvailableEntities(rowIndex, override.entityType)"
[(ngModel)]="override.library"
optionLabel="label" optionValue="value"
class="w-full" appendTo="body"></p-select>
}
</td>
<td>
<p-select size="small" [options]="sortOptions" optionLabel="label" optionValue="field"
[(ngModel)]="override.sort" class="w-full" appendTo="body"></p-select>
</td>
<td>
<p-select size="small" [options]="sortDirectionOptions" [(ngModel)]="override.sortDir"
class="w-full" appendTo="body"></p-select>
</td>
<td>
<p-select size="small" [options]="viewModeOptions" [(ngModel)]="override.view"
class="w-full" appendTo="body"></p-select>
</td>
<td class="text-center">
<p-button icon="pi pi-trash" severity="danger" size="small" [outlined]="true"
(onClick)="removeOverride(rowIndex)"></p-button>
</td>
</tr>
</ng-template>
</p-table>
</div>
}
<div class="save-section">
<p-button label="Save" icon="pi pi-save" severity="success" outlined size="small" (onClick)="saveSettings()"></p-button>
</div>
</div>
</div>
</div>
<div class="pl-2 pt-2 min-w-[400px] max-w-[1000px]">
@if (overrides.length > 0) {
<p-table [value]="overrides" class="relative z-0" [responsiveLayout]="'scroll'">
<ng-template pTemplate="header">
<tr>
<th class="min-w-[150px] max-w-[220px]">Type</th>
<th class="min-w-[220px] max-w-[280px]">Name</th>
<th class="min-w-[190px] max-w-[250px]">Sort By</th>
<th class="min-w-[125px]">Sort Dir</th>
<th class="min-w-[125px]">View</th>
<th class="w-[75px] text-center"></th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-override let-rowIndex="rowIndex">
<tr>
<td><p-select size="small" [options]="entityTypeOptions" [(ngModel)]="override.entityType"
class="w-full" appendTo="body"></p-select></td>
<td>
@if (override.entityType) {
<p-select size="small"
[options]="getAvailableEntities(rowIndex, override.entityType)"
[(ngModel)]="override.library"
optionLabel="label" optionValue="value"
class="w-full" appendTo="body"></p-select>
}
</td>
<td><p-select size="small" [options]="sortOptions" optionLabel="label" optionValue="field"
[(ngModel)]="override.sort" class="w-full" appendTo="body"></p-select></td>
<td><p-select size="small" [options]="sortDirectionOptions" [(ngModel)]="override.sortDir"
class="w-full" appendTo="body"></p-select></td>
<td><p-select size="small" [options]="viewModeOptions" [(ngModel)]="override.view"
class="w-full" appendTo="body"></p-select></td>
<td class="text-center">
<p-button icon="pi pi-trash" severity="danger" class="p-button-sm" [outlined]="true"
(onClick)="removeOverride(rowIndex)"></p-button>
</td>
</tr>
</ng-template>
</p-table>
}
</div>
<div class="flex justify-start px-6 pt-4">
<p-button label="Save" icon="pi pi-save" outlined size="small" (onClick)="saveSettings()"></p-button>
</div>
</div>

View File

@@ -0,0 +1,223 @@
.main-container {
width: 100%;
padding: 1rem;
}
.enclosing-container {
border-color: var(--p-content-border-color);
background: var(--p-content-background);
}
.settings-header {
margin-top: 1rem;
margin-bottom: 2rem;
}
.settings-title {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.25rem;
font-weight: 700;
color: var(--p-text-color);
margin: 0 0 0.75rem 0;
.pi {
color: var(--p-primary-color);
font-size: 1.25rem;
}
}
.settings-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin-bottom: 1rem;
}
.settings-content {
display: flex;
flex-direction: column;
gap: 2rem;
}
.preferences-section {
@media (min-width: 768px) {
padding: 0 1rem;
}
}
.section-header {
margin-bottom: 1rem;
}
.section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--p-text-color);
margin: 0 0 0.5rem 0;
.pi {
color: var(--p-primary-color);
}
}
.section-description {
display: flex;
align-items: flex-start;
gap: 0.5rem;
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
.pi {
color: var(--p-primary-color);
margin-top: 0.125rem;
flex-shrink: 0;
}
}
.settings-card {
border: 1px solid var(--p-content-border-color);
border-radius: 8px;
background: var(--p-content-background);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.setting-item {
display: flex;
align-items: flex-start;
gap: 2rem;
padding: 0.25rem 0;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
&.no-border {
border-bottom: none;
}
@media (max-width: 768px) {
flex-direction: column;
gap: 1rem;
}
}
.setting-info {
flex: 1;
min-width: 0;
.setting-label {
display: block;
font-weight: 600;
color: var(--p-text-color);
font-size: 1rem;
margin-bottom: 0.5rem;
}
.setting-label-row {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
.setting-label {
margin-bottom: 0;
flex-shrink: 0;
min-width: 180px;
}
.select-group {
display: flex;
gap: 1rem;
flex-wrap: wrap;
@media (max-width: 768px) {
flex-direction: column;
gap: 0.75rem;
width: 100%;
}
.select-item {
min-width: 150px;
@media (max-width: 768px) {
min-width: 100%;
}
}
}
}
.setting-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
}
}
.setting-control {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: flex-end;
@media (max-width: 768px) {
align-items: flex-start;
width: 100%;
}
}
.select-group {
display: flex;
gap: 1rem;
flex-wrap: wrap;
@media (max-width: 768px) {
flex-direction: column;
gap: 0.75rem;
width: 100%;
}
.select-item {
min-width: 200px;
@media (max-width: 768px) {
min-width: 100%;
}
}
}
.overrides-table {
width: 100%;
::ng-deep {
.p-datatable {
.p-datatable-thead > tr > th {
font-weight: 600;
padding: 0.75rem;
}
.p-datatable-tbody > tr > td {
padding: 0.5rem;
}
}
}
}
.save-section {
display: flex;
justify-content: flex-start;
margin-top: 1rem;
}

View File

@@ -4,7 +4,6 @@ import {Button} from 'primeng/button';
import {MessageService} from 'primeng/api';
import {Select} from 'primeng/select';
import {TableModule} from 'primeng/table';
import {Tooltip} from 'primeng/tooltip';
import {User, UserService} from '../../user-management/user.service';
import {LibraryService} from '../../../book/service/library.service';
import {ShelfService} from '../../../book/service/shelf.service';
@@ -18,7 +17,6 @@ import {filter, take, takeUntil} from 'rxjs/operators';
standalone: true,
imports: [
Select,
Tooltip,
FormsModule,
Button,
TableModule,