mirror of
https://github.com/gotson/komga.git
synced 2025-12-06 16:42:24 +01:00
add server settings
This commit is contained in:
parent
1dd56e00f6
commit
04b02b73d8
12 changed files with 662 additions and 47 deletions
34
next-ui/src/colada/settings.ts
Normal file
34
next-ui/src/colada/settings.ts
Normal 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 })
|
||||
},
|
||||
})
|
||||
})
|
||||
1
next-ui/src/components.d.ts
vendored
1
next-ui/src/components.d.ts
vendored
|
|
@ -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']
|
||||
}
|
||||
|
|
|
|||
11
next-ui/src/components/server/Settings.mdx
Normal file
11
next-ui/src/components/server/Settings.mdx
Normal 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} />
|
||||
42
next-ui/src/components/server/Settings.stories.ts
Normal file
42
next-ui/src/components/server/Settings.stories.ts
Normal 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: {},
|
||||
}
|
||||
374
next-ui/src/components/server/Settings.vue
Normal file
374
next-ui/src/components/server/Settings.vue
Normal 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>
|
||||
64
next-ui/src/generated/openapi/komga.d.ts
vendored
64
next-ui/src/generated/openapi/komga.d.ts
vendored
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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 = () =>
|
||||
|
|
|
|||
22
next-ui/src/mocks/api/handlers/settings.ts
Normal file
22
next-ui/src/mocks/api/handlers/settings.ts
Normal 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()),
|
||||
]
|
||||
13
next-ui/src/pages/server/settings.mdx
Normal file
13
next-ui/src/pages/server/settings.mdx
Normal 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} />
|
||||
55
next-ui/src/pages/server/settings.stories.ts
Normal file
55
next-ui/src/pages/server/settings.stories.ts
Normal 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)],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
31
next-ui/src/types/ThumbnailSize.ts
Normal file
31
next-ui/src/types/ThumbnailSize.ts
Normal 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',
|
||||
},
|
||||
})
|
||||
Loading…
Reference in a new issue