diff --git a/src/lib/admin/listFilters.svelte.ts b/src/lib/admin/listFilters.svelte.ts new file mode 100644 index 0000000..44eee41 --- /dev/null +++ b/src/lib/admin/listFilters.svelte.ts @@ -0,0 +1,164 @@ +/** + * Shared list filtering and sorting utilities for admin pages. + * Eliminates duplication across projects, posts, and media list pages. + */ + +type FilterValue = string | number | boolean + +interface FilterDefinition { + field: keyof T + default: FilterValue +} + +interface FilterConfig { + [key: string]: FilterDefinition +} + +interface SortConfig { + [key: string]: (a: T, b: T) => number +} + +interface ListFiltersConfig { + filters: FilterConfig + sorts: SortConfig + defaultSort: string +} + +export interface ListFiltersResult { + /** Current filter values */ + values: Record + /** Current sort key */ + sort: string + /** Filtered and sorted items */ + items: T[] + /** Number of items after filtering */ + count: number + /** Set a filter value */ + set: (filterKey: string, value: FilterValue) => void + /** Set the current sort */ + setSort: (sortKey: string) => void + /** Reset all filters to defaults */ + reset: () => void +} + +/** + * Creates a reactive list filter store using Svelte 5 runes. + * Must be called within component context. + * + * @example + * const filters = createListFilters(projects, { + * filters: { + * type: { field: 'projectType', default: 'all' }, + * status: { field: 'status', default: 'all' } + * }, + * sorts: { + * newest: (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + * oldest: (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + * }, + * defaultSort: 'newest' + * }) + */ +export function createListFilters( + sourceItems: T[], + config: ListFiltersConfig +): ListFiltersResult { + // Initialize filter state from config defaults + const initialValues = Object.entries(config.filters).reduce( + (acc, [key, def]) => { + acc[key] = def.default + return acc + }, + {} as Record + ) + + let filterValues = $state>(initialValues) + let currentSort = $state(config.defaultSort) + + // Derived filtered and sorted items + const filteredItems = $derived.by(() => { + let result = [...sourceItems] + + // Apply all filters + for (const [filterKey, filterDef] of Object.entries(config.filters)) { + const value = filterValues[filterKey] + // Skip filtering if value is 'all' (common default for show-all state) + if (value !== 'all') { + result = result.filter((item) => item[filterDef.field] === value) + } + } + + // Apply sort + const sortFn = config.sorts[currentSort] + if (sortFn) { + result.sort(sortFn) + } + + return result + }) + + return { + get values() { + return filterValues + }, + get sort() { + return currentSort + }, + get items() { + return filteredItems + }, + get count() { + return filteredItems.length + }, + set(filterKey: string, value: FilterValue) { + filterValues[filterKey] = value + }, + setSort(sortKey: string) { + currentSort = sortKey + }, + reset() { + filterValues = { ...initialValues } + currentSort = config.defaultSort + } + } +} + +/** + * Common sort functions for reuse across list pages + */ +export const commonSorts = { + /** Sort by date field, newest first */ + dateDesc: (field: keyof T) => (a: T, b: T) => + new Date(b[field] as string).getTime() - new Date(a[field] as string).getTime(), + + /** Sort by date field, oldest first */ + dateAsc: (field: keyof T) => (a: T, b: T) => + new Date(a[field] as string).getTime() - new Date(b[field] as string).getTime(), + + /** Sort by string field, A-Z */ + stringAsc: (field: keyof T) => (a: T, b: T) => + String(a[field] || '').localeCompare(String(b[field] || '')), + + /** Sort by string field, Z-A */ + stringDesc: (field: keyof T) => (a: T, b: T) => + String(b[field] || '').localeCompare(String(a[field] || '')), + + /** Sort by number field, ascending */ + numberAsc: (field: keyof T) => (a: T, b: T) => + Number(a[field]) - Number(b[field]), + + /** Sort by number field, descending */ + numberDesc: (field: keyof T) => (a: T, b: T) => + Number(b[field]) - Number(a[field]), + + /** Sort by status field, published first */ + statusPublishedFirst: (field: keyof T) => (a: T, b: T) => { + if (a[field] === b[field]) return 0 + return a[field] === 'published' ? -1 : 1 + }, + + /** Sort by status field, draft first */ + statusDraftFirst: (field: keyof T) => (a: T, b: T) => { + if (a[field] === b[field]) return 0 + return a[field] === 'draft' ? -1 : 1 + } +} diff --git a/src/routes/admin/posts/+page.svelte b/src/routes/admin/posts/+page.svelte index 449d657..c4c3ecc 100644 --- a/src/routes/admin/posts/+page.svelte +++ b/src/routes/admin/posts/+page.svelte @@ -9,6 +9,7 @@ import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte' import Button from '$lib/components/admin/Button.svelte' import Select from '$lib/components/admin/Select.svelte' + import { createListFilters, commonSorts } from '$lib/admin/listFilters.svelte' import type { PageData } from './$types' import type { AdminPost } from '$lib/types/admin' @@ -18,13 +19,25 @@ let showInlineComposer = true let showDeleteConfirmation = false let postToDelete: AdminPost | null = null -let selectedTypeFilter = 'all' -let selectedStatusFilter = 'all' -let sortBy = 'newest' - const actionError = form?.message ?? '' const posts = data.items ?? [] -let filteredPosts = $state([...posts]) + +// Create reactive filters +const filters = createListFilters(posts, { + filters: { + type: { field: 'postType', default: 'all' }, + status: { field: 'status', default: 'all' } + }, + sorts: { + newest: commonSorts.dateDesc('createdAt'), + oldest: commonSorts.dateAsc('createdAt'), + 'title-asc': commonSorts.stringAsc('title'), + 'title-desc': commonSorts.stringDesc('title'), + 'status-published': commonSorts.statusPublishedFirst('status'), + 'status-draft': commonSorts.statusDraftFirst('status') + }, + defaultSort: 'newest' +}) let toggleForm: HTMLFormElement | null = null let toggleIdField: HTMLInputElement | null = null @@ -55,62 +68,6 @@ const statusFilterOptions = [ { value: 'status-draft', label: 'Draft first' } ] -function applyFilterAndSort() { - let next = [...posts] - - if (selectedTypeFilter !== 'all') { - next = next.filter((post) => post.postType === selectedTypeFilter) - } - - if (selectedStatusFilter !== 'all') { - next = next.filter((post) => post.status === selectedStatusFilter) - } - - switch (sortBy) { - case 'oldest': - next.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) - break - case 'title-asc': - next.sort((a, b) => (a.title || '').localeCompare(b.title || '')) - break - case 'title-desc': - next.sort((a, b) => (b.title || '').localeCompare(a.title || '')) - break - case 'status-published': - next.sort((a, b) => { - if (a.status === b.status) return 0 - return a.status === 'published' ? -1 : 1 - }) - break - case 'status-draft': - next.sort((a, b) => { - if (a.status === b.status) return 0 - return a.status === 'draft' ? -1 : 1 - }) - break - case 'newest': - default: - next.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) - break - } - - filteredPosts = next -} - -applyFilterAndSort() - -function handleTypeFilterChange() { - applyFilterAndSort() -} - -function handleStatusFilterChange() { - applyFilterAndSort() -} - -function handleSortChange() { - applyFilterAndSort() -} - onMount(() => { document.addEventListener('click', handleOutsideClick) return () => document.removeEventListener('click', handleOutsideClick) @@ -195,27 +152,27 @@ function handleSortChange() { {#snippet left()} filters.set('status', (e.target as HTMLSelectElement).value)} /> {/snippet} {#snippet right()} filters.set('type', (e.target as HTMLSelectElement).value)} /> filters.setSort((e.target as HTMLSelectElement).value)} /> {/snippet} @@ -214,11 +167,11 @@
{actionError}
{/if} - {#if filteredProjects.length === 0} + {#if filters.items.length === 0}

No projects found

- {#if selectedTypeFilter === 'all' && selectedStatusFilter === 'all'} + {#if filters.values.type === 'all' && filters.values.status === 'all'} Create your first project to get started! {:else} No projects found matching the current filters. Try adjusting your filters or create a new @@ -228,7 +181,7 @@

{:else}
- {#each filteredProjects as project (project.id)} + {#each filters.items as project (project.id)}