mirror of
https://github.com/gotson/komga.git
synced 2026-05-07 12:01:40 +02:00
add sort
This commit is contained in:
parent
0aa71d6b51
commit
e9cf44e3e9
9 changed files with 477 additions and 4 deletions
2
next-ui/src/components.d.ts
vendored
2
next-ui/src/components.d.ts
vendored
|
|
@ -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']
|
||||
|
|
|
|||
75
next-ui/src/components/sort/List.stories.ts
Normal file
75
next-ui/src/components/sort/List.stories.ts
Normal file
|
|
@ -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: '<List v-model="args.modelValue"/>',
|
||||
}),
|
||||
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<typeof List>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
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' },
|
||||
],
|
||||
},
|
||||
}
|
||||
75
next-ui/src/components/sort/List.vue
Normal file
75
next-ui/src/components/sort/List.vue
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<template>
|
||||
<v-list v-if="items.length > 0">
|
||||
<SortTriState
|
||||
v-for="(option, i) in items"
|
||||
:key="i"
|
||||
:model-value="dynamicFind(option.key).value"
|
||||
:number="multiSort ? dynamicFindIndex(option.key).value + 1 : undefined"
|
||||
:sort-option="option"
|
||||
:color="color"
|
||||
:mandatory="isChildMandatory"
|
||||
@change="(newVal, oldVal) => internalUpdate(newVal, oldVal)"
|
||||
/>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Sort } from '@/types/PageRequest'
|
||||
import type { SortOption } from '@/types/sort'
|
||||
|
||||
const model = defineModel<Sort[]>({ default: [] })
|
||||
|
||||
const {
|
||||
items = [],
|
||||
color,
|
||||
multiSort = false,
|
||||
mandatory = false,
|
||||
} = defineProps<{
|
||||
items?: SortOption[]
|
||||
color?: string
|
||||
/**
|
||||
* Whether multiple sorts can be applied concurrently. Defaults to false.
|
||||
*/
|
||||
multiSort?: boolean
|
||||
/**
|
||||
* Whether a sort is always required. Defaults to false.
|
||||
*/
|
||||
mandatory?: boolean
|
||||
}>()
|
||||
|
||||
const dynamicFind = (key: string) => computed(() => model.value?.find((it) => it?.key === key))
|
||||
const dynamicFindIndex = (key: string) =>
|
||||
computed(() => model.value?.findIndex((it) => it?.key === key))
|
||||
|
||||
const isChildMandatory = computed(() => {
|
||||
if (mandatory) {
|
||||
if (multiSort) return model.value.length <= 1
|
||||
else return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
function internalUpdate(newVal: Sort | undefined, oldVal: Sort | undefined) {
|
||||
if (multiSort) {
|
||||
const oldIndex = model.value.findIndex((it) => it.key === oldVal?.key)
|
||||
|
||||
// if key is not present, add to end
|
||||
if (oldIndex === -1 && newVal) model.value.push(newVal)
|
||||
|
||||
// if key is present, replace or remove
|
||||
if (oldIndex >= 0) {
|
||||
if (!!newVal) {
|
||||
// replace
|
||||
model.value.splice(oldIndex, 1, newVal)
|
||||
} else {
|
||||
// remove
|
||||
model.value.splice(oldIndex, 1)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
model.value = newVal ? [newVal] : []
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts"></script>
|
||||
103
next-ui/src/components/sort/TriState.stories.ts
Normal file
103
next-ui/src/components/sort/TriState.stories.ts
Normal file
|
|
@ -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: '<TriState v-model="args.modelValue"/>',
|
||||
}),
|
||||
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<typeof TriState>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
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,
|
||||
},
|
||||
}
|
||||
80
next-ui/src/components/sort/TriState.vue
Normal file
80
next-ui/src/components/sort/TriState.vue
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<template>
|
||||
<v-list-item
|
||||
:title="sortOption.label"
|
||||
@click="cycle()"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon
|
||||
:icon="icon"
|
||||
:color="!!model ? color : undefined"
|
||||
/>
|
||||
<!-- Use opacity instead of model-value so that spacing is the same even when hidden -->
|
||||
<v-badge
|
||||
:content="number"
|
||||
inline
|
||||
:color="color"
|
||||
:class="!!model && !!number ? '' : 'opacity-0'"
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SortOption, SortOrder } from '@/types/sort'
|
||||
import type { Sort } from '@/types/PageRequest'
|
||||
|
||||
const model = defineModel<Sort | undefined>()
|
||||
|
||||
const icon = computed(() => {
|
||||
if (model.value?.order === 'asc') return 'i-mdi:chevron-up'
|
||||
if (model.value?.order === 'desc') return 'i-mdi:chevron-down'
|
||||
return undefined
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
change: [newValue: Sort | undefined, oldValue: Sort | undefined]
|
||||
}>()
|
||||
|
||||
const {
|
||||
sortOption,
|
||||
number,
|
||||
color,
|
||||
mandatory = false,
|
||||
} = defineProps<{
|
||||
sortOption: SortOption
|
||||
/**
|
||||
* Sort number, in case of multiSort
|
||||
*/
|
||||
number?: number
|
||||
/**
|
||||
* Base color. Applies to the icon when the value is not `undefined`.
|
||||
*/
|
||||
color?: string
|
||||
mandatory?: boolean
|
||||
}>()
|
||||
|
||||
const states = computed(
|
||||
() =>
|
||||
[
|
||||
sortOption.initialOrder,
|
||||
...(sortOption.invertible ? (sortOption.initialOrder === 'asc' ? ['desc'] : ['asc']) : []),
|
||||
...(mandatory ? [] : [undefined]),
|
||||
] as SortOrder[],
|
||||
)
|
||||
|
||||
function cycle() {
|
||||
const index = states.value.findIndex((x) => x === model.value?.order)
|
||||
const newIndex = (index + 1) % states.value.length
|
||||
if (index === newIndex) return
|
||||
|
||||
const newOrder = states.value[newIndex]
|
||||
const oldVal = model.value
|
||||
const newVal = !!newOrder ? { key: sortOption.key, order: newOrder } : undefined
|
||||
model.value = newVal
|
||||
emit('change', newVal, oldVal)
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts"></script>
|
||||
|
||||
<style scoped></style>
|
||||
17
next-ui/src/composables/intlFormatter.ts
Normal file
17
next-ui/src/composables/intlFormatter.ts
Normal file
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -186,6 +186,13 @@
|
|||
<v-divider />
|
||||
|
||||
<v-list-subheader>{{ $formatMessage(commonMessages.filterPanelSort) }}</v-list-subheader>
|
||||
|
||||
<SortList
|
||||
v-model="sortActive"
|
||||
:items="sortOptions"
|
||||
color="primary"
|
||||
mandatory
|
||||
/>
|
||||
</v-list>
|
||||
</TempDrawer>
|
||||
|
||||
|
|
@ -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),
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, PresentationMode>,
|
||||
/**
|
||||
* Store the sort order per view.
|
||||
* Use the getter to ensure a default value is always set.
|
||||
*/
|
||||
sortActive: {} as Record<string, Sort[]>,
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -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<SortOption, 'label'> & { 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,
|
||||
},
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in a new issue