From e9cf44e3e9fd1b5e951a2ec4e636c22fd0b2e235 Mon Sep 17 00:00:00 2001 From: Gauthier Roebroeck Date: Tue, 24 Mar 2026 10:45:50 +0800 Subject: [PATCH] add sort --- next-ui/src/components.d.ts | 2 + next-ui/src/components/sort/List.stories.ts | 75 +++++++++++++ next-ui/src/components/sort/List.vue | 75 +++++++++++++ .../src/components/sort/TriState.stories.ts | 103 ++++++++++++++++++ next-ui/src/components/sort/TriState.vue | 80 ++++++++++++++ next-ui/src/composables/intlFormatter.ts | 17 +++ next-ui/src/pages/libraries/[id]/series.vue | 20 +++- next-ui/src/stores/app.ts | 14 +++ next-ui/src/types/sort.ts | 95 +++++++++++++++- 9 files changed, 477 insertions(+), 4 deletions(-) create mode 100644 next-ui/src/components/sort/List.stories.ts create mode 100644 next-ui/src/components/sort/List.vue create mode 100644 next-ui/src/components/sort/TriState.stories.ts create mode 100644 next-ui/src/components/sort/TriState.vue create mode 100644 next-ui/src/composables/intlFormatter.ts diff --git a/next-ui/src/components.d.ts b/next-ui/src/components.d.ts index 69f980b1..24194d34 100644 --- a/next-ui/src/components.d.ts +++ b/next-ui/src/components.d.ts @@ -108,6 +108,8 @@ declare module 'vue' { SeriesMenuBottomSheet: typeof import('./components/series/menu/SeriesMenuBottomSheet.vue')['default'] ServerSettings: typeof import('./components/server/Settings.vue')['default'] SnackQueue: typeof import('./components/SnackQueue.vue')['default'] + SortList: typeof import('./components/sort/List.vue')['default'] + SortTriState: typeof import('./components/sort/TriState.vue')['default'] TempDrawer: typeof import('./components/TempDrawer.vue')['default'] ThemeSelector: typeof import('./components/ThemeSelector.vue')['default'] UserAuthenticationActivityTable: typeof import('./components/user/AuthenticationActivityTable.vue')['default'] diff --git a/next-ui/src/components/sort/List.stories.ts b/next-ui/src/components/sort/List.stories.ts new file mode 100644 index 00000000..2bd10a1c --- /dev/null +++ b/next-ui/src/components/sort/List.stories.ts @@ -0,0 +1,75 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import List from './List.vue' +import { fn } from 'storybook/test' + +const meta = { + component: List, + render: (args: object) => ({ + components: { List }, + setup() { + return { args } + }, + template: '', + }), + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + docs: { + description: { + component: 'List of tri-state choices that allows multiple selection.', + }, + }, + }, + args: { + modelValue: [], + 'onUpdate:modelValue': fn(), + items: [ + { label: 'Invertible Asc', key: 'ia', initialOrder: 'asc', invertible: true }, + { label: 'Invertible Desc', key: 'id', initialOrder: 'desc', invertible: true }, + { label: 'Asc only', key: 'a', initialOrder: 'asc', invertible: false }, + { label: 'Desc only', key: 'd', initialOrder: 'desc', invertible: false }, + ], + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: {}, +} + +export const Mandatory: Story = { + args: { + mandatory: true, + }, +} + +export const InitialValue: Story = { + args: { + modelValue: [{ key: 'ia', order: 'asc' }], + color: 'red', + }, +} + +export const MultiSort: Story = { + args: { + multiSort: true, + modelValue: [ + { key: 'ia', order: 'asc' }, + { key: 'a', order: 'asc' }, + ], + }, +} + +export const MultiSortMandatory: Story = { + args: { + multiSort: true, + mandatory: true, + color: 'primary', + modelValue: [ + { key: 'ia', order: 'asc' }, + { key: 'a', order: 'asc' }, + ], + }, +} diff --git a/next-ui/src/components/sort/List.vue b/next-ui/src/components/sort/List.vue new file mode 100644 index 00000000..6410b08a --- /dev/null +++ b/next-ui/src/components/sort/List.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/next-ui/src/components/sort/TriState.stories.ts b/next-ui/src/components/sort/TriState.stories.ts new file mode 100644 index 00000000..97351050 --- /dev/null +++ b/next-ui/src/components/sort/TriState.stories.ts @@ -0,0 +1,103 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import TriState from './TriState.vue' +import { fn } from 'storybook/test' + +const meta = { + component: TriState, + render: (args: object) => ({ + components: { TriState }, + setup() { + return { args } + }, + template: '', + }), + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + docs: { + description: { + component: + 'A tri-state component used for filtering. Can also be configured as a simple checkbox.', + }, + }, + }, + args: { + modelValue: undefined, + sortOption: { + label: 'Title', + key: 'title', + initialOrder: 'asc', + invertible: true, + }, + 'onUpdate:modelValue': fn(), + onChange: fn(), + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: {}, +} + +export const Mandatory: Story = { + args: { + mandatory: true, + }, +} + +export const InitialValueColor: Story = { + args: { + color: 'primary', + modelValue: { key: 'title', order: 'asc' }, + }, +} + +export const BiState: Story = { + args: { + sortOption: { + label: 'Title', + key: 'title', + initialOrder: 'asc', + invertible: false, + }, + }, +} + +export const BiStateMandatory: Story = { + args: { + sortOption: { + label: 'Title', + key: 'title', + initialOrder: 'asc', + invertible: false, + }, + mandatory: true, + }, +} + +export const InitialOrder: Story = { + args: { + sortOption: { + label: 'Title', + key: 'title', + initialOrder: 'desc', + invertible: false, + }, + }, +} + +export const Number: Story = { + args: { + number: 2, + color: 'primary', + modelValue: { key: 'title', order: 'asc' }, + }, +} + +export const NumberNoSort: Story = { + args: { + number: 2, + }, +} diff --git a/next-ui/src/components/sort/TriState.vue b/next-ui/src/components/sort/TriState.vue new file mode 100644 index 00000000..5c05ed1d --- /dev/null +++ b/next-ui/src/components/sort/TriState.vue @@ -0,0 +1,80 @@ + + + + + + + diff --git a/next-ui/src/composables/intlFormatter.ts b/next-ui/src/composables/intlFormatter.ts new file mode 100644 index 00000000..5a92a21f --- /dev/null +++ b/next-ui/src/composables/intlFormatter.ts @@ -0,0 +1,17 @@ +import { useIntl } from 'vue-intl' +import type { SortOption, SortOptionDescriptor } from '@/types/sort' + +export function useIntlFormatter() { + const intl = useIntl() + + function convertSortOptionDescriptor(sortDescriptor: SortOptionDescriptor): SortOption { + return { + label: intl.formatMessage(sortDescriptor.message), + key: sortDescriptor.key, + initialOrder: sortDescriptor.initialOrder, + invertible: sortDescriptor.invertible, + } + } + + return { convertSortOptionDescriptor } +} diff --git a/next-ui/src/pages/libraries/[id]/series.vue b/next-ui/src/pages/libraries/[id]/series.vue index db51e69e..a554b47c 100644 --- a/next-ui/src/pages/libraries/[id]/series.vue +++ b/next-ui/src/pages/libraries/[id]/series.vue @@ -186,6 +186,13 @@ {{ $formatMessage(commonMessages.filterPanelSort) }} + + @@ -286,6 +293,8 @@ import { useRouteQuerySchema } from '@/composables/useRouteQuerySchema' import { authorRoles } from '@/types/referential' import { useIntl } from 'vue-intl' import { commonMessages } from '@/utils/i18n/common-messages' +import { useIntlFormatter } from '@/composables/intlFormatter' +import { sortSeries } from '@/types/sort' const route = useRoute('/libraries/[id]/series') const libraryId = route.params.id @@ -296,7 +305,8 @@ const intl = useIntl() const display = useDisplay() const appStore = useAppStore() const { browsingPageSize } = storeToRefs(appStore) -const presentationMode = appStore.getPresentationMode(`${libraryId}_series`, 'grid') +const viewName = computed(() => `${libraryId}_series`) +const presentationMode = appStore.getPresentationMode(viewName.value, 'grid') const presentationModeEffective = computed(() => display.xs.value ? 'grid' : presentationMode.value, ) @@ -380,6 +390,12 @@ const { data: filterLanguage } = useRouteQuerySchema('language', SchemaFilterStr const { data: filterReleaseYear } = useRouteQuerySchema('year', SchemaSeriesReleaseYears) const { data: filterAgeRating } = useRouteQuerySchema('age', SchemaSeriesAgeRatings) +const { convertSortOptionDescriptor } = useIntlFormatter() +const sortActive = appStore.getSortActive(viewName.value, [ + { key: 'metadata.titleSort', order: 'asc' }, +]) +const sortOptions = sortSeries.map((it) => convertSortOptionDescriptor(it)) + const conds = computed(() => ({ allOf: [ librariesCondition.value as components['schemas']['AnyOfSeries'], @@ -406,7 +422,7 @@ const { data: series } = useQuery(() => search: { condition: conds.value as components['schemas']['AllOfSeries'], }, - pageRequest: PageRequest.FromPageSize(appStore.browsingPageSize, page0.value), + pageRequest: PageRequest.FromPageSize(appStore.browsingPageSize, page0.value, sortActive.value), }), ) diff --git a/next-ui/src/stores/app.ts b/next-ui/src/stores/app.ts index bf088d31..74592016 100644 --- a/next-ui/src/stores/app.ts +++ b/next-ui/src/stores/app.ts @@ -3,6 +3,7 @@ import { defineStore } from 'pinia' import { useDisplay } from 'vuetify' import type { PresentationMode } from '@/types/libraries' import type { PageSize } from '@/types/page' +import type { Sort } from '@/types/PageRequest' export const useAppStore = defineStore('app', { state: () => ({ @@ -17,6 +18,11 @@ export const useAppStore = defineStore('app', { * Use the getter to ensure a default value is always set. */ presentationMode: {} as Record, + /** + * Store the sort order per view. + * Use the getter to ensure a default value is always set. + */ + sortActive: {} as Record, gridCardWidth: 150, // transient reorderLibraries: false, @@ -30,6 +36,14 @@ export const useAppStore = defineStore('app', { }, }) }, + getSortActive: (state) => (key: string, defaultValue: Sort[]) => { + return computed({ + get: () => state.sortActive[key] ?? (state.sortActive[key] = defaultValue), + set: (value) => { + state.sortActive[key] = value + }, + }) + }, }, persist: { key: 'komga.nextui.app', diff --git a/next-ui/src/types/sort.ts b/next-ui/src/types/sort.ts index 984b3c3e..8ff3c734 100644 --- a/next-ui/src/types/sort.ts +++ b/next-ui/src/types/sort.ts @@ -1,8 +1,99 @@ +import { defineMessage, type MessageDescriptor } from 'vue-intl' + export type SortOption = { // for display label: string // sorting key sent to API key: string - // default ordering - defaultOrder: 'asc' | 'desc' + // initial order + initialOrder: SortOrder + // whether the order can be flipped + invertible: boolean } + +export type SortOrder = 'asc' | 'desc' + +export type SortOptionDescriptor = Omit & { message: MessageDescriptor } + +export const sortSeries: SortOptionDescriptor[] = [ + { + message: defineMessage({ + description: 'Sort label: metadata.titleSort', + defaultMessage: 'Title', + id: 'H4Kte4', + }), + key: 'metadata.titleSort', + initialOrder: 'asc', + invertible: true, + }, + { + message: defineMessage({ + description: 'Sort label: createdDate', + defaultMessage: 'Date added', + id: 'TG7prC', + }), + key: 'createdDate', + initialOrder: 'desc', + invertible: true, + }, + { + message: defineMessage({ + description: 'Sort label: lastModifiedDate', + defaultMessage: 'Date updated', + id: 'VHe28r', + }), + key: 'lastModifiedDate', + initialOrder: 'desc', + invertible: true, + }, + { + message: defineMessage({ + description: 'Sort label: readDate', + defaultMessage: 'Date read', + id: 'NasBHg', + }), + key: 'readDate', + initialOrder: 'desc', + invertible: true, + }, + { + message: defineMessage({ + description: 'Sort label: booksMetadata.releaseDate', + defaultMessage: 'Release year', + id: 'J8rAqm', + }), + key: 'booksMetadata.releaseDate', + initialOrder: 'desc', + invertible: true, + }, + { + message: defineMessage({ + description: 'Sort label: name', + defaultMessage: 'Directory name', + id: 'DNVnmS', + }), + key: 'name', + initialOrder: 'asc', + invertible: true, + }, + { + message: defineMessage({ + description: 'Sort label: booksCount', + defaultMessage: 'Books count', + id: 'TAVSfO', + }), + key: 'booksCount', + initialOrder: 'desc', + invertible: true, + }, + { + message: defineMessage({ + description: 'Sort label: random', + defaultMessage: 'Random', + id: 'Vwpr+D', + }), + key: 'random', + initialOrder: 'asc', + invertible: false, + }, +]