This commit is contained in:
Gauthier Roebroeck 2026-03-24 10:45:50 +08:00
parent 0aa71d6b51
commit e9cf44e3e9
9 changed files with 477 additions and 4 deletions

View file

@ -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']

View 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' },
],
},
}

View 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>

View 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,
},
}

View 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>

View 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 }
}

View file

@ -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),
}),
)

View file

@ -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',

View file

@ -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,
},
]