add server settings

This commit is contained in:
Gauthier Roebroeck 2025-07-21 14:27:09 +08:00
parent 1dd56e00f6
commit 04b02b73d8
12 changed files with 662 additions and 47 deletions

View file

@ -0,0 +1,34 @@
import { defineMutation, defineQuery, useMutation, useQuery, useQueryCache } from '@pinia/colada'
import { komgaClient } from '@/api/komga-client'
import type { components } from '@/generated/openapi/komga'
export const QUERY_KEYS_SETTINGS = {
root: ['settings'] as const,
}
export const useSettings = defineQuery(() => {
return useQuery({
key: () => QUERY_KEYS_SETTINGS.root,
query: () =>
komgaClient
.GET('/api/v1/settings')
// unwrap the openapi-fetch structure on success
.then((res) => res.data),
// 1 hour
staleTime: 60 * 60 * 1000,
gcTime: false,
})
})
export const useUpdateSettings = defineMutation(() => {
const queryCache = useQueryCache()
return useMutation({
mutation: (settings: components['schemas']['SettingsUpdateDto']) =>
komgaClient.PATCH('/api/v1/settings', {
body: settings,
}),
onSuccess: () => {
void queryCache.invalidateQueries({ key: QUERY_KEYS_SETTINGS.root })
},
})
})

View file

