refactor(admin): extract shared list filtering utilities

Introduces createListFilters() with type-safe, reactive filtering and sorting
for admin list pages. Eliminates ~120 lines of duplicate code across projects
and posts pages.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-10-07 07:41:13 -07:00
parent c67dbeaf38
commit 66d5240240
3 changed files with 220 additions and 146 deletions

View file

@ -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<T> {
field: keyof T
default: FilterValue
}
interface FilterConfig<T> {
[key: string]: FilterDefinition<T>
}
interface SortConfig<T> {
[key: string]: (a: T, b: T) => number
}
interface ListFiltersConfig<T> {
filters: FilterConfig<T>
sorts: SortConfig<T>
defaultSort: string
}
export interface ListFiltersResult<T> {
/** Current filter values */
values: Record<string, FilterValue>
/** 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<T>(
sourceItems: T[],
config: ListFiltersConfig<T>
): ListFiltersResult<T> {
// Initialize filter state from config defaults
const initialValues = Object.entries(config.filters).reduce(
(acc, [key, def]) => {
acc[key] = def.default
return acc
},
{} as Record<string, FilterValue>
)
let filterValues = $state<Record<string, FilterValue>>(initialValues)
let currentSort = $state<string>(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: <T>(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: <T>(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: <T>(field: keyof T) => (a: T, b: T) =>
String(a[field] || '').localeCompare(String(b[field] || '')),
/** Sort by string field, Z-A */
stringDesc: <T>(field: keyof T) => (a: T, b: T) =>
String(b[field] || '').localeCompare(String(a[field] || '')),
/** Sort by number field, ascending */
numberAsc: <T>(field: keyof T) => (a: T, b: T) =>
Number(a[field]) - Number(b[field]),
/** Sort by number field, descending */
numberDesc: <T>(field: keyof T) => (a: T, b: T) =>
Number(b[field]) - Number(a[field]),
/** Sort by status field, published first */
statusPublishedFirst: <T>(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: <T>(field: keyof T) => (a: T, b: T) => {
if (a[field] === b[field]) return 0
return a[field] === 'draft' ? -1 : 1
}
}

View file

@ -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<AdminPost[]>([...posts])
// Create reactive filters
const filters = createListFilters(posts, {
filters: {
type: { field: 'postType', default: 'all' },
status: { field: 'status', default: 'all' }
},
sorts: {
newest: commonSorts.dateDesc<AdminPost>('createdAt'),
oldest: commonSorts.dateAsc<AdminPost>('createdAt'),
'title-asc': commonSorts.stringAsc<AdminPost>('title'),
'title-desc': commonSorts.stringDesc<AdminPost>('title'),
'status-published': commonSorts.statusPublishedFirst<AdminPost>('status'),
'status-draft': commonSorts.statusDraftFirst<AdminPost>('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() {
<AdminFilters>
{#snippet left()}
<Select
bind:value={selectedTypeFilter}
value={filters.values.type}
options={typeFilterOptions}
size="small"
variant="minimal"
onchange={handleTypeFilterChange}
onchange={(e) => filters.set('type', (e.target as HTMLSelectElement).value)}
/>
<Select
bind:value={selectedStatusFilter}
value={filters.values.status}
options={statusFilterOptions}
size="small"
variant="minimal"
onchange={handleStatusFilterChange}
onchange={(e) => filters.set('status', (e.target as HTMLSelectElement).value)}
/>
{/snippet}
{#snippet right()}
<Select
bind:value={sortBy}
value={filters.sort}
options={sortOptions}
size="small"
variant="minimal"
onchange={handleSortChange}
onchange={(e) => filters.setSort((e.target as HTMLSelectElement).value)}
/>
{/snippet}
</AdminFilters>
@ -224,12 +181,12 @@ function handleSortChange() {
<div class="error-message">{actionError}</div>
{/if}
{#if filteredPosts.length === 0}
{#if filters.items.length === 0}
<div class="empty-state">
<div class="empty-icon">📝</div>
<h3>No posts found</h3>
<p>
{#if selectedTypeFilter === 'all' && selectedStatusFilter === 'all'}
{#if filters.values.type === 'all' && filters.values.status === 'all'}
Create your first post to get started!
{:else}
No posts found matching the current filters. Try adjusting your filters or create a new
@ -239,7 +196,7 @@ function handleSortChange() {
</div>
{:else}
<div class="posts-list">
{#each filteredPosts as post (post.id)}
{#each filters.items as post (post.id)}
<PostListItem
{post}
on:edit={handleEdit}

View file

@ -8,6 +8,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 { AdminProject } from '$lib/types/admin'
@ -16,13 +17,27 @@
let showDeleteModal = false
let projectToDelete: AdminProject | null = null
let selectedTypeFilter: string = 'all'
let selectedStatusFilter: string = 'all'
let sortBy: string = 'newest'
const actionError = form?.message ?? ''
const projects = data.items ?? []
let filteredProjects = $state<AdminProject[]>([...projects])
// Create reactive filters
const filters = createListFilters(projects, {
filters: {
type: { field: 'projectType', default: 'all' },
status: { field: 'status', default: 'all' }
},
sorts: {
newest: commonSorts.dateDesc<AdminProject>('createdAt'),
oldest: commonSorts.dateAsc<AdminProject>('createdAt'),
'title-asc': commonSorts.stringAsc<AdminProject>('title'),
'title-desc': commonSorts.stringDesc<AdminProject>('title'),
'year-desc': commonSorts.numberDesc<AdminProject>('year'),
'year-asc': commonSorts.numberAsc<AdminProject>('year'),
'status-published': commonSorts.statusPublishedFirst<AdminProject>('status'),
'status-draft': commonSorts.statusDraftFirst<AdminProject>('status')
},
defaultSort: 'newest'
})
let toggleForm: HTMLFormElement | null = null
let toggleIdField: HTMLInputElement | null = null
@ -55,68 +70,6 @@
{ value: 'status-draft', label: 'Draft first' }
]
function applyFilterAndSort() {
let next = [...projects]
if (selectedStatusFilter !== 'all') {
next = next.filter((project) => project.status === selectedStatusFilter)
}
if (selectedTypeFilter !== 'all') {
next = next.filter((project) => project.projectType === selectedTypeFilter)
}
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 'year-desc':
next.sort((a, b) => b.year - a.year)
break
case 'year-asc':
next.sort((a, b) => a.year - b.year)
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
}
filteredProjects = next
}
applyFilterAndSort()
function handleTypeFilterChange() {
applyFilterAndSort()
}
function handleStatusFilterChange() {
applyFilterAndSort()
}
function handleSortChange() {
applyFilterAndSort()
}
onMount(() => {
document.addEventListener('click', handleOutsideClick)
return () => document.removeEventListener('click', handleOutsideClick)
@ -185,27 +138,27 @@
<AdminFilters>
{#snippet left()}
<Select
bind:value={selectedTypeFilter}
value={filters.values.type}
options={typeFilterOptions}
size="small"
variant="minimal"
onchange={handleTypeFilterChange}
onchange={(e) => filters.set('type', (e.target as HTMLSelectElement).value)}
/>
<Select
bind:value={selectedStatusFilter}
value={filters.values.status}
options={statusFilterOptions}
size="small"
variant="minimal"
onchange={handleStatusFilterChange}
onchange={(e) => filters.set('status', (e.target as HTMLSelectElement).value)}
/>
{/snippet}
{#snippet right()}
<Select
bind:value={sortBy}
value={filters.sort}
options={sortOptions}
size="small"
variant="minimal"
onchange={handleSortChange}
onchange={(e) => filters.setSort((e.target as HTMLSelectElement).value)}
/>
{/snippet}
</AdminFilters>
@ -214,11 +167,11 @@
<div class="error">{actionError}</div>
{/if}
{#if filteredProjects.length === 0}
{#if filters.items.length === 0}
<div class="empty-state">
<h3>No projects found</h3>
<p>
{#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 @@
</div>
{:else}
<div class="projects-list">
{#each filteredProjects as project (project.id)}
{#each filters.items as project (project.id)}
<ProjectListItem
{project}
on:edit={handleEdit}