Skip to content
This repository was archived by the owner on Oct 7, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "kmweb",
"private": true,
"version": "0.11.7",
"version": "0.11.8",
"type": "module",
"packageManager": "pnpm@12.5.1",
"scripts": {
Expand Down
61 changes: 61 additions & 0 deletions src/components/browse/SmartListsGrid.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useCallback } from 'react'
import { useInfiniteQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Funnel, WarningCircle } from '@phosphor-icons/react'
import { smartListsApi } from '@/lib/api/smartLists'
import { Button } from '@/components/ui/Button'
import { EmptyState } from '@/components/ui/EmptyState'
import { CardSkeleton, GridSkeleton } from '@/components/ui/Skeleton'
import { MediaGrid } from '@/components/media/MediaGrid'
import { SmartListCard } from '@/components/media/SmartListCard'
import { Sentinel } from '@/components/filters/Sentinel'

export function SmartListsGrid({ owner, onCreate }: { owner?: string; onCreate: () => void }) {
const { t } = useTranslation('smartlists')

const q = useInfiniteQuery({
queryKey: ['smart-lists', 'list', owner ?? 'visible'],
queryFn: ({ pageParam }) => smartListsApi.list({ page: pageParam, size: 50, owner }),
getNextPageParam: (last) => (last.last ? undefined : last.number + 1),
initialPageParam: 0,
})

const items = q.data?.pages.flatMap((p) => p.content) ?? []
const { hasNextPage, isFetchingNextPage, fetchNextPage } = q
const loadMore = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) void fetchNextPage()
}, [hasNextPage, isFetchingNextPage, fetchNextPage])