@ -35,6 +35,7 @@ declare module 'vue' {
ReleaseCard: typeof import('./components/release/Card.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
ServerSettings: typeof import('./components/server/Settings.vue')['default']
UserDeletionWarning: typeof import('./components/user/DeletionWarning.vue')['default']
UserFormChangePassword: typeof import('./components/user/form/ChangePassword.vue')['default']
}

View file

@ -0,0 +1,11 @@
import { Canvas, Meta } from '@storybook/addon-docs/blocks';
import * as Stories from './Settings.stories';
<Meta of={Stories} />
# ServerSettings
A form to update the server settings.
<Canvas of={Stories.Default} />

View file

@ -0,0 +1,42 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Settings from './Settings.vue'
import { fn } from 'storybook/test'
const meta = {
component: Settings,
render: (args: object) => ({
components: { Settings },
setup() {
return { args }
},
template: '<Settings :settings="args.settings"/>',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {
settings: {
deleteEmptyCollections: true,
deleteEmptyReadLists: false,
rememberMeDurationDays: 365,
thumbnailSize: 'XLARGE',
taskPoolSize: 8,
serverPort: { configurationSource: 8090, effectiveValue: 8090 },
serverContextPath: { effectiveValue: '' },
koboProxy: false,
kepubifyPath: {
configurationSource: '/usr/bin/kepubify',
effectiveValue: '/usr/bin/kepubify',
},
},
onUpdateSettings: fn(),
},
} satisfies Meta<typeof Settings>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}

View file

@ -0,0 +1,374 @@
<template>
<v-confirm-edit
v-model="settingsUpdate"
hide-actions
>
<template v-slot:default="{ model: proxyModel, cancel, save, isPristine }">
<v-form
v-model="formValid"
@submit.prevent="submitForm(save)"
:disabled="loading"
>
<v-list>
<v-list-subheader
:class="headerClass"
class="mb-2"
>{{
$formatMessage({
description: 'Server settings: section header for posters',
defaultMessage: 'Posters',
id: 'a5MYiP',
})
}}
</v-list-subheader>
<v-select
v-model="proxyModel.value.thumbnailSize"
:label="
$formatMessage({
description: 'Server settings: selection of poster size',
defaultMessage: 'Generated poster size',
id: 'eDA9Gm',
})
"
:items="thumbnailSizes"
/>
</v-list>
<v-divider />
<v-list>
<v-list-subheader :class="headerClass"
>{{
$formatMessage({
description: 'Server settings: section header for scan behaviour',
defaultMessage: 'Overall scan behaviour',
id: 'dSlkbn',
})
}}
</v-list-subheader>
<v-checkbox
v-model="proxyModel.value.deleteEmptyCollections"
:label="
$formatMessage({
description: 'Server settings: checkbox to delete empty collections after scan',
defaultMessage: 'Delete empty collections after scan',
id: 'pHdVzh',
})
"
hide-details
/>
<v-checkbox
v-model="proxyModel.value.deleteEmptyReadLists"
:label="
$formatMessage({
description: 'Server settings: checkbox to delete empty readlists after scan',
defaultMessage: 'Delete empty read lists after scan',
id: 'kqV7EJ',
})
"
hide-details
/>
</v-list>
<v-divider />
<v-list>
<v-list-subheader
:class="headerClass"
class="mb-2"
>{{
$formatMessage({
description: 'Server settings: section header for tasks',
defaultMessage: 'Tasks',
id: '8hC76W',
})
}}
</v-list-subheader>
<v-number-input
v-model="proxyModel.value.taskPoolSize"
:label="
$formatMessage({
description: 'Server settings: input field for task threads',
defaultMessage: 'Task threads',
id: 'rHwSrF',
})
"
:min="1"
:rules="['required']"
></v-number-input>
</v-list>
<v-divider />
<v-list>
<v-list-subheader class="mb-4">
<div :class="headerClass">
{{
$formatMessage({
description: 'Server settings: section header for remember me',
defaultMessage: 'Remember me',
id: 'EabQ38',
})
}}
</div>
<div class="text-caption">{{ messageRequiresRestart }}</div>
</v-list-subheader>
<v-number-input
v-model="proxyModel.value.rememberMeDurationDays"
:label="
$formatMessage({
description: 'Server settings: input field for remember me duration',
defaultMessage: 'Remember me duration (in days)',
id: 'iDU5FS',
})
"
:min="1"
:rules="['required']"
></v-number-input>
<v-checkbox
v-model="proxyModel.value.renewRememberMeKey"
:label="
$formatMessage({
description: 'Server settings: checkbox to regenerate the remember me key',
defaultMessage: 'Regenerate the \'remember me\' key',
id: 'UaD47n',
})
"
hide-details
/>
</v-list>
<v-divider />
<v-list>
<v-list-subheader class="mb-4">
<div :class="headerClass">
{{
$formatMessage({
description: 'Server settings: section header for HTTP server',
defaultMessage: 'HTTP Server',
id: '6F1ebQ',
})
}}
</div>
<div class="text-caption">{{ messageRequiresRestart }}</div>
</v-list-subheader>
<v-number-input
v-model="proxyModel.value.serverPort"
:label="
$formatMessage({
description: 'Server settings: input field for server port',
defaultMessage: 'Server listening port',
id: '+r8FCS',
})
"
:min="1"
:max="65535"
:placeholder="settings?.serverPort.configurationSource?.toString()"
:persistent-placeholder="!!settings?.serverPort.configurationSource"
clearable
>
<template
v-slot:append-inner
v-if="!!settings?.serverPort.configurationSource"
>
<v-icon
icon="i-mdi:information-outline"
v-tooltip:bottom="messagePrecedence"
></v-icon>
</template>
</v-number-input>
<v-text-field
v-model="proxyModel.value.serverContextPath"
:label="
$formatMessage({
description: 'Server settings: input field for server base URL',
defaultMessage: 'Server base URL',
id: 'eRJOa6',
})
"
:placeholder="settings?.serverContextPath.configurationSource?.toString()"
:persistent-placeholder="!!settings?.serverContextPath.configurationSource"
clearable
:rules="[
[
'pattern',
/^\/[-a-zA-Z0-9_\/]*[a-zA-Z0-9]$/,
$formatMessage({
description: 'Server settings: error message when server context path is invalid',
defaultMessage:
'Must start with \'/\', not end with \'/-_\', and contain only \'/-_a-z0-9\'',
id: 'Lto2Lg',
}),
],
]"
><template
v-slot:append-inner
v-if="!!settings?.serverContextPath.configurationSource"
>
<v-icon
icon="i-mdi:information-outline"
v-tooltip:bottom="messagePrecedence"
></v-icon> </template
></v-text-field>
</v-list>
<v-divider />
<v-list>
<v-list-subheader :class="headerClass"
>{{
$formatMessage({
description: 'Server settings: section header for Kobo Sync',
defaultMessage: 'Kobo Sync',
id: 'rRFQKU',
})
}}
</v-list-subheader>
<v-checkbox
v-model="proxyModel.value.koboProxy"
:label="
$formatMessage({
description:
'Server settings: checkbox to enable Kobo Store proxying for Kobo Sync ',
defaultMessage: 'Proxy unhandled requests to Kobo Store',
id: 'iNBto3',
})
"
hide-details
/>
<v-number-input
v-model="proxyModel.value.koboPort"
:label="
$formatMessage({
description: 'Server settings: input field for kobo sync port',
defaultMessage: 'Kobo Sync external port',
id: '4AKIbg',
})
"
:min="1"
:max="65535"
:hint="
$formatMessage({
description: 'Server settings: input field hint for kobo sync port',
defaultMessage: 'Set only in case of sync issues with covers and downloads',
id: 'TwB29u',
})
"
persistent-hint
clearable
></v-number-input>
</v-list>
<v-list>
<v-btn
:text="
$formatMessage({
description: 'Server settings: button to discard any changes made',
defaultMessage: 'Discard',
id: 'kh49ZJ',
})
"
@click="cancel"
:disabled="isPristine"
variant="text"
></v-btn>
<v-btn
:text="
$formatMessage({
description: 'Server settings: button to save any changes made',
defaultMessage: 'Save changes',
id: 'FpwJlU',
})
"
type="submit"
:disabled="isPristine"
variant="text"
:loading="loading"
></v-btn>
</v-list>
</v-form>
</template>
</v-confirm-edit>
</template>
<script setup lang="ts">
import { ThumbnailSize, thumbnailSizeMessages } from '@/types/ThumbnailSize'
import { useIntl } from 'vue-intl'
import type { components } from '@/generated/openapi/komga'
const intl = useIntl()
const headerClass = 'text-subtitle1 font-weight-bold'
const { settings, loading = false } = defineProps<{
settings?: components['schemas']['SettingsDto']
loading?: boolean
}>()
const emit = defineEmits<{
updateSettings: [settings: components['schemas']['SettingsUpdateDto']]
}>()
const settingsUpdate = ref<components['schemas']['SettingsUpdateDto']>({
thumbnailSize: ThumbnailSize.DEFAULT,
deleteEmptyCollections: false,
deleteEmptyReadLists: false,
taskPoolSize: 1,
rememberMeDurationDays: 365,
renewRememberMeKey: false,
serverPort: 25600,
serverContextPath: '',
koboProxy: false,
koboPort: undefined,
})
watch(
() => settings,
() => {
if (settings)
settingsUpdate.value = {
thumbnailSize: settings.thumbnailSize,
deleteEmptyCollections: settings.deleteEmptyCollections,
deleteEmptyReadLists: settings.deleteEmptyReadLists,
taskPoolSize: settings.taskPoolSize,
rememberMeDurationDays: settings.rememberMeDurationDays,
renewRememberMeKey: false,
serverPort: settings.serverPort.databaseSource,
serverContextPath: settings.serverContextPath.databaseSource,
koboProxy: settings.koboProxy,
koboPort: settings.koboPort,
}
},
{ immediate: true },
)
const formValid = ref<boolean>(false)
function submitForm(callback: () => void) {
if (formValid.value) {
callback()
emit('updateSettings', settingsUpdate.value)
}
}
const thumbnailSizes = Object.values(ThumbnailSize).map((x) => ({
title: intl.formatMessage(thumbnailSizeMessages[x]),
value: x,
}))
const messageRequiresRestart = intl.formatMessage({
description: 'Server settings: input field hint for settings that require a restart',
defaultMessage: 'Requires restart to take effect',
id: '6Kd/YV',
})
const messagePrecedence = intl.formatMessage({
description: 'Server settings: tooltip shown for configuration items that can be overriden',
defaultMessage: 'Takes precedence over the configuration file',
id: 'l7+H/k',
})
</script>

View file

@ -2739,7 +2739,7 @@ export interface components {
} & (Omit<components["schemas"]["StringOp"], "operator"> & {
value: string;
});
Book: Record<string, never>;
Book: unknown;
BookDto: {
/** Format: date-time */
created: Date;
@ -2952,7 +2952,7 @@ export interface components {
GreaterThan: {
operator: "GreaterThan";
} & (Omit<components["schemas"]["NumericNullableInteger"], "operator"> & {
value: Record<string, never>;
value: unknown;
} & Omit<components["schemas"]["NumericFloat"], "operator">);
GroupCountDto: {
/** Format: int32 */
@ -2972,7 +2972,7 @@ export interface components {
Is: {
operator: "Is";
} & (Omit<components["schemas"]["NumericNullableInteger"], "operator"> & {
value: Record<string, never>;
value: unknown;
} & Omit<components["schemas"]["EqualityAuthorMatch"], "operator"> & Omit<components["schemas"]["EqualityString"], "operator"> & Omit<components["schemas"]["EqualityNullableString"], "operator"> & Omit<components["schemas"]["EqualityReadStatus"], "operator"> & Omit<components["schemas"]["EqualityStatus"], "operator"> & Omit<components["schemas"]["StringOp"], "operator"> & Omit<components["schemas"]["EqualityMediaProfile"], "operator"> & Omit<components["schemas"]["NumericFloat"], "operator"> & Omit<components["schemas"]["EqualityPosterMatch"], "operator">);
IsFalse: {
operator: "IsFalse";
@ -2980,43 +2980,17 @@ export interface components {
IsInTheLast: {
operator: "IsInTheLast";
} & (Omit<components["schemas"]["Date"], "operator"> & {
duration: {
/** Format: int32 */
nano?: number;
negative?: boolean;
positive?: boolean;
/** Format: int64 */
seconds?: number;
units?: {
dateBased?: boolean;
durationEstimated?: boolean;
timeBased?: boolean;
}[];
zero?: boolean;
};
duration: string;
});
IsNot: {
operator: "IsNot";
} & (Omit<components["schemas"]["NumericNullableInteger"], "operator"> & {
value: Record<string, never>;
value: unknown;
} & Omit<components["schemas"]["EqualityAuthorMatch"], "operator"> & Omit<components["schemas"]["EqualityString"], "operator"> & Omit<components["schemas"]["EqualityNullableString"], "operator"> & Omit<components["schemas"]["EqualityReadStatus"], "operator"> & Omit<components["schemas"]["EqualityStatus"], "operator"> & Omit<components["schemas"]["StringOp"], "operator"> & Omit<components["schemas"]["EqualityMediaProfile"], "operator"> & Omit<components["schemas"]["NumericFloat"], "operator"> & Omit<components["schemas"]["EqualityPosterMatch"], "operator">);
IsNotInTheLast: {
operator: "IsNotInTheLast";
} & (Omit<components["schemas"]["Date"], "operator"> & {
duration: {
/** Format: int32 */
nano?: number;
negative?: boolean;
positive?: boolean;
/** Format: int64 */
seconds?: number;
units?: {
dateBased?: boolean;
durationEstimated?: boolean;
timeBased?: boolean;
}[];
zero?: boolean;
};
duration: string;
});
IsNotNull: {
operator: "IsNotNull";
@ -3033,7 +3007,6 @@ export interface components {
IsTrue: {
operator: "IsTrue";
} & Omit<components["schemas"]["Boolean"], "operator">;
/** @description Author of the item */
ItemAuthorDto: {
/**
* @description Author's name
@ -3047,7 +3020,9 @@ export interface components {
url?: string;
};
ItemDto: {
/** @description Additional fields for the item */
_komga?: components["schemas"]["KomgaExtensionDto"];
/** @description Author of the item */
author?: components["schemas"]["ItemAuthorDto"];
/**
* @description HTML of the item
@ -3152,7 +3127,6 @@ export interface components {
*/
version: string;
};
/** @description Additional fields for the item */
KomgaExtensionDto: {
/**
* @description Whether the current item has been marked read by the current user
@ -3166,7 +3140,7 @@ export interface components {
LessThan: {
operator: "LessThan";
} & (Omit<components["schemas"]["NumericNullableInteger"], "operator"> & {
value: Record<string, never>;
value: unknown;
} & Omit<components["schemas"]["NumericFloat"], "operator">);
LibraryCreationDto: {
analyzeDimensions: boolean;
@ -3701,7 +3675,7 @@ export interface components {
ScanRequestDto: {
path: string;
};
Series: Record<string, never>;
Series: unknown;
SeriesDto: {
/** Format: int32 */
booksCount: number;
@ -3809,16 +3783,16 @@ export interface components {
};
SettingMultiSourceInteger: {
/** Format: int32 */
configurationSource: number;
configurationSource?: number;
/** Format: int32 */
databaseSource: number;
databaseSource?: number;
/** Format: int32 */
effectiveValue: number;
effectiveValue?: number;
};
SettingMultiSourceString: {
configurationSource: string;
databaseSource: string;
effectiveValue: string;
configurationSource?: string;
databaseSource?: string;
effectiveValue?: string;
};
SettingsDto: {
deleteEmptyCollections: boolean;
@ -3867,7 +3841,7 @@ export interface components {
sorted?: boolean;
unsorted?: boolean;
};
StreamingResponseBody: Record<string, never>;
StreamingResponseBody: unknown;
StringOp: {
operator: string;
};
@ -4038,7 +4012,7 @@ export interface components {
href?: string;
properties: {
[key: string]: {
[key: string]: Record<string, never>;
[key: string]: unknown;
};
};
rel?: string;
@ -4073,7 +4047,7 @@ export interface components {
/** @enum {string} */
readingProgression?: "rtl" | "ltr" | "ttb" | "btt" | "auto";
rendition: {
[key: string]: Record<string, never>;
[key: string]: unknown;
};
sortAs?: string;
subject: string[];

View file

@ -5,6 +5,7 @@ import { HttpResponse } from 'msw'
import { librariesHandlers } from '@/mocks/api/handlers/libraries'
import { referentialHandlers } from '@/mocks/api/handlers/referential'
import { usersHandlers } from '@/mocks/api/handlers/users'
import { settingsHandlers } from '@/mocks/api/handlers/settings'
export const handlers = [
...librariesHandlers,
@ -13,6 +14,7 @@ export const handlers = [
...announcementHandlers,
...releasesHandlers,
...usersHandlers,
...settingsHandlers,
]
export const response401Unauthorized = () =>

View file

@ -0,0 +1,22 @@
import { httpTyped } from '@/mocks/api/httpTyped'
import { ThumbnailSize } from '@/types/ThumbnailSize'
export const settings = {
deleteEmptyCollections: true,
deleteEmptyReadLists: false,
rememberMeDurationDays: 365,
thumbnailSize: ThumbnailSize.XLARGE,
taskPoolSize: 8,
serverPort: { configurationSource: 8090, effectiveValue: 8090 },
serverContextPath: { effectiveValue: '' },
koboProxy: false,
kepubifyPath: {
configurationSource: '/usr/bin/kepubify',
effectiveValue: '/usr/bin/kepubify',
},
}
export const settingsHandlers = [
httpTyped.get('/api/v1/settings', ({ response }) => response(200).json(settings)),
httpTyped.patch('/api/v1/settings', ({ response }) => response(204).empty()),
]

View file

@ -0,0 +1,13 @@
import { Canvas, Meta } from '@storybook/addon-docs/blocks';
import * as Stories from './settings.stories';
<Meta of={Stories} />
# Settings
Page showing the server settings.
Settings can be edited, the discard and save buttons are disabled if no changes were made.
<Canvas of={Stories.Default} />

View file

@ -0,0 +1,55 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import settings from './settings.vue'
import SnackQueue from '@/fragments/fragment/SnackQueue.vue'
import { http, delay } from 'msw'
import { response401Unauthorized } from '@/mocks/api/handlers'
const meta = {
component: settings,
subcomponents: { SnackQueue },
render: (args: object) => ({
components: { settings, SnackQueue },
setup() {
return { args }
},
template: '<settings /><SnackQueue/>',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {},
} satisfies Meta<typeof settings>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}
export const SaveFail: Story = {
args: {},
parameters: {
msw: {
handlers: [http.patch('*/api/v1/settings', response401Unauthorized)],
},
},
}
export const Loading: Story = {
parameters: {
msw: {
handlers: [http.all('*', async () => await delay(5_000))],
},
},
}
export const Error: Story = {
parameters: {
msw: {
handlers: [http.all('*', response401Unauthorized)],
},
},
}

View file

@ -1,9 +1,65 @@
<template>
<h1>Settings</h1>
<v-skeleton-loader
type="article@6, button@2"
v-if="isPending"
/>
<v-empty-state
v-else-if="error"
icon="i-mdi:connection"
:title="$formatMessage(commonMessages.somethingWentWrongTitle)"
:text="$formatMessage(commonMessages.somethingWentWrongSubTitle)"
/>
<template v-else-if="settings">
<div class="d-flex">
<ServerSettings
:settings="settings"
:loading="loading"
@update-settings="(s) => saveSettings(s)"
/>
</div>
</template>
</template>
<script lang="ts" setup>
//
import { useSettings, useUpdateSettings } from '@/colada/settings'
import { commonMessages } from '@/utils/i18n/common-messages'
import type { components } from '@/generated/openapi/komga'
import { useMessagesStore } from '@/stores/messages'
import type { ErrorCause } from '@/api/komga-client'
import { useIntl } from 'vue-intl'
const intl = useIntl()
const messagesStore = useMessagesStore()
const loading = ref<boolean>(false)
const { data: settings, error, isPending, refetch } = useSettings()
const { mutateAsync } = useUpdateSettings()
function saveSettings(settings: components['schemas']['SettingsUpdateDto']) {
loading.value = true
mutateAsync(settings)
.then(() =>
messagesStore.messages.push({
text: intl.formatMessage({
description: 'Snackbar notification shown upon successful server settings update',
defaultMessage: 'Settings updated',
id: 'TL5bVZ',
}),
}),
)
.catch((error) => {
messagesStore.messages.push({
text:
(error?.cause as ErrorCause)?.message || intl.formatMessage(commonMessages.networkError),
})
})
.finally(() => {
loading.value = false
void refetch()
})
}
</script>
<route lang="yaml">

View file

@ -0,0 +1,31 @@
import { defineMessages } from 'vue-intl'
export enum ThumbnailSize {
DEFAULT = 'DEFAULT',
MEDIUM = 'MEDIUM',
LARGE = 'LARGE',
XLARGE = 'XLARGE',
}
export const thumbnailSizeMessages = defineMessages({
[ThumbnailSize.DEFAULT]: {
description: 'Thumbnail size: default',
defaultMessage: 'Default (300px)',
id: '0DGOZl',
},
[ThumbnailSize.MEDIUM]: {
description: 'Thumbnail size: medium',
defaultMessage: 'Medium (600px)',
id: 't1LnqS',
},
[ThumbnailSize.LARGE]: {
description: 'Thumbnail size: large',
defaultMessage: 'Large (900px)',
id: 'hxUnz6',
},
[ThumbnailSize.XLARGE]: {
description: 'Thumbnail size: x-large',
defaultMessage: 'X-Large (1200px)',
id: 'C7iLlR',
},
})