Add "OPDS Server Enabled" button to OPDS v2 settings

This commit is contained in:
aditya.chandel
2025-09-01 15:38:28 -06:00
committed by Aditya Chandel
parent cdd9b06b1d
commit 47e24e2241
8 changed files with 552 additions and 325 deletions

View File

@@ -125,7 +125,6 @@ export interface AppSettings {
export enum AppSettingKey {
QUICK_BOOK_MATCH = 'QUICK_BOOK_MATCH',
AUTO_BOOK_SEARCH = 'AUTO_BOOK_SEARCH',
COVER_IMAGE_RESOLUTION = 'COVER_IMAGE_RESOLUTION',
SIMILAR_BOOK_RECOMMENDATION = 'SIMILAR_BOOK_RECOMMENDATION',
UPLOAD_FILE_PATTERN = 'UPLOAD_FILE_PATTERN',
OPDS_SERVER_ENABLED = 'OPDS_SERVER_ENABLED',

View File

@@ -89,7 +89,7 @@
border: 1px solid var(--p-content-border-color);
border-radius: 8px;
background: var(--p-content-background);
padding: 1rem 2rem 2rem 2rem;
padding: 1rem 1.5rem 1.5rem 1.5rem;
margin-left: 1rem;
margin-right: 1rem;
display: flex;
@@ -107,7 +107,7 @@
display: flex;
align-items: flex-start;
gap: 2rem;
padding: 0.75rem 0;
padding: 0.5rem 0;
&:last-child {
border-bottom: none;
@@ -117,7 +117,7 @@
@media (max-width: 768px) {
flex-direction: column;
gap: 1rem;
padding: 0.75rem 0;
padding: 0.5rem 0;
}
}

View File

@@ -2,7 +2,7 @@
<div class="settings-header">
<h2 class="settings-title">
<i class="pi pi-server"></i>
OPDS Settings
OPDS Settings v2
</h2>
<p class="settings-description">
Manage your OPDS credentials and control how your book collection is shared with reading apps.
@@ -12,134 +12,177 @@
@if (hasPermission) {
<div class="settings-content">
<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"
severity="info"
outlined
size="small"
(onClick)="copyEndpoint()">
</p-button>
</div>
</div>
</div>
<div class="users-section">
<div class="preferences-section">
<div class="section-header">
<div class="section-title-group">
<h3 class="section-title">
<i class="pi pi-power-off"></i>
Server Control
</h3>
</div>
<div class="settings-card">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">OPDS Server Enabled</label>
<p-toggleswitch
[(ngModel)]="opdsEnabled"
(onChange)="toggleOpdsServer()">
</p-toggleswitch>
</div>
<p class="setting-description">
<i class="pi pi-info-circle"></i>
Enable or disable the OPDS server to control access to your book collection through reading apps.
</p>
</div>
</div>
</div>
</div>
@if (opdsEnabled) {
<div class="preferences-section">
<div class="section-header">
<h3 class="section-title">
<i class="pi pi-users"></i>
OPDS Users
<i class="pi pi-link"></i>
OPDS Endpoint
</h3>
<p-button
icon="pi pi-plus"
label="Add User"
severity="success"
size="small"
(onClick)="showCreateUserDialog = true">
</p-button>
</div>
<div class="settings-card">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Endpoint URL</label>
<div class="input-group">
<input
id="endpoint-url"
class="endpoint-input"
fluid
type="text"
pInputText
[value]="opdsEndpoint"
readonly/>
<p-button
icon="pi pi-copy"
severity="info"
outlined
size="small"
(onClick)="copyEndpoint()">
</p-button>
</div>
</div>
<p class="setting-description">
<i class="pi pi-info-circle"></i>
Use this URL to connect your reading apps to your OPDS catalog.
</p>
</div>
</div>
</div>
</div>
<div class="table-card">
<p-table
[value]="users"
[paginator]="users.length > 10"
[rows]="10"
[showCurrentPageReport]="true"
currentPageReportTemplate="Showing {first} to {last} of {totalRecords} users">
<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-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 let-rowIndex="rowIndex">
<tr>
<td>
<div class="user-info">
<div class="user-avatar">
{{ user.username.charAt(0).toUpperCase() }}
<div class="preferences-section">
<div class="section-header">
<div class="section-header-content">
<h3 class="section-title">
<i class="pi pi-users"></i>
OPDS Users
</h3>
<p-button
icon="pi pi-plus"
label="Add User"
severity="success"
size="small"
(onClick)="showCreateUserDialog = true">
</p-button>
</div>
</div>
<div class="table-card">
<p-table
[value]="users"
[paginator]="users.length > 10"
[rows]="10"
[showCurrentPageReport]="true"
currentPageReportTemplate="Showing {first} to {last} of {totalRecords} users">
<ng-template pTemplate="header">
<tr>
<th>
<div class="header-content">
<i class="pi pi-user"></i>
<span>Username</span>
</div>
<span class="username">{{ user.username }}</span>
</div>
</td>
<td>
<div class="flex items-center gap-2">
<p-password
fluid
class="w-32 md:w-56"
[(ngModel)]="dummyPassword"
[feedback]="false"
</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 let-rowIndex="rowIndex">
<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>
<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="Passwords are hidden for security reasons. To change, delete the user and create a new one with a new password."
tooltipPosition="right"
style="cursor: pointer;">
</i>
</div>
</td>
<td class="actions-cell">
<p-button
icon="pi pi-trash"
severity="danger"
size="small"
[disabled]="true"
[toggleMask]="false">
</p-password>
<i
class="pi pi-info-circle text-gray-400"
pTooltip="Passwords are hidden for security reasons. To change, delete the user and create a new one with a new password."
tooltipPosition="right"
style="cursor: pointer;">
</i>
</div>
</td>
<td class="actions-cell">
<p-button
icon="pi pi-trash"
severity="danger"
size="small"
[outlined]="true"
[rounded]="true"
(onClick)="confirmDelete(user)"
pTooltip="Delete user">
</p-button>
</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>
</p-table>
[outlined]="true"
[rounded]="true"
(onClick)="confirmDelete(user)"
pTooltip="Delete user">
</p-button>
</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>
</p-table>
</div>
</div>
</div>
}
</div>
<p-dialog

View File

@@ -16,6 +16,179 @@
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;
}
.settings-content {
display: flex;
flex-direction: column;
gap: 2rem;
}
.preferences-section {
@media (min-width: 768px) {
padding: 0 1rem;
}
}
.section-header {
margin-bottom: 1rem;
}
.section-header-content {
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;
}
}
.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;
}
.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;
}
}
}
.input-group {
display: flex;
align-items: center;
gap: 0.75rem;
@media (max-width: 768px) {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
}
.endpoint-input {
width: 100%;
min-width: 25rem;
}
.table-card {
border: 1px solid var(--p-content-border-color);
border-radius: 8px;
overflow: hidden;
background: var(--p-content-background);
}
.p-datatable th .header-content {
display: flex;
align-items: center;
@@ -28,19 +201,6 @@
vertical-align: middle;
}
.user-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.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;
@@ -73,6 +233,12 @@
}
}
.user-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.user-avatar {
width: 32px;
height: 32px;
@@ -90,17 +256,6 @@
font-weight: 500;
}
.password-field {
display: flex;
align-items: center;
gap: 0.5rem;
}
.password-hidden, .password-visible {
font-family: monospace;
min-width: 80px;
}
.actions-cell {
text-align: center;
}
@@ -128,9 +283,6 @@
}
.user-dialog {
.p-dialog-header {
}
.p-dialog-content {
padding: 1.5rem;
}
@@ -180,88 +332,6 @@
gap: 0.75rem;
}
.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;
}
.settings-content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.endpoint-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);
.pi {
color: var(--p-primary-color);
}
}
.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: 1rem;
}
.section-title-group {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
}
.access-denied-card {
display: flex;
align-items: center;

View File

@@ -10,10 +10,13 @@ import {FormsModule} from '@angular/forms';
import {ConfirmDialog} from 'primeng/confirmdialog';
import {ConfirmationService, MessageService} from 'primeng/api';
import {OpdsUserV2, OpdsUserV2CreateRequest, OpdsV2Service} from './opds-v2.service';
import {catchError, filter, takeUntil, tap} from 'rxjs/operators';
import {catchError, filter, take, takeUntil, tap} from 'rxjs/operators';
import {UserService} from '../user-management/user.service';
import {of, Subject} from 'rxjs';
import {of, pipe, Subject} from 'rxjs';
import {Password} from 'primeng/password';
import {ToggleSwitch} from 'primeng/toggleswitch';
import {AppSettingsService} from '../../core/service/app-settings.service';
import {AppSettingKey} from '../../core/model/app-settings.model';
@Component({
selector: 'app-opds-settings-v2',
@@ -26,7 +29,8 @@ import {Password} from 'primeng/password';
FormsModule,
ConfirmDialog,
TableModule,
Password
Password,
ToggleSwitch
],
providers: [ConfirmationService],
templateUrl: './opds-settings-v2.html',
@@ -35,11 +39,13 @@ import {Password} from 'primeng/password';
export class OpdsSettingsV2 implements OnInit, OnDestroy {
opdsEndpoint = `${API_CONFIG.BASE_URL}/api/v2/opds/catalog`;
opdsEnabled = false;
private opdsService = inject(OpdsV2Service);
private confirmationService = inject(ConfirmationService);
private messageService = inject(MessageService);
private userService = inject(UserService);
private appSettingsService = inject(AppSettingsService);
users: OpdsUserV2[] = [];
loading = false;
@@ -61,10 +67,26 @@ export class OpdsSettingsV2 implements OnInit, OnDestroy {
this.hasPermission = !!(state.user?.permissions.canAccessOpds || state.user?.permissions.admin);
}),
filter(() => this.hasPermission),
tap(() => this.loadUsers())
tap(() => this.loadAppSettings())
).subscribe();
}
private loadAppSettings(): void {
this.appSettingsService.appSettings$
.pipe(
filter((settings): settings is NonNullable<typeof settings> => settings != null),
take(1)
)
.subscribe(settings => {
this.opdsEnabled = settings.opdsServerEnabled ?? false;
if (this.opdsEnabled) {
this.loadUsers();
} else {
this.loading = false;
}
});
}
private loadUsers(): void {
this.opdsService.getUser().pipe(
takeUntil(this.destroy$),
@@ -135,6 +157,29 @@ export class OpdsSettingsV2 implements OnInit, OnDestroy {
});
}
toggleOpdsServer(): void {
this.saveSetting(AppSettingKey.OPDS_SERVER_ENABLED, this.opdsEnabled);
if (this.opdsEnabled) {
this.loadUsers();
} else {
this.users = [];
}
}
private saveSetting(key: string, value: unknown): void {
this.appSettingsService.saveSettings([{key, newValue: value}]).subscribe({
next: () => {
const successMessage = (value === true)
? 'OPDS Server Enabled.'
: 'OPDS Server Disabled.';
this.showMessage('success', 'Settings Saved', successMessage);
},
error: () => {
this.showMessage('error', 'Error', 'There was an error saving the settings.');
}
});
}
private resetCreateUserDialog(): void {
this.showCreateUserDialog = false;
this.newUser = {username: '', password: ''};

View File

@@ -2,16 +2,10 @@
<div class="settings-header">
<h2 class="settings-title">
<i class="pi pi-server"></i>
OPDS Settings (v1)
OPDS Settings v1 (Legacy)
</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="deprecation-notice">
@@ -19,7 +13,7 @@
<div>
<p class="notice-title">Deprecated:</p>
<p class="notice-text">
OPDS (v1) support will be removed in a future release.
OPDS v1 support will be removed in a future release.
Please migrate to <strong>OPDS v2</strong> for continued support and improvements.
</p>
</div>
@@ -27,56 +21,79 @@
</div>
<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 class="preferences-section">
<div class="section-header">
<h3 class="section-title">
<i class="pi pi-power-off"></i>
Server Control
</h3>
</div>
<div class="settings-card">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">OPDS Server Enabled</label>
<p-toggleswitch
[(ngModel)]="opdsEnabled"
(onChange)="toggleOpdsServer()">
</p-toggleswitch>
</div>
<p class="setting-description">
<i class="pi pi-info-circle"></i>
Enable or disable the OPDS server to control access to your book collection through reading apps.
</p>
</div>
</div>
</div>
</div>
@if (opdsEnabled) {
<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 class="preferences-section">
<div class="section-header">
<h3 class="section-title">
<i class="pi pi-link"></i>
OPDS Endpoint
</h3>
</div>
<div class="settings-card">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Endpoint URL</label>
<div class="input-group">
<input
id="endpoint-url"
class="endpoint-input"
type="text"
pInputText
[value]="opdsEndpoint"
readonly/>
<p-button
icon="pi pi-copy"
severity="info"
outlined
size="small"
(onClick)="copyOpdsEndpoint()">
</p-button>
</div>
</div>
<p class="setting-description">
<i class="pi pi-info-circle"></i>
Use this URL to connect your reading apps to your OPDS catalog.
</p>
</div>
</div>
</div>
</div>
<div class="users-section">
<div class="preferences-section">
<div class="section-header">
<div class="section-title-group">
<h3 class="section-title">
<i class="pi pi-users"></i>
OPDS Users
</h3>
</div>
<h3 class="section-title">
<i class="pi pi-users"></i>
OPDS Users
</h3>
</div>
<div class="table-card">

View File

@@ -82,15 +82,16 @@
gap: 2rem;
}
.server-section {
background: var(--p-content-background);
border-radius: 8px;
.preferences-section {
@media (min-width: 768px) {
padding: 0 1rem 0 1rem;
padding: 0 1rem;
}
}
.section-header {
margin-bottom: 1rem;
}
.section-title {
display: flex;
align-items: center;
@@ -98,58 +99,110 @@
font-size: 1.125rem;
font-weight: 600;
color: var(--p-text-color);
margin-bottom: 1rem;
margin: 0;
.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);
.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 (min-width: 768px) {
padding: 0 1rem 0 1rem;
.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;
}
}
.endpoint-form {
margin-top: 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;
}
}
}
.endpoint-field {
.input-group {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 0.75rem;
@media (max-width: 768px) {
flex-direction: column;
align-items: flex-start;
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;
}
max-width: 30rem;
width: 100%;
}
.table-card {

View File

@@ -33,7 +33,7 @@
</p-tab>
}
<p-tab [value]="SettingsTab.OpdsV2">
<i class="pi pi-globe"></i> OPDS V2
<i class="pi pi-globe"></i> OPDS v2
</p-tab>
<p-tab [value]="SettingsTab.DeviceSettings">
<i class="pi pi-mobile"></i> Devices