filter by read status and other stuff

This commit is contained in:
Gauthier Roebroeck 2026-03-23 13:37:52 +08:00
parent 44d2ca6d2d
commit 92482a9f39
15 changed files with 378 additions and 149 deletions

View file

@ -40,6 +40,7 @@ declare module 'vue' {
FilterByGenre: typeof import('./components/filter/by/Genre.vue')['default']
FilterByLanguage: typeof import('./components/filter/by/Language.vue')['default']
FilterByPublisher: typeof import('./components/filter/by/Publisher.vue')['default']
FilterByReadStatus: typeof import('./components/filter/by/ReadStatus.vue')['default']
FilterByReleaseYear: typeof import('./components/filter/by/ReleaseYear.vue')['default']
FilterBySeriesStatus: typeof import('./components/filter/by/SeriesStatus.vue')['default']
FilterBySharingLabel: typeof import('./components/filter/by/SharingLabel.vue')['default']
@ -107,6 +108,7 @@ 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']
TempDrawer: typeof import('./components/TempDrawer.vue')['default']
ThemeSelector: typeof import('./components/ThemeSelector.vue')['default']
UserAuthenticationActivityTable: typeof import('./components/user/AuthenticationActivityTable.vue')['default']
UserDeletionWarning: typeof import('./components/user/DeletionWarning.vue')['default']

View file

@ -1,7 +1,7 @@
<template>
<v-menu>
<template #activator="{ props }">
<v-btn
<v-icon-btn
v-bind="props"
icon="i-mdi:translate"
:aria-label="

View file

@ -1,7 +1,7 @@
<template>
<v-menu>
<template #activator="{ props }">
<v-btn
<v-icon-btn
v-bind="props"
icon="i-mdi:view-grid-plus"
:aria-label="

View file

@ -1,5 +1,5 @@
<template>
<v-btn
<v-icon-btn
:id="id"
v-tooltip:bottom="allModes[currentMode].title"
:icon="allModes[currentMode].icon"

View file

@ -0,0 +1,30 @@
<template>
<!-- Teleport is needed so that the scrim covers the whole screen -->
<Teleport to="#app">
<!-- order=-1 is needed for the drawer to open full height -->
<!-- disable-route-watcher is needed, else the drawer closes when the route query params are updated when the filters change -->
<v-navigation-drawer
v-model="model"
:location="location"
temporary
order="-1"
disable-route-watcher
>
<v-icon-btn
icon="i-mdi:close"
variant="text"
class="position-absolute top-0 right-0 me-2 mt-1"
style="z-index: 2"
@click="model = false"
/>
<slot />
</v-navigation-drawer>
</Teleport>
</template>
<script setup lang="ts">
const model = defineModel<boolean>({ default: false })
const { location = 'end' } = defineProps<{
location?: 'top' | 'end' | 'bottom' | 'start' | 'left' | 'right'
}>()
</script>

View file

@ -1,5 +1,5 @@
<template>
<v-btn
<v-icon-btn
:icon="themeIcon"
:aria-label="
$formatMessage({

View file

@ -8,7 +8,6 @@
rounded
closable
class="ms-2"
variant="elevated"
size="small"
@click:close="emit('clear')"
>

View file

@ -31,6 +31,7 @@
v-model="modelRange"
strict
hide-details
color="primary"
:disabled="disabled || isSingle"
:step="1"
:min="min"

View file

@ -0,0 +1,43 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ReadStatus from './ReadStatus.vue'
import { fn } from 'storybook/test'
const meta = {
component: ReadStatus,
render: (args: object) => ({
components: { ReadStatus },
setup() {
return { args }
},
template: '<ReadStatus v-model="args.modelValue"/>',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
docs: {
description: {
component: 'Read Status filter.',
},
},
},
args: {
'onUpdate:modelValue': fn(),
modelValue: [],
},
} satisfies Meta<typeof ReadStatus>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}
export const InitialValue: Story = {
args: {
modelValue: [
{ i: 'e', v: 'UNREAD' },
{ i: 'i', v: 'IN_PROGRESS' },
],
},
}

View file

@ -0,0 +1,30 @@
<template>
<FilterList
v-model="model"
:items="items"
color="primary"
/>
</template>
<script setup lang="ts">
import * as v from 'valibot'
import { SchemaReadStatus } from '@/types/filter'
import { useIntl } from 'vue-intl'
import { ReadStatus, readStatusMessages } from '@/types/ReadStatus'
type SchReadStatus = v.InferOutput<typeof SchemaReadStatus>
const intl = useIntl()
const model = defineModel<SchReadStatus[]>({ default: [] })
const items = Object.values(ReadStatus).map((it) => ({
title: intl.formatMessage(readStatusMessages[it]),
value: { i: 'i', v: it },
valueExclude: { i: 'e', v: it },
}))
</script>
<script lang="ts"></script>
<style scoped></style>

View file

@ -1,6 +1,7 @@
import {
SchemaAnyNone,
SchemaFilterAuthors,
SchemaFilterReadStatus,
type SchemaFilterSeriesStatus,
SchemaFilterStrings,
SchemaSeriesAgeRatings,
@ -61,6 +62,22 @@ export function schemaFilterAuthorsToConditions(
}
}
export function schemaFilterReadStatusToConditions(
filter: InferOutput<typeof SchemaFilterReadStatus>,
) {
const list = filter.v.map((it) => {
return {
readStatus: {
operator: it.i === 'e' ? 'isNot' : 'is',
value: it.v,
},
}
})
return {
anyOf: list,
}
}
export function schemaFilterStringToConditions(
filter: InferOutput<typeof SchemaFilterStrings>,
key: string,
@ -158,22 +175,27 @@ export function schemaFilterAgeRatingToConditions(
operator: 'isNull',
},
})
} else if (!!filter.is) {
conds.push({
ageRating: {
operator: 'is',
value: filter.is,
},
})
} else {
if (!!filter.is || !!filter.min) {
const v = Number(filter.is || filter.min)
if (!!filter.min) {
conds.push({
ageRating: {
operator: 'greaterThan',
value: v,
value: filter.min,
},
})
}
if (!!filter.is || !!filter.max) {
const v = Number(filter.is || filter.max)
if (!!filter.max) {
conds.push({
ageRating: {
operator: 'lessThan',
value: v,
value: filter.max,
},
})
}

View file

@ -26,141 +26,164 @@
:sizes="[1, 10, 20]"
/>
<v-icon-btn
icon="i-mdi:filter-variant"
@click="filterDrawer = true"
/>
<v-badge
location="top right"
color="primary"
:content="filterCount"
:model-value="filterCount > 0"
class="pe-4"
offset-x="7"
offset-y="7"
>
<v-icon-btn
icon="i-mdi:filter-variant"
@click="filterDrawer = true"
/>
</v-badge>
</v-app-bar>
<!-- TODO: Move into its own component -->
<!-- Teleport is needed so that the scrim covers the whole screen -->
<Teleport to="#app">
<!-- order=-1 is needed for the drawer to open full height -->
<!-- disable-route-watcher is needed, else the drawer closes when the route query params are updated when the filters change -->
<v-navigation-drawer
v-model="filterDrawer"
location="end"
temporary
order="-1"
disable-route-watcher
>
<v-list>
<v-expansion-panels
v-model="filterExpansionPanels"
variant="accordion"
class="no-padding"
flat
tile
<TempDrawer v-model="filterDrawer">
<v-list>
<v-list-subheader>
<div class="d-flex ga-2 align-center mb-1">
<span>FILTERS</span>
<v-chip
v-if="filterCount > 0"
color="primary"
rounded
closable
variant="elevated"
size="small"
@click:close="clearFilters()"
>
{{ filterCount }}
</v-chip>
</div>
</v-list-subheader>
<v-expansion-panels
v-model="filterExpansionPanels"
variant="accordion"
class="no-padding"
flat
tile
>
<FilterExpansionPanel
title="Read status"
:count="filterReadStatus.v.length"
@clear="clearFilter(filterReadStatus)"
>
<FilterExpansionPanel
title="Status"
:count="filterSeriesStatus.v.length"
@clear="clearFilter(filterSeriesStatus)"
>
<FilterBySeriesStatus v-model="filterSeriesStatus.v" />
</FilterExpansionPanel>
<FilterExpansionPanel
title="Genre"
:count="filterGenre.v.length"
@clear="clearFilter(filterGenre)"
>
<FilterByGenre
v-model="filterGenre.v"
v-model:mode="filterGenre.m"
/>
</FilterExpansionPanel>
<FilterExpansionPanel
title="Tag"
:count="filterTag.v.length"
@clear="clearFilter(filterTag)"
>
<FilterByTag
v-model="filterTag.v"
v-model:mode="filterTag.m"
/>
</FilterExpansionPanel>
<FilterExpansionPanel
title="Publisher"
:count="filterPublisher.v.length"
@clear="clearFilter(filterPublisher)"
>
<FilterByPublisher
v-model="filterPublisher.v"
v-model:mode="filterPublisher.m"
/>
</FilterExpansionPanel>
<FilterExpansionPanel
title="Release year"
:count="!!filterReleaseYear.is ? 1 : !!filterReleaseYear.min ? 1 : 0"
@clear="clearFilterSelectRange(filterReleaseYear)"
>
<FilterByReleaseYear v-model="filterReleaseYear" />
</FilterExpansionPanel>
<FilterExpansionPanel
title="Age rating"
:count="!!filterAgeRating.is ? 1 : !!filterAgeRating.min ? 1 : 0"
@clear="clearFilterSelectRange(filterAgeRating)"
>
<FilterByAgeRating v-model="filterAgeRating" />
</FilterExpansionPanel>
<FilterExpansionPanel
title="Language"
:count="filterLanguage.v.length"
@clear="clearFilter(filterLanguage)"
>
<FilterByLanguage
v-model="filterLanguage.v"
v-model:mode="filterLanguage.m"
/>
</FilterExpansionPanel>
<FilterExpansionPanel
title="Sharing label"
:count="filterSharingLabel.v.length"
@clear="clearFilter(filterSharingLabel)"
>
<FilterBySharingLabel
v-model="filterSharingLabel.v"
v-model:mode="filterSharingLabel.m"
/>
</FilterExpansionPanel>
</v-expansion-panels>
<v-divider />
<v-list-subheader>CREATORS</v-list-subheader>
<v-expansion-panels
v-model="filterExpansionPanels"
variant="accordion"
class="no-padding"
flat
tile
<FilterByReadStatus v-model="filterReadStatus.v" />
</FilterExpansionPanel>
<FilterExpansionPanel
title="Status"
:count="filterSeriesStatus.v.length"
@clear="clearFilter(filterSeriesStatus)"
>
<FilterExpansionPanel
v-for="(filterAuthor, role) in filterAuthors"
:key="role"
:title="filterAuthor.text"
:count="filterAuthor.filter.v.length"
@clear="clearFilter(filterAuthor.filter)"
>
<FilterByAuthor
v-model="filterAuthor.filter.v"
v-model:mode="filterAuthor.filter.m"
:role="filterAuthor.role"
/>
</FilterExpansionPanel>
</v-expansion-panels>
</v-list>
</v-navigation-drawer>
</Teleport>
<FilterBySeriesStatus v-model="filterSeriesStatus.v" />
</FilterExpansionPanel>
<FilterExpansionPanel
title="Genre"
:count="filterGenre.v.length"
@clear="clearFilter(filterGenre)"
>
<FilterByGenre
v-model="filterGenre.v"
v-model:mode="filterGenre.m"
/>
</FilterExpansionPanel>
<FilterExpansionPanel
title="Tag"
:count="filterTag.v.length"
@clear="clearFilter(filterTag)"
>
<FilterByTag
v-model="filterTag.v"
v-model:mode="filterTag.m"
/>
</FilterExpansionPanel>
<FilterExpansionPanel
title="Publisher"
:count="filterPublisher.v.length"
@clear="clearFilter(filterPublisher)"
>
<FilterByPublisher
v-model="filterPublisher.v"
v-model:mode="filterPublisher.m"
/>
</FilterExpansionPanel>
<FilterExpansionPanel
title="Release year"
:count="!!filterReleaseYear.is ? 1 : !!filterReleaseYear.min ? 1 : 0"
@clear="clearFilterSelectRange(filterReleaseYear)"
>
<FilterByReleaseYear v-model="filterReleaseYear" />
</FilterExpansionPanel>
<FilterExpansionPanel
title="Age rating"
:count="!!filterAgeRating.is ? 1 : !!filterAgeRating.min ? 1 : 0"
@clear="clearFilterSelectRange(filterAgeRating)"
>
<FilterByAgeRating v-model="filterAgeRating" />
</FilterExpansionPanel>
<FilterExpansionPanel
title="Language"
:count="filterLanguage.v.length"
@clear="clearFilter(filterLanguage)"
>
<FilterByLanguage
v-model="filterLanguage.v"
v-model:mode="filterLanguage.m"
/>
</FilterExpansionPanel>
<FilterExpansionPanel
title="Sharing label"
:count="filterSharingLabel.v.length"
@clear="clearFilter(filterSharingLabel)"
>
<FilterBySharingLabel
v-model="filterSharingLabel.v"
v-model:mode="filterSharingLabel.m"
/>
</FilterExpansionPanel>
</v-expansion-panels>
<v-divider><span class="text-body-medium text-medium-emphasis">Creators</span></v-divider>
<v-expansion-panels
v-model="filterExpansionPanels"
variant="accordion"
class="no-padding"
flat
tile
>
<FilterExpansionPanel
v-for="(filterAuthor, role) in filterAuthors"
:key="role"
:title="filterAuthor.text"
:count="filterAuthor.filter.v.length"
@clear="clearFilter(filterAuthor.filter)"
>
<FilterByAuthor
v-model="filterAuthor.filter.v"
v-model:mode="filterAuthor.filter.m"
:role="filterAuthor.role"
/>
</FilterExpansionPanel>
</v-expansion-panels>
<v-divider />
<v-list-subheader>SORT</v-list-subheader>
</v-list>
</TempDrawer>
<div>CONDITION</div>
<div>{{ conds }}</div>
@ -244,12 +267,14 @@ import {
schemaFilterSeriesStatusToConditions,
schemaFilterReleaseYearToConditions,
schemaFilterAgeRatingToConditions,
schemaFilterReadStatusToConditions,
} from '@/functions/filter'
import * as v from 'valibot'
import {
type FilterType,
type FilterTypeSelectRange,
SchemaFilterAuthors,
SchemaFilterReadStatus,
SchemaFilterSeriesStatus,
SchemaFilterStrings,
SchemaSeriesAgeRatings,
@ -313,7 +338,37 @@ function clearFilterSelectRange(filter: FilterTypeSelectRange) {
filter.max = undefined
}
function clearFilters() {
clearFilter(filterSeriesStatus.value)
clearFilter(filterReadStatus.value)
clearFilter(filterGenre.value)
clearFilter(filterTag.value)
clearFilter(filterPublisher.value)
clearFilter(filterSharingLabel.value)
clearFilter(filterLanguage.value)
clearFilterSelectRange(filterReleaseYear.value)
clearFilterSelectRange(filterAgeRating.value)
Object.entries(filterAuthors).map(([, filter]) => clearFilter(filter.filter))
}
const filterCount = computed(
() =>
filterReadStatus.value.v.length +
filterSeriesStatus.value.v.length +
filterGenre.value.v.length +
filterTag.value.v.length +
filterPublisher.value.v.length +
(!!filterReleaseYear.value.is ? 1 : !!filterReleaseYear.value.min ? 1 : 0) +
(!!filterAgeRating.value.is ? 1 : !!filterAgeRating.value.min ? 1 : 0) +
filterLanguage.value.v.length +
filterSharingLabel.value.v.length +
Object.entries(filterAuthors)
.map(([, filter]) => filter.filter.v.length)
.reduce((sum, item) => sum + item, 0),
)
const { data: filterSeriesStatus } = useRouteQuerySchema('status', SchemaFilterSeriesStatus)
const { data: filterReadStatus } = useRouteQuerySchema('read', SchemaFilterReadStatus)
const { data: filterGenre } = useRouteQuerySchema('genre', SchemaFilterStrings)
const { data: filterTag } = useRouteQuerySchema('tag', SchemaFilterStrings)
const { data: filterPublisher } = useRouteQuerySchema('publisher', SchemaFilterStrings)
@ -326,6 +381,7 @@ const conds = computed(() => ({
allOf: [
librariesCondition.value as components['schemas']['AnyOfSeries'],
schemaFilterSeriesStatusToConditions(filterSeriesStatus.value),
schemaFilterReadStatusToConditions(filterReadStatus.value),
schemaFilterStringToConditions(filterGenre.value, 'genre', true),
schemaFilterStringToConditions(filterTag.value, 'tag', true),
schemaFilterStringToConditions(filterPublisher.value, 'publisher', false),

View file

@ -0,0 +1,25 @@
import { defineMessages } from 'vue-intl'
export enum ReadStatus {
UNREAD = 'UNREAD',
IN_PROGRESS = 'IN_PROGRESS',
READ = 'READ',
}
export const readStatusMessages = defineMessages({
[ReadStatus.UNREAD]: {
description: 'Read status: UNREAD',
defaultMessage: 'Unread',
id: 'XUgQvn',
},
[ReadStatus.IN_PROGRESS]: {
description: 'Read status: IN_PROGRESS',
defaultMessage: 'In progress',
id: '8DRgrr',
},
[ReadStatus.READ]: {
description: 'Read status: READ',
defaultMessage: 'Read',
id: 'HhmZaG',
},
})

View file

@ -3,6 +3,7 @@ import * as v from 'valibot'
import {
SchemaAnyAll,
SchemaFilterAuthors,
SchemaFilterReadStatus,
SchemaFilterSeriesStatus,
SchemaFilterStrings,
SchemaSeriesAgeRatings,
@ -155,6 +156,13 @@ describe('filter schemas have a default value', () => {
expect(defaults).toStrictEqual(expected)
})
test('SchemaFilterReadStatus', () => {
const expected = { v: [] }
const defaults = v.getDefaults(SchemaFilterReadStatus)
expect(defaults).toStrictEqual(expected)
})
test('SchemaFilterStrings', () => {
const expected = { m: 'anyOf', v: [] }
const defaults = v.getDefaults(SchemaFilterStrings)

View file

@ -56,13 +56,11 @@ export const SchemaSeriesStatus = v.pipe(
v.picklist(['ENDED', 'ONGOING', 'ABANDONED', 'HIATUS']),
)
export const SchemaString = v.strictObject({
...SchemaIncludeExclude.entries,
/**
* Shorthand for `value`.
*/
v: v.string(),
})
export const SchemaReadStatus = createSchemaIncludeExclude(
v.pipe(v.string(), v.toUpperCase(), v.picklist(['UNREAD', 'IN_PROGRESS', 'READ'])),
)
export const SchemaString = createSchemaIncludeExclude(v.string())
export const SchemaAnyNone = v.strictObject({
a: v.optional(v.picklist(['any', 'none'])),
@ -76,6 +74,16 @@ const SchemaYear = v.pipe(v.string(), v.regex(/^\d{4}$/, 'Must be exactly 4 digi
// All schema filters need to have a default value
////////////////////////////////////////////////////
function createSchemaIncludeExclude<T extends v.GenericSchema>(schema: T) {
return v.strictObject({
...SchemaIncludeExclude.entries,
/**
* Shorthand for `value`.
*/
v: schema,
})
}
function createSchemaFilterAnyAll<T extends v.GenericSchema>(schema: T) {
return v.strictObject({
...SchemaAnyAll.entries,
@ -104,10 +112,15 @@ function createSchemaFilterSelectRange<T extends v.GenericSchema>(schema: T) {
}
/**
* Schema for Series Status.
* Schema for Series Status
*/
export const SchemaFilterSeriesStatus = createSchemaFilterArray(SchemaSeriesStatus)
/**
* Schema for Read Status
*/
export const SchemaFilterReadStatus = createSchemaFilterArray(SchemaReadStatus)
/**
* Schema for Series Release Years
*/