|
|
@@ -0,0 +1,131 @@
|
|
|
+<script generics="T" lang="ts">
|
|
|
+ import { Plus, Search } from '@lucide/svelte';
|
|
|
+ import type { Snippet } from 'svelte';
|
|
|
+
|
|
|
+ import { Empty } from '$lib/components/common/empty';
|
|
|
+ import * as Filters from '$lib/components/common/filters';
|
|
|
+ import { List } from '$lib/components/common/list';
|
|
|
+ import { Button } from '$lib/components/controls/button';
|
|
|
+ import * as InputGroup from '$lib/components/controls/input-group';
|
|
|
+
|
|
|
+ let {
|
|
|
+ query = $bindable(),
|
|
|
+ filters = $bindable(),
|
|
|
+ items = [],
|
|
|
+ itemKey,
|
|
|
+ itemRenderer,
|
|
|
+ itemName = 'item',
|
|
|
+ itemNamePlural = 'items',
|
|
|
+ itemCount = items.length,
|
|
|
+ itemIcon = Search,
|
|
|
+ creationLink = '',
|
|
|
+ }: {
|
|
|
+ query: string;
|
|
|
+ filters: Filters.FilterRow[];
|
|
|
+ items: T[];
|
|
|
+ itemKey: (item: T) => string;
|
|
|
+ itemRenderer: Snippet<[T, number]>;
|
|
|
+ itemName?: string;
|
|
|
+ itemNamePlural?: string;
|
|
|
+ itemCount?: number;
|
|
|
+ itemIcon?: typeof Search;
|
|
|
+ creationLink?: string;
|
|
|
+ } = $props();
|
|
|
+ let filtersShown = $state(false);
|
|
|
+ let activeFilterCount = $derived.by(() => {
|
|
|
+ let count = query ? 1 : 0;
|
|
|
+ for (const row of filters) {
|
|
|
+ if (row.countSelected) {
|
|
|
+ count += row.countSelected();
|
|
|
+ } else if (row.type === 'multi') {
|
|
|
+ count += row.selected.size;
|
|
|
+ } else if (row.type === 'single' && row.selected !== row.default) {
|
|
|
+ count += 1;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return count;
|
|
|
+ });
|
|
|
+
|
|
|
+ const clearFilters = () => {
|
|
|
+ query = '';
|
|
|
+ filters = filters.map((row) => {
|
|
|
+ if (row.type === 'multi') {
|
|
|
+ row.selected.clear();
|
|
|
+ return row;
|
|
|
+ } else if (row.type === 'single') {
|
|
|
+ return { ...row, selected: row.default };
|
|
|
+ }
|
|
|
+ return row;
|
|
|
+ });
|
|
|
+ };
|
|
|
+</script>
|
|
|
+
|
|
|
+<div class="sticky top-0 z-10 bg-background/50 p-6 backdrop-blur-lg">
|
|
|
+ <!-- toolbar -->
|
|
|
+ <div class="mb-4 flex items-center gap-2">
|
|
|
+ <InputGroup.Root class="max-w-64 flex-1">
|
|
|
+ <InputGroup.Addon>
|
|
|
+ <Search size={14} strokeWidth={2} />
|
|
|
+ </InputGroup.Addon>
|
|
|
+ <InputGroup.Input placeholder="Search by name…" bind:value={query} />
|
|
|
+ </InputGroup.Root>
|
|
|
+ <Filters.Toggle
|
|
|
+ count={activeFilterCount}
|
|
|
+ shown={filtersShown}
|
|
|
+ onclick={() => (filtersShown = !filtersShown)}
|
|
|
+ />
|
|
|
+ <div class="flex-1"></div>
|
|
|
+ <Button href={creationLink} size="sm">
|
|
|
+ <Plus size={14} strokeWidth={2} />
|
|
|
+ New
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- filter panel -->
|
|
|
+ <Filters.Root class="mb-4" shown={filtersShown} bind:rows={filters} />
|
|
|
+
|
|
|
+ <!-- result count -->
|
|
|
+ {#if itemCount !== undefined}
|
|
|
+ <p class="text-xs text-white/30">
|
|
|
+ {#if items.length === itemCount}
|
|
|
+ {itemCount}
|
|
|
+ {itemCount === 1 ? itemName : itemNamePlural}
|
|
|
+ {:else}
|
|
|
+ {items.length} of {itemCount} {itemNamePlural}
|
|
|
+ {/if}
|
|
|
+ </p>
|
|
|
+ {/if}
|
|
|
+</div>
|
|
|
+
|
|
|
+<div class="p-6">
|
|
|
+ <!-- grid -->
|
|
|
+ {#if items.length > 0}
|
|
|
+ <List
|
|
|
+ class="mb-4 grid max-w-400 grid-cols-1 gap-3 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
|
|
|
+ {items}
|
|
|
+ key={(i) => itemKey(i)}
|
|
|
+ >
|
|
|
+ {#snippet itemComponent(item, idx)}
|
|
|
+ {@render itemRenderer(item, idx)}
|
|
|
+ {/snippet}
|
|
|
+ </List>
|
|
|
+ {:else}
|
|
|
+ <!-- empty states -->
|
|
|
+ <Empty icon={itemIcon}>
|
|
|
+ {#snippet caption()}
|
|
|
+ {#if activeFilterCount > 0}
|
|
|
+ <div class="text-sm text-white/45">No {itemNamePlural} match the filters</div>
|
|
|
+ <Button size="sm" variant="secondary" onclick={() => clearFilters()}>
|
|
|
+ Clear filters
|
|
|
+ </Button>
|
|
|
+ {:else}
|
|
|
+ <div class="text-sm text-white/45">No {itemNamePlural} yet</div>
|
|
|
+ <Button href={creationLink} size="sm">
|
|
|
+ <Plus size={14} strokeWidth={2} />
|
|
|
+ New {itemName}
|
|
|
+ </Button>
|
|
|
+ {/if}
|
|
|
+ {/snippet}
|
|
|
+ </Empty>
|
|
|
+ {/if}
|
|
|
+</div>
|