return (
<>
{q.isPending ? (
<GridSkeleton count={18} />
) : q.isLoadingError ? (
<EmptyState
icon={<WarningCircle />}
title={t('loadError')}
body={q.error?.message}
action={<Button onClick={() => q.refetch()}>{t('common:action.retry')}</Button>}
/>
) : items.length === 0 ? (
<EmptyState icon={<Funnel />} title={t('empty')} action={<Button onClick={onCreate}>{t('create')}</Button>} />
) : (
<>
<MediaGrid>
{items.map((l) => (
<SmartListCard key={l.id} id={l.id} name={l.name} target={l.target} visibility={l.visibility} />
))}
</MediaGrid>
{isFetchingNextPage && (
<MediaGrid className="mt-7">
{Array.from({ length: 4 }, (_, i) => (
<CardSkeleton key={i} />
))}
</MediaGrid>
)}
<Sentinel active={!!hasNextPage} onIntersect={loadMore} />
</>
)}
</>
)
}
7 changes: 5 additions & 2 deletions src/components/filters/FilterDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ interface FilterDrawerProps {
groups: FilterGroupDef[]
state: FilterState
scope?: ReferentialScope
/** resolves entity ids (libraries/read lists/collections) to display names in group summaries */
labelFor?: (key: GroupKey, id: string) => string
activeCount: number
onToggleValue: (key: GroupKey, value: string) => void
onToggleAuthor: (author: AuthorFilter) => void
Expand All @@ -32,6 +34,7 @@ export function FilterDrawer({
groups,
state,
scope,
labelFor,
activeCount,
onToggleValue,
onToggleAuthor,
Expand Down Expand Up @@ -182,7 +185,7 @@ export function FilterDrawer({
)}
{letterGroups.map((def) => (
<section key={def.key} className="border-b border-line py-4">
<FilterGroupRow def={def} state={state} onOpen={() => setOpenKey(def.key)} />
<FilterGroupRow def={def} state={state} labelFor={labelFor} onOpen={() => setOpenKey(def.key)} />
</section>
))}
{navGroups.length > 0 && (
Expand All @@ -191,7 +194,7 @@ export function FilterDrawer({
{t('section.metadata')}
</h3>
{navGroups.map((def) => (
<FilterGroupRow key={def.key} def={def} state={state} onOpen={() => setOpenKey(def.key)} />
<FilterGroupRow key={def.key} def={def} state={state} labelFor={labelFor} onOpen={() => setOpenKey(def.key)} />
))}
</section>
)}
Expand Down
89 changes: 85 additions & 4 deletions src/components/filters/FilterGroups.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { useMemo, useState, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { CaretRight, CheckCircle, Circle, X, XCircle } from '@phosphor-icons/react'
import { collectionsApi, readlistsApi } from '@/lib/api/collections'
import { librariesApi } from '@/lib/api/libraries'
import { referentialApi, type ReferentialScope } from '@/lib/api/referential'
import { Skeleton } from '@/components/ui/Skeleton'
import { SegmentedControl } from '@/components/ui/SegmentedControl'
Expand Down Expand Up @@ -163,6 +165,72 @@ function ReferentialOptions({
)
}

interface EntityOption {
id: string
name: string
}

function fetchEntities(kind: NonNullable<FilterGroupDef['entities']>): Promise<EntityOption[]> {
if (kind === 'libraries') return librariesApi.list()
if (kind === 'collections') return collectionsApi.list({ unpaged: true }).then((p) => p.content)
return readlistsApi.list({ unpaged: true }).then((p) => p.content)
}

/** id→name options for entity groups (libraries, read lists, collections); the search DSL
stores ids, so names are resolved here for display and by the caller for chip labels */
export function EntityOptions({
kind,
selected,
enabled,
onToggle,
}: {
kind: NonNullable<FilterGroupDef['entities']>
selected: string[]
enabled: boolean
onToggle: (id: string) => void
}) {
const [filter, setFilter] = useState('')
const { t, i18n } = useTranslation('filters')
const query = useQuery({
queryKey: ['entity-options', kind],
queryFn: () => fetchEntities(kind),
enabled,
staleTime: 60_000,
})
const names = useMemo(() => new Map((query.data ?? []).map((e) => [e.id, e.name])), [query.data])
const options = useMemo(
() => [...names.keys()].sort((a, b) => (names.get(a) ?? a).localeCompare(names.get(b) ?? b, i18n.language)),
[names, i18n.language],
)
const visible = useMemo(() => {
const f = filter.trim().toLowerCase()
return options.filter((o) => selected.includes(o) || !f || (names.get(o) ?? o).toLowerCase().includes(f))
}, [options, filter, selected, names])

if (query.isPending) return <LoadingChips />
if (query.isLoadingError) return <LoadError onRetry={() => query.refetch()} />

return (
<div>
{options.length > GROUP_SEARCH_THRESHOLD && (
<GroupSearchInput value={filter} onChange={setFilter} placeholder={t('options.filterPlaceholder')} />
)}
{visible.length === 0 ? (
<p className="text-xs text-ink-3">{t('options.noMatch')}</p>
) : (
<div className="flex flex-wrap gap-1.5">
{visible.map((id) => (
<OptionChip key={id} active={selected.includes(id)} onClick={() => onToggle(id)}>
{names.get(id) ?? id}
{selected.includes(id) && <X className="size-3" />}
</OptionChip>
))}
</div>
)}
</div>
)
}

function LetterOptions({ selected, onSelect }: { selected: string[]; onSelect: (letter: string) => void }) {
const active = selected[0]
return (
Expand Down Expand Up @@ -338,16 +406,26 @@ export function FlagFilterRow({ def, value, onCycle }: { def: FilterGroupDef; va
)
}

function groupSummary(def: FilterGroupDef, state: FilterState, locale: string): string {
function groupSummary(def: FilterGroupDef, state: FilterState, locale: string, labelFor?: (key: GroupKey, id: string) => string): string {
const list = new Intl.ListFormat(locale, { style: 'narrow', type: 'conjunction' })
if (def.kind === 'authors') return list.format(state.authors.map((a) => (a.role ? `${a.name} (${a.role})` : a.name)))
return list.format((state[def.key] as string[]).map((v) => displayFilterValue(def.key, v)))
return list.format((state[def.key] as string[]).map((v) => labelFor?.(def.key, v) ?? displayFilterValue(def.key, v)))
}

/** collapsed row for high-cardinality groups; opens the group's detail view */
export function FilterGroupRow({ def, state, onOpen }: { def: FilterGroupDef; state: FilterState; onOpen: () => void }) {
export function FilterGroupRow({
def,
state,
labelFor,
onOpen,
}: {
def: FilterGroupDef
state: FilterState
labelFor?: (key: GroupKey, id: string) => string
onOpen: () => void
}) {
const { t, i18n } = useTranslation('filters')
const summary = groupSummary(def, state, i18n.language)
const summary = groupSummary(def, state, i18n.language, labelFor)
return (
<button
type="button"
Expand Down Expand Up @@ -400,6 +478,9 @@ export function FilterGroupDetail({
onToggle={(v) => onToggleValue(def.key, v)}
/>
)}
{def.kind === 'entities' && def.entities && (
<EntityOptions kind={def.entities} selected={values} enabled={enabled} onToggle={(v) => onToggleValue(def.key, v)} />
)}
{def.kind === 'letters' && <LetterOptions selected={values} onSelect={(l) => onSetExclusive(def.key, l)} />}
{def.kind === 'authors' && (
<AuthorsOptions scope={scope} selected={state.authors} enabled={enabled} onToggle={onToggleAuthor} />
Expand Down
16 changes: 16 additions & 0 deletions src/components/filters/builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@ export function buildSeriesSearch(state: FilterState, libraryId?: string, extra?
push(conditions, combine(state.languages.map((v) => leaf('language', 'is', v)), mode('languages')))
push(conditions, combine(yearConditions(state.releaseYears), mode('releaseYears')))
push(conditions, combine(authorConditions(state.authors), mode('authors')))
push(conditions, libraryScopeCondition(state.libraries))
push(
conditions,
combine(
state.collections.map((v) => leaf('collectionId', state.exclude.includes('collections') ? 'isNot' : 'is', v)),
mode('collections'),
),
)
return finalize(state, conditions)
}

Expand All @@ -114,5 +122,13 @@ export function buildBookSearch(state: FilterState, libraryId?: string, extra?:
)
push(conditions, combine(yearConditions(state.releaseYears), mode('releaseYears')))
push(conditions, combine(authorConditions(state.authors), mode('authors')))
push(conditions, libraryScopeCondition(state.libraries))
push(
conditions,
combine(
state.readlists.map((v) => leaf('readListId', state.exclude.includes('readlists') ? 'isNot' : 'is', v)),
mode('readlists'),
),
)
return finalize(state, conditions)
}
11 changes: 10 additions & 1 deletion src/components/filters/filterUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ const MULTI_KEYS: GroupKey[] = [
'mediaProfiles',
'mediaStatuses',
'poster',
'libraries',
'readlists',
'collections',
]

// names may contain commas, so the role is split off at the last comma
Expand Down Expand Up @@ -54,6 +57,9 @@ export function parseFilterState(params: URLSearchParams): FilterState {
mediaProfiles: params.getAll('mediaProfiles'),
mediaStatuses: params.getAll('mediaStatuses'),
poster: params.getAll('poster'),
libraries: params.getAll('libraries'),
readlists: params.getAll('readlists'),
collections: params.getAll('collections'),
matchAll: params.getAll('ma').filter((v): v is GroupKey => (MULTI_KEYS as string[]).includes(v)),
exclude: params.getAll('not').filter((v): v is GroupKey => (MULTI_KEYS as string[]).includes(v)),
}
Expand Down Expand Up @@ -86,7 +92,10 @@ export function activeFilterCount(s: FilterState): number {
s.authors.length +
s.mediaProfiles.length +
s.mediaStatuses.length +
s.poster.length
s.poster.length +
s.libraries.length +
s.readlists.length +
s.collections.length
)
}

Expand Down
24 changes: 23 additions & 1 deletion src/components/filters/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ export type GroupKey =
| 'mediaProfiles'
| 'mediaStatuses'
| 'poster'
| 'libraries'
| 'readlists'
| 'collections'

/** How multiple values inside one group combine in the search DSL. */
export type GroupMode = 'any' | 'all'
Expand Down Expand Up @@ -44,6 +47,10 @@ export interface FilterState {
mediaProfiles: string[]
mediaStatuses: string[]
poster: string[]
/** entity ids (libraries / read lists / collections); only smart-list editing offers these */
libraries: string[]
readlists: string[]
collections: string[]
/** groups combined with allOf instead of the default anyOf */
matchAll: GroupKey[]
/** groups whose values are matched with isNot instead of is */
Expand All @@ -64,9 +71,11 @@ export interface FilterGroupDef {
key: GroupKey
/** translation key, resolved with t() at render sites */
labelKey: string
kind: 'enum' | 'referential' | 'authors' | 'letters'
kind: 'enum' | 'referential' | 'authors' | 'letters' | 'entities'
options?: { value: string; labelKey: string }[]
referential?: ReferentialKind
/** which entity list feeds an 'entities' group */
entities?: 'libraries' | 'collections' | 'readlists'
/** hidden from non-admin users */
adminOnly?: boolean
/** offers an is / is-not switch once values are selected */
Expand Down Expand Up @@ -199,6 +208,19 @@ export const BOOK_SORT_OPTIONS: SortOption[] = [
series-level metadata, meaningless for one series and unavailable for a read list */
export const BOOK_DETAIL_FILTER_GROUPS: FilterGroupDef[] = BOOK_FILTER_GROUPS.filter((g) => g.key !== 'releaseYears')

// smart-list editing scopes by whole entities, so libraries and membership get their own groups
export const SMARTLIST_BOOK_FILTER_GROUPS: FilterGroupDef[] = [
{ key: 'libraries', labelKey: 'filters:group.libraries', kind: 'entities', entities: 'libraries' },
{ key: 'readlists', labelKey: 'filters:group.readlists', kind: 'entities', entities: 'readlists', negatable: true },
...BOOK_FILTER_GROUPS,
]

export const SMARTLIST_SERIES_FILTER_GROUPS: FilterGroupDef[] = [
{ key: 'libraries', labelKey: 'filters:group.libraries', kind: 'entities', entities: 'libraries' },
{ key: 'collections', labelKey: 'filters:group.collections', kind: 'entities', entities: 'collections', negatable: true },
...SERIES_FILTER_GROUPS,
]

/** sorting by series is meaningless inside a single series' book list */
export const SERIES_BOOK_SORT_OPTIONS = BOOK_SORT_OPTIONS.filter((o) => o.property !== 'series')

Expand Down
4 changes: 4 additions & 0 deletions src/components/layout/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
Copy,
Database,
FileMagnifyingGlass,
Funnel,
Gauge,
GearSix,
HardDrives,
Expand Down Expand Up @@ -234,6 +235,9 @@ function SidebarContent({ onNavigate }: { onNavigate?: () => void }) {
<NavItem to="/readlists" icon={<BookmarkSimple />} onClick={onNavigate}>
{t('nav.readLists')}
</NavItem>
<NavItem to="/smart-lists" icon={<Funnel />} onClick={onNavigate}>
{t('nav.smartLists')}
</NavItem>

{isAdmin(user) && (
<>
Expand Down
Loading
Loading