From 04b02b73d81cccbe69b51fc8df491a9b56b252a8 Mon Sep 17 00:00:00 2001 From: Gauthier Roebroeck Date: Mon, 21 Jul 2025 14:27:09 +0800 Subject: [PATCH] add server settings --- next-ui/src/colada/settings.ts | 34 ++ next-ui/src/components.d.ts | 1 + next-ui/src/components/server/Settings.mdx | 11 + .../src/components/server/Settings.stories.ts | 42 ++ next-ui/src/components/server/Settings.vue | 374 ++++++++++++++++++ next-ui/src/generated/openapi/komga.d.ts | 64 +-- next-ui/src/mocks/api/handlers.ts | 2 + next-ui/src/mocks/api/handlers/settings.ts | 22 ++ next-ui/src/pages/server/settings.mdx | 13 + next-ui/src/pages/server/settings.stories.ts | 55 +++ next-ui/src/pages/server/settings.vue | 60 ++- next-ui/src/types/ThumbnailSize.ts | 31 ++ 12 files changed, 662 insertions(+), 47 deletions(-) create mode 100644 next-ui/src/colada/settings.ts create mode 100644 next-ui/src/components/server/Settings.mdx create mode 100644 next-ui/src/components/server/Settings.stories.ts create mode 100644 next-ui/src/components/server/Settings.vue create mode 100644 next-ui/src/mocks/api/handlers/settings.ts create mode 100644 next-ui/src/pages/server/settings.mdx create mode 100644 next-ui/src/pages/server/settings.stories.ts create mode 100644 next-ui/src/types/ThumbnailSize.ts diff --git a/next-ui/src/colada/settings.ts b/next-ui/src/colada/settings.ts new file mode 100644 index 000000000..ccad77fb4 --- /dev/null +++ b/next-ui/src/colada/settings.ts @@ -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 }) + }, + }) +}) diff --git a/next-ui/src/components.d.ts b/next-ui/src/components.d.ts index 96d01c02a..cecb2420c 100644 --- a/next-ui/src/components.d.ts +++ b/next-ui/src/components.d.ts @@ -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'] } diff --git a/next-ui/src/components/server/Settings.mdx b/next-ui/src/components/server/Settings.mdx new file mode 100644 index 000000000..b36b90f6f --- /dev/null +++ b/next-ui/src/components/server/Settings.mdx @@ -0,0 +1,11 @@ +import { Canvas, Meta } from '@storybook/addon-docs/blocks'; + +import * as Stories from './Settings.stories'; + + + +# ServerSettings + +A form to update the server settings. + + diff --git a/next-ui/src/components/server/Settings.stories.ts b/next-ui/src/components/server/Settings.stories.ts new file mode 100644 index 000000000..3e3bb2b31 --- /dev/null +++ b/next-ui/src/components/server/Settings.stories.ts @@ -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: '', + }), + 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 + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: {}, +} diff --git a/next-ui/src/components/server/Settings.vue b/next-ui/src/components/server/Settings.vue new file mode 100644 index 000000000..5a12346f7 --- /dev/null +++ b/next-ui/src/components/server/Settings.vue @@ -0,0 +1,374 @@ + + + diff --git a/next-ui/src/generated/openapi/komga.d.ts b/next-ui/src/generated/openapi/komga.d.ts index 0b989d541..b853c6929 100644 --- a/next-ui/src/generated/openapi/komga.d.ts +++ b/next-ui/src/generated/openapi/komga.d.ts @@ -2739,7 +2739,7 @@ export interface components { } & (Omit & { value: string; }); - Book: Record; + Book: unknown; BookDto: { /** Format: date-time */ created: Date; @@ -2952,7 +2952,7 @@ export interface components { GreaterThan: { operator: "GreaterThan"; } & (Omit & { - value: Record; + value: unknown; } & Omit); GroupCountDto: { /** Format: int32 */ @@ -2972,7 +2972,7 @@ export interface components { Is: { operator: "Is"; } & (Omit & { - value: Record; + value: unknown; } & Omit & Omit & Omit & Omit & Omit & Omit & Omit & Omit & Omit); IsFalse: { operator: "IsFalse"; @@ -2980,43 +2980,17 @@ export interface components { IsInTheLast: { operator: "IsInTheLast"; } & (Omit & { - 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 & { - value: Record; + value: unknown; } & Omit & Omit & Omit & Omit & Omit & Omit & Omit & Omit & Omit); IsNotInTheLast: { operator: "IsNotInTheLast"; } & (Omit & { - 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; - /** @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 & { - value: Record; + value: unknown; } & Omit); LibraryCreationDto: { analyzeDimensions: boolean; @@ -3701,7 +3675,7 @@ export interface components { ScanRequestDto: { path: string; }; - Series: Record; + 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; + StreamingResponseBody: unknown; StringOp: { operator: string; }; @@ -4038,7 +4012,7 @@ export interface components { href?: string; properties: { [key: string]: { - [key: string]: Record; + [key: string]: unknown; }; }; rel?: string; @@ -4073,7 +4047,7 @@ export interface components { /** @enum {string} */ readingProgression?: "rtl" | "ltr" | "ttb" | "btt" | "auto"; rendition: { - [key: string]: Record; + [key: string]: unknown; }; sortAs?: string; subject: string[]; diff --git a/next-ui/src/mocks/api/handlers.ts b/next-ui/src/mocks/api/handlers.ts index 6a7c0b8ba..567965c30 100644 --- a/next-ui/src/mocks/api/handlers.ts +++ b/next-ui/src/mocks/api/handlers.ts @@ -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 = () => diff --git a/next-ui/src/mocks/api/handlers/settings.ts b/next-ui/src/mocks/api/handlers/settings.ts new file mode 100644 index 000000000..ecee5152e --- /dev/null +++ b/next-ui/src/mocks/api/handlers/settings.ts @@ -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()), +] diff --git a/next-ui/src/pages/server/settings.mdx b/next-ui/src/pages/server/settings.mdx new file mode 100644 index 000000000..fd9b8b2b5 --- /dev/null +++ b/next-ui/src/pages/server/settings.mdx @@ -0,0 +1,13 @@ +import { Canvas, Meta } from '@storybook/addon-docs/blocks'; + +import * as Stories from './settings.stories'; + + + +# Settings + +Page showing the server settings. + +Settings can be edited, the discard and save buttons are disabled if no changes were made. + + diff --git a/next-ui/src/pages/server/settings.stories.ts b/next-ui/src/pages/server/settings.stories.ts new file mode 100644 index 000000000..3a2116030 --- /dev/null +++ b/next-ui/src/pages/server/settings.stories.ts @@ -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: '', + }), + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + }, + args: {}, +} satisfies Meta + +export default meta +type Story = StoryObj + +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)], + }, + }, +} diff --git a/next-ui/src/pages/server/settings.vue b/next-ui/src/pages/server/settings.vue index 56ad829cf..70a7adc18 100644 --- a/next-ui/src/pages/server/settings.vue +++ b/next-ui/src/pages/server/settings.vue @@ -1,9 +1,65 @@ diff --git a/next-ui/src/types/ThumbnailSize.ts b/next-ui/src/types/ThumbnailSize.ts new file mode 100644 index 000000000..cfb45127c --- /dev/null +++ b/next-ui/src/types/ThumbnailSize.ts @@ -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', + }, +})