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:
parent
c67dbeaf38
commit
66d5240240
3 changed files with 220 additions and 146 deletions
164
src/lib/admin/listFilters.svelte.ts
Normal file
164
src/lib/admin/listFilters.svelte.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue