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 @@
+
+
+ internalUpdate(newVal, oldVal)"
+ />
+
+
+
+
+
+
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,
+ },
+]