diff options
author | Wolfgang Müller | 2025-02-20 15:24:42 +0100 |
---|---|---|
committer | Wolfgang Müller | 2025-02-20 19:51:39 +0100 |
commit | 8e9aa5f6286a15c818a47344fc80964f5288bb52 (patch) | |
tree | b3ef187daff345eeb918ab07900cc1c4185c53e4 /frontend/src | |
parent | c6bf35aea63969b90463d6e70cb02ed61e4e3270 (diff) | |
download | hircine-8e9aa5f6286a15c818a47344fc80964f5288bb52.tar.gz |
frontend: Allow filtering for orphaned associations
With the association count filters in place we may now also allow the
user to filter for associations that do not have a matching counterparts
(artists without a comic, for example).
Diffstat (limited to 'frontend/src')
-rw-r--r-- | frontend/src/lib/Filter.svelte.ts | 73 | ||||
-rw-r--r-- | frontend/src/lib/icons/Orphan.svelte | 15 | ||||
-rw-r--r-- | frontend/src/lib/toolbar/FilterOrphaned.svelte | 24 | ||||
-rw-r--r-- | frontend/src/routes/artists/+page.svelte | 2 | ||||
-rw-r--r-- | frontend/src/routes/characters/+page.svelte | 2 | ||||
-rw-r--r-- | frontend/src/routes/circles/+page.svelte | 2 | ||||
-rw-r--r-- | frontend/src/routes/namespaces/+page.svelte | 8 | ||||
-rw-r--r-- | frontend/src/routes/tags/+page.svelte | 2 | ||||
-rw-r--r-- | frontend/src/routes/worlds/+page.svelte | 2 |
9 files changed, 122 insertions, 8 deletions
diff --git a/frontend/src/lib/Filter.svelte.ts b/frontend/src/lib/Filter.svelte.ts index e73f497..390b98a 100644 --- a/frontend/src/lib/Filter.svelte.ts +++ b/frontend/src/lib/Filter.svelte.ts @@ -4,6 +4,8 @@ import { type ArchiveFilterInput, type ComicFilter, type ComicFilterInput, + type NamespaceFilter, + type NamespaceFilterInput, type StringFilter, type TagFilter, type TagFilterInput @@ -16,10 +18,18 @@ interface FilterInput<T> { exclude?: T | null; } -interface BasicFilter { +interface NameFilter { name?: { contains?: string | null } | null; } +interface AssociationCount { + count: { value: number; operator?: Operator | null }; +} + +interface BasicFilter extends NameFilter { + comics?: AssociationCount | null; +} + export type FilterType = 'include' | 'exclude'; type FilterMode = 'any' | 'all' | 'exact'; @@ -146,6 +156,27 @@ class Bool<K extends Key> { } } +class Orphan<K extends Key> { + key: K; + value?: boolean = false; + + constructor(key: K, filter?: Filter<AssociationCount, K> | null) { + this.key = key; + + if (filter) { + this.value = + filter[key]?.count?.value === 0 && + (filter[key].count.operator === undefined || filter[key].count.operator === Operator.Equal); + } + } + + integrate(filter: Filter<AssociationCount, K>) { + if (this.value) { + filter[this.key] = { count: { value: 0, operator: Operator.Equal } }; + } + } +} + class Str<K extends Key> { key: K; contains = $state(''); @@ -177,7 +208,7 @@ export class ArchiveFilterControls extends Controls<ArchiveFilter> { path: Str<'path'>; organized: Bool<'organized'>; - constructor(filter: ArchiveFilter | null | undefined) { + constructor(filter?: ArchiveFilter | null) { super(); this.path = new Str('path', filter); @@ -223,16 +254,26 @@ export class ComicFilterControls extends Controls<ComicFilter> { } } -export class BasicFilterControls extends Controls<BasicFilter> { +export class NameFilterControls extends Controls<NameFilter> { name: Str<'name'>; - constructor(filter?: BasicFilter | null) { + constructor(filter?: NameFilter | null) { super(); this.name = new Str('name', filter); } } +export class BasicFilterControls extends NameFilterControls { + orphan: Orphan<'comics'>; + + constructor(filter?: BasicFilter | null) { + super(filter); + + this.orphan = new Orphan('comics', filter); + } +} + export class TagFilterControls extends BasicFilterControls { namespaces: Association<'namespaces'>; @@ -243,6 +284,16 @@ export class TagFilterControls extends BasicFilterControls { } } +export class NamespaceFilterControls extends NameFilterControls { + orphan: Orphan<'tags'>; + + constructor(filter?: NamespaceFilter | null) { + super(filter); + + this.orphan = new Orphan('tags', filter); + } +} + function buildFilterInput<F>(include?: F, exclude?: F) { const input: FilterInput<F> = {}; @@ -318,7 +369,7 @@ export class BasicFilterContext extends FilterContext<BasicFilter> { export class TagFilterContext extends FilterContext<TagFilter> { include: TagFilterControls; exclude: TagFilterControls; - private static ignore = ['name']; + private static ignore = ['name', 'comics']; constructor(filter: TagFilterInput) { super(); @@ -330,6 +381,18 @@ export class TagFilterContext extends FilterContext<TagFilter> { } } +export class NamespaceFilterContext extends FilterContext<NamespaceFilter> { + include: NamespaceFilterControls; + exclude: NamespaceFilterControls; + + constructor(filter: NamespaceFilterInput) { + super(); + + this.include = new NamespaceFilterControls(filter.include); + this.exclude = new NamespaceFilterControls(); + } +} + export function cycleBooleanFilter(value: boolean | undefined, tristate = true) { if (tristate) { if (value === undefined) { diff --git a/frontend/src/lib/icons/Orphan.svelte b/frontend/src/lib/icons/Orphan.svelte new file mode 100644 index 0000000..7d947d2 --- /dev/null +++ b/frontend/src/lib/icons/Orphan.svelte @@ -0,0 +1,15 @@ +<script lang="ts"> + interface Props { + orphaned?: boolean; + hoverable?: boolean; + } + + let { orphaned, hoverable = false }: Props = $props(); +</script> + +{#if orphaned} + <span class:hoverable class="icon-gray icon-base icon-[material-symbols--fmd-bad]"></span> +{:else} + <span class:hoverable class="icon-gray icon-base dim icon-[material-symbols--fmd-bad-outline]" + ></span> +{/if} diff --git a/frontend/src/lib/toolbar/FilterOrphaned.svelte b/frontend/src/lib/toolbar/FilterOrphaned.svelte new file mode 100644 index 0000000..7e79be1 --- /dev/null +++ b/frontend/src/lib/toolbar/FilterOrphaned.svelte @@ -0,0 +1,24 @@ +<script lang="ts"> + import { page } from '$app/state'; + import { BasicFilterContext, NamespaceFilterContext } from '$lib/Filter.svelte'; + import { accelerator } from '$lib/Shortcuts'; + import Orphan from '$lib/icons/Orphan.svelte'; + + let { filter }: { filter: BasicFilterContext | NamespaceFilterContext } = $props(); + let orphaned = $derived(filter.include.orphan.value); + + const toggle = () => { + filter.include.orphan.value = !orphaned; + filter.apply(page.url.searchParams); + }; +</script> + +<button + class:toggled={orphaned} + class="btn-slate" + title="Filter orphaned" + onclick={toggle} + use:accelerator={'r'} +> + <Orphan {orphaned} /> +</button> diff --git a/frontend/src/routes/artists/+page.svelte b/frontend/src/routes/artists/+page.svelte index 9f0d893..9fff9d2 100644 --- a/frontend/src/routes/artists/+page.svelte +++ b/frontend/src/routes/artists/+page.svelte @@ -20,6 +20,7 @@ import { initSelectionContext } from '$lib/selection/Selection.svelte'; import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte'; import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte'; + import FilterOrphaned from '$lib/toolbar/FilterOrphaned.svelte'; import Search from '$lib/toolbar/Search.svelte'; import SelectItems from '$lib/toolbar/SelectItems.svelte'; import SelectSort from '$lib/toolbar/SelectSort.svelte'; @@ -69,6 +70,7 @@ {/snippet} {#snippet center()} <Search name="Artists" {filter} bind:field={filter.include.name.contains} /> + <FilterOrphaned {filter} /> <SelectSort {sort} labels={ArtistSortLabel} /> <SelectItems {pagination} /> {/snippet} diff --git a/frontend/src/routes/characters/+page.svelte b/frontend/src/routes/characters/+page.svelte index 3a4b737..970a8fa 100644 --- a/frontend/src/routes/characters/+page.svelte +++ b/frontend/src/routes/characters/+page.svelte @@ -20,6 +20,7 @@ import { initSelectionContext } from '$lib/selection/Selection.svelte'; import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte'; import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte'; + import FilterOrphaned from '$lib/toolbar/FilterOrphaned.svelte'; import Search from '$lib/toolbar/Search.svelte'; import SelectItems from '$lib/toolbar/SelectItems.svelte'; import SelectSort from '$lib/toolbar/SelectSort.svelte'; @@ -69,6 +70,7 @@ {/snippet} {#snippet center()} <Search name="Characters" {filter} bind:field={filter.include.name.contains} /> + <FilterOrphaned {filter} /> <SelectSort {sort} labels={CharacterSortLabel} /> <SelectItems {pagination} /> {/snippet} diff --git a/frontend/src/routes/circles/+page.svelte b/frontend/src/routes/circles/+page.svelte index 8bac7ed..bad1e1d 100644 --- a/frontend/src/routes/circles/+page.svelte +++ b/frontend/src/routes/circles/+page.svelte @@ -20,6 +20,7 @@ import { initSelectionContext } from '$lib/selection/Selection.svelte'; import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte'; import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte'; + import FilterOrphaned from '$lib/toolbar/FilterOrphaned.svelte'; import Search from '$lib/toolbar/Search.svelte'; import SelectItems from '$lib/toolbar/SelectItems.svelte'; import SelectSort from '$lib/toolbar/SelectSort.svelte'; @@ -69,6 +70,7 @@ {/snippet} {#snippet center()} <Search name="Circles" {filter} bind:field={filter.include.name.contains} /> + <FilterOrphaned {filter} /> <SelectSort {sort} labels={CircleSortLabel} /> <SelectItems {pagination} /> {/snippet} diff --git a/frontend/src/routes/namespaces/+page.svelte b/frontend/src/routes/namespaces/+page.svelte index d8e728d..429432f 100644 --- a/frontend/src/routes/namespaces/+page.svelte +++ b/frontend/src/routes/namespaces/+page.svelte @@ -3,7 +3,7 @@ import { fetchNamespace, namespacesQuery } from '$gql/Queries'; import type { Namespace } from '$gql/graphql'; import { NamespaceSortLabel } from '$lib/Enums'; - import { BasicFilterContext } from '$lib/Filter.svelte'; + import { NamespaceFilterContext } from '$lib/Filter.svelte'; import { quickComicFilter } from '$lib/Navigation'; import { toastFinally } from '$lib/Toasts'; import AddButton from '$lib/components/AddButton.svelte'; @@ -20,6 +20,7 @@ import { initSelectionContext } from '$lib/selection/Selection.svelte'; import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte'; import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte'; + import FilterOrphaned from '$lib/toolbar/FilterOrphaned.svelte'; import Search from '$lib/toolbar/Search.svelte'; import SelectItems from '$lib/toolbar/SelectItems.svelte'; import SelectSort from '$lib/toolbar/SelectSort.svelte'; @@ -44,9 +45,9 @@ } }); - let filter = $state(new BasicFilterContext(data.filter)); + let filter = $state(new NamespaceFilterContext(data.filter)); $effect(() => { - filter = new BasicFilterContext(data.filter); + filter = new NamespaceFilterContext(data.filter); }); const edit = (id: number) => { @@ -69,6 +70,7 @@ {/snippet} {#snippet center()} <Search name="Namespaces" {filter} bind:field={filter.include.name.contains} /> + <FilterOrphaned {filter} /> <SelectSort {sort} labels={NamespaceSortLabel} /> <SelectItems {pagination} /> {/snippet} diff --git a/frontend/src/routes/tags/+page.svelte b/frontend/src/routes/tags/+page.svelte index f71267f..2cb6f87 100644 --- a/frontend/src/routes/tags/+page.svelte +++ b/frontend/src/routes/tags/+page.svelte @@ -23,6 +23,7 @@ import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte'; import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte'; import EditSelection from '$lib/toolbar/EditSelection.svelte'; + import FilterOrphaned from '$lib/toolbar/FilterOrphaned.svelte'; import Search from '$lib/toolbar/Search.svelte'; import SelectItems from '$lib/toolbar/SelectItems.svelte'; import SelectSort from '$lib/toolbar/SelectSort.svelte'; @@ -76,6 +77,7 @@ {#snippet center({ expanded, toggle })} <Search name="Tags" {filter} bind:field={filter.include.name.contains} /> <ToggleAdvancedFilters {expanded} {toggle} {filterSize} /> + <FilterOrphaned {filter} /> <SelectSort {sort} labels={TagSortLabel} /> <SelectItems {pagination} /> {/snippet} diff --git a/frontend/src/routes/worlds/+page.svelte b/frontend/src/routes/worlds/+page.svelte index 6b95142..133dc27 100644 --- a/frontend/src/routes/worlds/+page.svelte +++ b/frontend/src/routes/worlds/+page.svelte @@ -20,6 +20,7 @@ import { initSelectionContext } from '$lib/selection/Selection.svelte'; import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte'; import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte'; + import FilterOrphaned from '$lib/toolbar/FilterOrphaned.svelte'; import Search from '$lib/toolbar/Search.svelte'; import SelectItems from '$lib/toolbar/SelectItems.svelte'; import SelectSort from '$lib/toolbar/SelectSort.svelte'; @@ -69,6 +70,7 @@ {/snippet} {#snippet center()} <Search name="Worlds" {filter} bind:field={filter.include.name.contains} /> + <FilterOrphaned {filter} /> <SelectSort {sort} labels={WorldSortLabel} /> <SelectItems {pagination} /> {/snippet} |